diff --git a/bun.lock b/bun.lock index 8d71f40a72..61834997fc 100644 --- a/bun.lock +++ b/bun.lock @@ -16,14 +16,17 @@ }, "integrations/1password": { "name": "@slates-integrations/1password", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/21risk": { @@ -136,10 +139,11 @@ }, "integrations/activecampaign": { "name": "@slates-integrations/activecampaign", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -196,10 +200,11 @@ }, "integrations/adobe-sign": { "name": "@slates-integrations/adobe-sign", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -244,14 +249,17 @@ }, "integrations/affinda": { "name": "@slates-integrations/affinda", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/affinity": { @@ -557,14 +565,17 @@ }, "integrations/amplitude": { "name": "@slates-integrations/amplitude", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/anchor-browser": { @@ -666,14 +677,17 @@ }, "integrations/api2pdf": { "name": "@slates-integrations/api2pdf", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/apiflash": { @@ -835,14 +849,17 @@ }, "integrations/asana": { "name": "@slates-integrations/asana", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/ascora": { @@ -871,14 +888,17 @@ }, "integrations/assembly-ai": { "name": "@slates-integrations/assemblyai", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/astica-ai": { @@ -919,14 +939,17 @@ }, "integrations/auth0": { "name": "@slates-integrations/auth0", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/autobound": { @@ -966,22 +989,26 @@ }, "integrations/aws-cognito": { "name": "@slates-integrations/aws-cognito", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/aws-dynamodb": { "name": "@slates-integrations/aws-dynamodb", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -1021,22 +1048,26 @@ }, "integrations/aws-ses": { "name": "@slates-integrations/aws-ses", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/aws-sns": { "name": "@slates-integrations/aws-sns", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -1045,26 +1076,32 @@ }, "integrations/aws-sqs": { "name": "@slates-integrations/aws-sqs", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/aws-transcribe": { "name": "@slates-integrations/aws-transcribe", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/ayrshare": { @@ -1136,10 +1173,11 @@ }, "integrations/azure-speech": { "name": "@slates-integrations/azure-speech", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -1376,14 +1414,17 @@ }, "integrations/bigcommerce": { "name": "@slates-integrations/bigcommerce", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/bigmailer": { @@ -1424,10 +1465,11 @@ }, "integrations/bigquery": { "name": "@slates-integrations/bigquery", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -1677,14 +1719,17 @@ }, "integrations/box": { "name": "@slates-integrations/box", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/boxhero": { @@ -1701,10 +1746,11 @@ }, "integrations/braintree": { "name": "@slates-integrations/braintree", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -1725,14 +1771,17 @@ }, "integrations/braze": { "name": "@slates-integrations/braze", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/breathehr": { @@ -1761,13 +1810,15 @@ }, "integrations/brevo": { "name": "@slates-integrations/brevo", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", }, }, @@ -1821,26 +1872,32 @@ }, "integrations/browserbase-tool": { "name": "@slates-integrations/browserbase", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/browserless": { "name": "@slates-integrations/browserless", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/btcpay-server": { @@ -2013,14 +2070,17 @@ }, "integrations/cal-com": { "name": "@slates-integrations/calcom", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/calendarhero": { @@ -2386,10 +2446,11 @@ }, "integrations/clearbit": { "name": "@slates-integrations/clearbit", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -2447,10 +2508,11 @@ }, "integrations/clickup": { "name": "@slates-integrations/clickup", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -2532,10 +2594,11 @@ }, "integrations/cloudflare-workers": { "name": "@slates-integrations/cloudflare-workers", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -2580,10 +2643,11 @@ }, "integrations/coda": { "name": "@slates-integrations/coda", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -2640,22 +2704,26 @@ }, "integrations/cohere": { "name": "@slates-integrations/cohere", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/coinbase": { "name": "@slates-integrations/coinbase", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -2809,14 +2877,17 @@ }, "integrations/convertkit": { "name": "@slates-integrations/convertkit", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/convex": { @@ -2917,14 +2988,17 @@ }, "integrations/crisp": { "name": "@slates-integrations/crisp", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/crowdin": { @@ -3025,14 +3099,17 @@ }, "integrations/customer-io": { "name": "@slates-integrations/customerio", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/customgpt": { @@ -3157,26 +3234,32 @@ }, "integrations/databricks": { "name": "@slates-integrations/databricks", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/datadog": { "name": "@slates-integrations/datadog", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/dataforseo": { @@ -3185,7 +3268,7 @@ "dependencies": { "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -3293,14 +3376,17 @@ }, "integrations/deepgram": { "name": "@slates-integrations/deepgram", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/deepimage": { @@ -3317,10 +3403,11 @@ }, "integrations/deepseek": { "name": "@slates-integrations/deepseek", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -3499,10 +3586,11 @@ }, "integrations/docker-hub": { "name": "@slates-integrations/docker-hub", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -3535,10 +3623,11 @@ }, "integrations/docparser": { "name": "@slates-integrations/docparser", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -3583,14 +3672,17 @@ }, "integrations/docsumo": { "name": "@slates-integrations/docsumo", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/docugenerate": { @@ -3667,14 +3759,17 @@ }, "integrations/docusign": { "name": "@slates-integrations/docusign", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.8", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/doodle": { @@ -3775,14 +3870,17 @@ }, "integrations/drift": { "name": "@slates-integrations/drift", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/drip": { @@ -3823,14 +3921,17 @@ }, "integrations/dropbox": { "name": "@slates-integrations/dropbox", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.8", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/dropcontact": { @@ -3971,14 +4072,17 @@ }, "integrations/eleven-labs": { "name": "@slates-integrations/elevenlabs", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/elevenreader": { @@ -4271,14 +4375,17 @@ }, "integrations/evernote": { "name": "@slates-integrations/evernote", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/eversign": { @@ -4701,10 +4808,11 @@ }, "integrations/fly-io": { "name": "@slates-integrations/flyio", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -4857,46 +4965,56 @@ }, "integrations/freshdesk": { "name": "@slates-integrations/freshdesk", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/freshsales": { "name": "@slates-integrations/freshsales", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/freshservice": { "name": "@slates-integrations/freshservice", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/front": { "name": "@slates-integrations/front", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -4917,14 +5035,17 @@ }, "integrations/fullstory": { "name": "@slates-integrations/fullstory", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/gagelist": { @@ -5646,10 +5767,11 @@ }, "integrations/grafana": { "name": "@slates-integrations/grafana", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -5718,14 +5840,17 @@ }, "integrations/groqcloud": { "name": "@slates-integrations/groqcloud", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/gtmetrix": { @@ -5742,14 +5867,17 @@ }, "integrations/gumroad": { "name": "@slates-integrations/gumroad", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/gusto": { @@ -5898,13 +6026,15 @@ }, "integrations/hellosign": { "name": "@slates-integrations/hellosign", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", }, }, @@ -5922,10 +6052,11 @@ }, "integrations/helpscout": { "name": "@slates-integrations/help-scout", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -6116,10 +6247,11 @@ }, "integrations/hotjar": { "name": "@slates-integrations/hotjar", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -6179,14 +6311,17 @@ }, "integrations/huggingface": { "name": "@slates-integrations/hugging-face", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/humanitix": { @@ -6589,14 +6724,17 @@ }, "integrations/iterable": { "name": "@slates-integrations/iterable", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/jenkins": { @@ -6759,13 +6897,15 @@ }, "integrations/kibana": { "name": "@slates-integrations/kibana", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", }, }, @@ -6783,26 +6923,32 @@ }, "integrations/kit": { "name": "@slates-integrations/kit", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/klaviyo": { "name": "@slates-integrations/klaviyo", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/klazify": { @@ -6891,10 +7037,11 @@ }, "integrations/kubernetes": { "name": "@slates-integrations/kubernetes", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -6951,14 +7098,17 @@ }, "integrations/langbase": { "name": "@slates-integrations/langbase", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/laposta": { @@ -7083,14 +7233,17 @@ }, "integrations/lemon-squeezy": { "name": "@slates-integrations/lemon-squeezy", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/lessonspace": { @@ -7396,10 +7549,11 @@ }, "integrations/magento": { "name": "@slates-integrations/magento", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -7493,14 +7647,17 @@ }, "integrations/mailerlite": { "name": "@slates-integrations/mailerlite", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/mailersend": { @@ -7517,14 +7674,17 @@ }, "integrations/mailgun": { "name": "@slates-integrations/mailgun", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/mails-so": { @@ -7722,10 +7882,11 @@ }, "integrations/metaads": { "name": "@slates-integrations/meta-ads", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -7758,14 +7919,15 @@ }, "integrations/metorial-admin": { "name": "@slates-integrations/metorial-admin", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "dependencies": { "@lowerdeck/error": "^1.1.0", - "@slates/provider": "1.0.0-rc.15", + "@slates/provider": "1.0.0-rc.16", "@types/node": "^20", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", "vitest": "^3.1.2", }, @@ -7796,7 +7958,7 @@ }, "integrations/microsoft-fabric": { "name": "@slates-integrations/microsoft-fabric", - "version": "0.1.0-rc.3", + "version": "0.1.0-rc.5", "dependencies": { "@lowerdeck/error": "^1.1.0", "@slates/provider": "1.0.0-rc.15", @@ -7825,14 +7987,17 @@ }, "integrations/midjourney": { "name": "@slates-integrations/midjourney", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/minerstat": { @@ -7873,14 +8038,17 @@ }, "integrations/mistral-ai": { "name": "@slates-integrations/mistral-ai", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/mixmax": { @@ -7897,14 +8065,17 @@ }, "integrations/mixpanel": { "name": "@slates-integrations/mixpanel", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/mocean": { @@ -7957,14 +8128,17 @@ }, "integrations/monday": { "name": "@slates-integrations/mondaycom", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/moneybird": { @@ -7981,26 +8155,32 @@ }, "integrations/mongodb": { "name": "@slates-integrations/mongodb", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/mongodb-atlas": { "name": "@slates-integrations/mongodb-atlas", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/moonclerk": { @@ -8125,14 +8305,18 @@ }, "integrations/mysql": { "name": "@slates-integrations/mysql", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "mysql2": "^3.22.5", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/n8n": { @@ -8161,14 +8345,17 @@ }, "integrations/nano-nets": { "name": "@slates-integrations/nanonets", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/nasa": { @@ -8209,14 +8396,17 @@ }, "integrations/neon": { "name": "@slates-integrations/neon", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/netlify": { @@ -8282,14 +8472,17 @@ }, "integrations/new-relic": { "name": "@slates-integrations/new-relic", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/newsdata-io": { @@ -8475,22 +8668,26 @@ }, "integrations/ocr-web-service": { "name": "@slates-integrations/ocr-web-service", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/ocrspace": { "name": "@slates-integrations/ocrspace", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.8", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -8523,10 +8720,11 @@ }, "integrations/okta": { "name": "@slates-integrations/okta", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -8535,14 +8733,17 @@ }, "integrations/ollama": { "name": "@slates-integrations/ollama", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/omnisend": { @@ -8696,26 +8897,32 @@ }, "integrations/openrouter": { "name": "@slates-integrations/openrouter", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/opsgenie": { "name": "@slates-integrations/opsgenie", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/optimizely": { @@ -8808,26 +9015,32 @@ }, "integrations/pagerduty": { "name": "@slates-integrations/pagerduty", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/pandadoc": { "name": "@slates-integrations/pandadoc", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.8", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/paperform": { @@ -9013,10 +9226,11 @@ }, "integrations/pdf-api-io": { "name": "@slates-integrations/pdf-apiio", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -9025,10 +9239,11 @@ }, "integrations/pdf-co": { "name": "@slates-integrations/pdfco", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -9037,14 +9252,17 @@ }, "integrations/pdf4me": { "name": "@slates-integrations/pdf4me", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/pdfless": { @@ -9061,26 +9279,32 @@ }, "integrations/pdfmonkey": { "name": "@slates-integrations/pdfmonkey", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/pendo": { "name": "@slates-integrations/pendo", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/penpot": { @@ -9121,10 +9345,11 @@ }, "integrations/perplexityai": { "name": "@slates-integrations/perplexity-ai", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -9217,14 +9442,17 @@ }, "integrations/pinecone": { "name": "@slates-integrations/pinecone", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/pingdom": { @@ -9327,14 +9555,17 @@ }, "integrations/planetscale": { "name": "@slates-integrations/planetscale", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/planly": { @@ -9508,10 +9739,11 @@ }, "integrations/posthog": { "name": "@slates-integrations/posthog", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -9532,14 +9764,17 @@ }, "integrations/postmark": { "name": "@slates-integrations/postmark", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/power-bi": { @@ -9848,10 +10083,11 @@ }, "integrations/railway": { "name": "@slates-integrations/railway", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -9969,10 +10205,11 @@ }, "integrations/redis": { "name": "@slates-integrations/redis", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -10053,14 +10290,17 @@ }, "integrations/render": { "name": "@slates-integrations/render", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/renderform": { @@ -10101,14 +10341,17 @@ }, "integrations/replicate": { "name": "@slates-integrations/replicate", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/reply-io": { @@ -10125,14 +10368,17 @@ }, "integrations/resend": { "name": "@slates-integrations/resend", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.8", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/resistant-ai": { @@ -10655,14 +10901,17 @@ }, "integrations/segment": { "name": "@slates-integrations/segment", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/segmetrics": { @@ -10751,14 +11000,17 @@ }, "integrations/sendgrid": { "name": "@slates-integrations/sendgrid", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.8", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/sendlane": { @@ -10908,14 +11160,17 @@ }, "integrations/servicenow": { "name": "@slates-integrations/servicenow", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/sevdesk": { @@ -11396,10 +11651,11 @@ }, "integrations/splunk": { "name": "@slates-integrations/splunk", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -11468,10 +11724,11 @@ }, "integrations/square": { "name": "@slates-integrations/square", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -11480,14 +11737,17 @@ }, "integrations/squarespace": { "name": "@slates-integrations/squarespace", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/sslmate-cert-spotter-api": { @@ -11504,14 +11764,17 @@ }, "integrations/stability-ai": { "name": "@slates-integrations/stability-ai", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/stack-ai": { @@ -11775,14 +12038,17 @@ }, "integrations/supabase": { "name": "@slates-integrations/supabase", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/supadata": { @@ -11947,14 +12213,17 @@ }, "integrations/taggun": { "name": "@slates-integrations/taggun", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/talenthr": { @@ -12440,14 +12709,17 @@ }, "integrations/trello": { "name": "@slates-integrations/trello", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/triggercmd": { @@ -12549,14 +12821,17 @@ }, "integrations/twilio-sendgrid": { "name": "@slates-integrations/twilio-sendgrid", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/twitch": { @@ -13008,14 +13283,17 @@ }, "integrations/webflow": { "name": "@slates-integrations/webflow", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/webscraper-io": { @@ -13140,14 +13418,17 @@ }, "integrations/wix": { "name": "@slates-integrations/wix", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.7", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/wiza": { @@ -13176,14 +13457,17 @@ }, "integrations/woocommerce": { "name": "@slates-integrations/woocommerce", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/woodpecker-co": { @@ -13214,14 +13498,17 @@ }, "integrations/wordpress": { "name": "@slates-integrations/wordpress", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/workable": { @@ -13547,14 +13834,17 @@ }, "integrations/zoho-crm": { "name": "@slates-integrations/zoho-crm", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/zoho-desk": { @@ -13674,7 +13964,7 @@ "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "axios": "^1.13.2", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", }, "devDependencies": { "@slates/tsconfig": "1.0.0-rc.1", @@ -13720,7 +14010,7 @@ "dependencies": { "@lowerdeck/error": "^1.1.0", "@slates/tool-recipes": "1.0.0-rc.4", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -13733,7 +14023,7 @@ "name": "@slates/oauth-microsoft", "version": "1.0.0-rc.5", "dependencies": { - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", }, "devDependencies": { "@slates/tsconfig": "1.0.0-rc.1", @@ -13811,7 +14101,7 @@ "version": "1.0.0-rc.8", "dependencies": { "@lowerdeck/error": "^1.1.0", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -13838,11 +14128,11 @@ "version": "1.0.0-rc.9", "dependencies": { "@lowerdeck/testing-tools": "latest", - "@slates/client": "1.0.0-rc.12", - "@slates/profiles": "1.0.0-rc.10", + "@slates/client": "1.0.0-rc.13", + "@slates/profiles": "1.0.0-rc.11", }, "devDependencies": { - "@slates/provider": "1.0.0-rc.15", + "@slates/provider": "1.0.0-rc.16", "@slates/tsconfig": "1.0.0-rc.1", "typescript": "5.8.2", "vitest": "^3.1.2", @@ -13853,7 +14143,7 @@ "name": "@slates/tool-recipes", "version": "1.0.0-rc.4", "dependencies": { - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -16910,6 +17200,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="], "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], @@ -17046,6 +17338,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -17138,6 +17432,8 @@ "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], "generic-names": ["generic-names@4.0.0", "", { "dependencies": { "loader-utils": "^3.2.0" } }, "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A=="], @@ -17234,6 +17530,8 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -17296,10 +17594,14 @@ "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="], @@ -17330,8 +17632,12 @@ "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + "mysql2": ["mysql2@3.22.5", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="], @@ -17568,6 +17874,8 @@ "sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="], + "sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="], + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], "stable": ["stable@0.1.8", "", {}, "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w=="], @@ -17742,8 +18050,6 @@ "@rollup/pluginutils/rollup": ["rollup@2.80.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="], - "@slates-integrations/1password/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/21risk/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/2chat/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -17760,8 +18066,6 @@ "@slates-integrations/acculynx/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/activecampaign/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/activetrail/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/acuity-scheduling/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -17772,16 +18076,12 @@ "@slates-integrations/adobe-creative-cloud/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/adobe-sign/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/adrapid/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/adyntel/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/aeroleads/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/affinda/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/affinity/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/agencyzoom/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -17834,8 +18134,6 @@ "@slates-integrations/amcards/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/amplitude/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/anchor-browser/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/anonyflow/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -17850,8 +18148,6 @@ "@slates-integrations/api-sports/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/api2pdf/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/apibible/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/apiflash/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -17880,40 +18176,22 @@ "@slates-integrations/aryn/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/asana/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/ascora/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/ashby/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/assemblyai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/astica-ai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/async-interview/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/attio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/auth0/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/autobound/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/aws-cognito/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/aws-dynamodb/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/aws-lambda/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/aws-s3/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/aws-ses/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/aws-sns/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/aws-sqs/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/aws-transcribe/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/ayrshare/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/azure-blob-storage/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -17924,8 +18202,6 @@ "@slates-integrations/azure-repos/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/azure-speech/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/backendless/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/bamboohr/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -17962,8 +18238,6 @@ "@slates-integrations/bidsketch/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/bigcommerce/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/bigdatacloud/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/bigmailer/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -17972,8 +18246,6 @@ "@slates-integrations/bigpictureio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/bigquery/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/bitbucket/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/bitquery/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18014,22 +18286,14 @@ "@slates-integrations/bouncer/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/box/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/boxhero/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/braintree/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/brandfetch/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/braze/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/breathe-hr/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/breeze/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/brevo/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/brex/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/bright-data/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18038,10 +18302,6 @@ "@slates-integrations/browseai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/browserbase/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/browserless/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/btcpay-server/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/bubble/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18070,8 +18330,6 @@ "@slates-integrations/cabinpanda/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/calcom/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/calendarhero/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/calendly/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -18134,8 +18392,6 @@ "@slates-integrations/classmarker/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/clearbit/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/clearout/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/clickhouse/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18144,8 +18400,6 @@ "@slates-integrations/clicksend/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/clickup/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/clientary/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/clockify/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18158,16 +18412,12 @@ "@slates-integrations/cloudflare/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/cloudflare-workers/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/cloudinary/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/cloudlayer/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/coassemble/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/coda/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/codacy/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/codemagic/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18176,10 +18426,6 @@ "@slates-integrations/cody/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/cohere/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/coinbase/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/coinmarketcal/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/coinmarketcap/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18204,8 +18450,6 @@ "@slates-integrations/convertapi/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/convertkit/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/convex/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/conveyor/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18222,8 +18466,6 @@ "@slates-integrations/craftmypdf/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/crisp/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/crowdin/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/crowterminal/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18240,8 +18482,6 @@ "@slates-integrations/cursor/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/customerio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/customgpt/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/customjs/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18262,12 +18502,6 @@ "@slates-integrations/databox/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/databricks/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/datadog/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/dataforseo/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/datagma/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/datarobot/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18284,12 +18518,8 @@ "@slates-integrations/deel/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/deepgram/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/deepimage/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/deepseek/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/delighted/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/demio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18318,22 +18548,16 @@ "@slates-integrations/dock-labs-truvera/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/docker-hub/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/docmosis/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/docnify/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/docparser/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/docraptor/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/docsautomator/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/docsbot-ai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/docsumo/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/docugenerate/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/documenso/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18346,8 +18570,6 @@ "@slates-integrations/docuseal/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/docusign/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/doodle/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/doppler/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18364,16 +18586,12 @@ "@slates-integrations/dreamstudio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/drift/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/drip/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/dripcel/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/dromo/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/dropbox/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/dropcontact/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/dub/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18396,8 +18614,6 @@ "@slates-integrations/elasticsearch/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/elevenlabs/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/elevenreader/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/elorus/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18446,8 +18662,6 @@ "@slates-integrations/everhour/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/evernote/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/eversign/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/exa/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18518,8 +18732,6 @@ "@slates-integrations/fluxguard/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/flyio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/folk/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/follow-up-boss/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18544,18 +18756,8 @@ "@slates-integrations/freshbooks/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/freshdesk/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/freshsales/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/freshservice/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/front/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/fullenrich/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/fullstory/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/gagelist/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/gamma/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18666,8 +18868,6 @@ "@slates-integrations/gosquared/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/grafana/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/grafbase/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/graphhopper/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18678,12 +18878,8 @@ "@slates-integrations/grist/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/groqcloud/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/gtmetrix/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/gumroad/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/gusto/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/habitica/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18708,10 +18904,6 @@ "@slates-integrations/helloleads/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/hellosign/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/help-scout/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/helpdesk/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/helpwise/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18744,8 +18936,6 @@ "@slates-integrations/hootsuite/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/hotjar/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/hotspotsystem/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/html-to-image/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18754,8 +18944,6 @@ "@slates-integrations/hubspot/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/hugging-face/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/humanitix/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/humanloop/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18822,8 +19010,6 @@ "@slates-integrations/item/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/iterable/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/jenkins/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/jigsawstack/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18850,14 +19036,8 @@ "@slates-integrations/keyword/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/kibana/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/kickbox/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/kit/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/klaviyo/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/klazify/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/klipfolio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18872,8 +19052,6 @@ "@slates-integrations/krakenio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/kubernetes/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/l2s/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/la-growth-machine/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18882,8 +19060,6 @@ "@slates-integrations/landbot/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/langbase/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/laposta/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/lastpass/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18904,8 +19080,6 @@ "@slates-integrations/lemlist/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/lemon-squeezy/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/lessonspace/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/lever/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18956,8 +19130,6 @@ "@slates-integrations/loyverse/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/magento/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/magnetic/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/mailbluster/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -18972,12 +19144,8 @@ "@slates-integrations/mailercloud/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mailerlite/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mailersend/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mailgun/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mailsoftly/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/mailsso/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19008,14 +19176,10 @@ "@slates-integrations/membervault/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/meta-ads/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/metabase/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/metatextai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/metorial-admin/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mezmo/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/microsoft-clarity/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19026,20 +19190,14 @@ "@slates-integrations/microsoft-teams/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/midjourney/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/minerstat/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/miro/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/missive/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mistral-ai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mixmax/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mixpanel/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mocean/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/moco/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19048,14 +19206,8 @@ "@slates-integrations/modelry/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mondaycom/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/moneybird/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mongodb/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/mongodb-atlas/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/moonclerk/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/moosend/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19076,22 +19228,16 @@ "@slates-integrations/mxtoolbox/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/mysql/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/n8n/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/nango/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/nanonets/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/nasa/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/nasdaq/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/needle/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/neon/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/netlify/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/netsuite/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19102,8 +19248,6 @@ "@slates-integrations/neverbounce/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/new-relic/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/newsapi/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/nextdns/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19134,18 +19278,10 @@ "@slates-integrations/nutshell/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/ocr-web-service/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/ocrspace/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/odoo/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/oksign/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/okta/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/ollama/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/omnisend/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/onedesk/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19168,14 +19304,10 @@ "@slates-integrations/openperplex/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/openrouter/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/opensea/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/openweathermap/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/opsgenie/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/optimizely/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/optimoroute/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19188,10 +19320,6 @@ "@slates-integrations/page-x/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/pagerduty/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/pandadoc/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/paperform/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/papertrail/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19222,26 +19350,14 @@ "@slates-integrations/paystack/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/pdf-apiio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/pdf4me/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/pdfco/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/pdfless/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/pdfmonkey/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/pendo/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/penpot/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/people-data-labs/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/perigon/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/perplexity-ai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/persistiq/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/persona/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19256,8 +19372,6 @@ "@slates-integrations/pilvio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/pinecone/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/pingdom/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/pinterest/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -19274,8 +19388,6 @@ "@slates-integrations/plain/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/planetscale/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/planly/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/planyo-online-booking/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19304,12 +19416,8 @@ "@slates-integrations/postgrid-verify/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/posthog/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/postman/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/postmark/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/power-bi/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/powerpoint-online/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19360,8 +19468,6 @@ "@slates-integrations/ragie/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/railway/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/raisely/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/ramp/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19380,8 +19486,6 @@ "@slates-integrations/reddit-ads/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/redis/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/referralrock/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/refiner/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19394,20 +19498,14 @@ "@slates-integrations/removebg/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/render/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/renderform/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/rentman/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/repairshopr/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/replicate/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/replyio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/resend/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/resistant-ai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/respondio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19494,8 +19592,6 @@ "@slates-integrations/securitytrails/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/segment/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/segmetrics/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/semantic-scholar/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19510,8 +19606,6 @@ "@slates-integrations/sendfox/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/sendgrid/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/sendlane/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/sendloop/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19536,8 +19630,6 @@ "@slates-integrations/servicem8/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/servicenow/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/sevdesk/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/sharepoint/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19614,8 +19706,6 @@ "@slates-integrations/splitwise/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/splunk/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/spoki/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/spondyr/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19626,14 +19716,8 @@ "@slates-integrations/sprout-social/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/square/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates-integrations/squarespace/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/sslmate-cert-spotter-api/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/stability-ai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/stack-ai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/stack-exchange/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19676,8 +19760,6 @@ "@slates-integrations/suitedash/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/supabase/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/supadata/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/superchat/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19704,8 +19786,6 @@ "@slates-integrations/tableau/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/taggun/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/talenthr/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/tally/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19800,8 +19880,6 @@ "@slates-integrations/trayio/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/trello/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/triggercmd/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/tripadvisor/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19818,8 +19896,6 @@ "@slates-integrations/twilio-flex/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/twilio-sendgrid/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/twitch/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/twitter/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -19890,8 +19966,6 @@ "@slates-integrations/weaviate/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/webflow/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/webscraper-io/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/webscrapingai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19912,20 +19986,14 @@ "@slates-integrations/witai/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/wix/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/wiza/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/wolfram-alpha-api/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/woocommerce/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/woodpecker-co/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/word-online/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/wordpress/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/workable/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/workato/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19978,8 +20046,6 @@ "@slates-integrations/zoho-books/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/zoho-crm/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/zoho-desk/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/zoho-inventory/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -19998,22 +20064,6 @@ "@slates-integrations/zyte-api/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates/aws-sdk-http-handler/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates/google-people-recipes/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates/oauth-microsoft/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates/slack-tools/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - - "@slates/test/@slates/client": ["@slates/client@1.0.0-rc.12", "", { "dependencies": { "@slates/proto": "1.0.0-rc.11", "@slates/provider": "1.0.0-rc.15", "@slates/provider-handler": "1.0.0-rc.17" } }, "sha512-wMTBVWoUl0m9UFJeema27rB+9MQDrXPWZX0strrnwvQgZk3tCX+DL5LUK9y7QWZzBnVUhxTK+jnz+TyEEdr+9A=="], - - "@slates/test/@slates/profiles": ["@slates/profiles@1.0.0-rc.10", "", { "dependencies": { "@slates/client": "1.0.0-rc.12", "@slates/proto": "1.0.0-rc.11" } }, "sha512-/6ZxhIhUOsrYnbaF0Bd/I6xO5+6w93EcsHtVzKxLwMtaBqT5V4YurMkseQvnT3Y4V9bBh/W2L8nwImd4y2iWcg=="], - - "@slates/test/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates/tool-recipes/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates/tsconfig/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], "@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], @@ -20090,8 +20140,6 @@ "@lowerdeck/testing-tools/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - "@slates-integrations/1password/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/21risk/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/2chat/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20108,8 +20156,6 @@ "@slates-integrations/acculynx/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/activecampaign/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/activetrail/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/acuity-scheduling/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20120,16 +20166,12 @@ "@slates-integrations/adobe-creative-cloud/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/adobe-sign/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/adrapid/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/adyntel/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/aeroleads/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/affinda/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/affinity/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/agencyzoom/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20182,8 +20224,6 @@ "@slates-integrations/amcards/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/amplitude/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/anchor-browser/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/anonyflow/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20198,8 +20238,6 @@ "@slates-integrations/api-sports/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/api2pdf/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/apibible/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/apiflash/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20226,40 +20264,22 @@ "@slates-integrations/aryn/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/asana/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/ascora/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/ashby/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/assemblyai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/astica-ai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/async-interview/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/attio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/auth0/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/autobound/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/aws-cognito/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/aws-dynamodb/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/aws-lambda/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/aws-s3/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/aws-ses/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/aws-sns/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/aws-sqs/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/aws-transcribe/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/ayrshare/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/azure-blob-storage/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20270,8 +20290,6 @@ "@slates-integrations/azure-repos/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/azure-speech/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/backendless/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/bamboohr/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20308,8 +20326,6 @@ "@slates-integrations/bidsketch/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/bigcommerce/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/bigdatacloud/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/bigmailer/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20318,8 +20334,6 @@ "@slates-integrations/bigpictureio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/bigquery/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/bitquery/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/bitwarden/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20358,22 +20372,14 @@ "@slates-integrations/bouncer/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/box/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/boxhero/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/braintree/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/brandfetch/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/braze/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/breathe-hr/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/breeze/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/brevo/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/brex/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/bright-data/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20382,10 +20388,6 @@ "@slates-integrations/browseai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/browserbase/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/browserless/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/btcpay-server/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/bubble-plan/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20414,8 +20416,6 @@ "@slates-integrations/cabinpanda/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/calcom/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/calendarhero/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/callerapi/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20476,8 +20476,6 @@ "@slates-integrations/classmarker/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/clearbit/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/clearout/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/clickhouse/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20486,8 +20484,6 @@ "@slates-integrations/clicksend/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/clickup/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/clientary/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/clockify/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20498,8 +20494,6 @@ "@slates-integrations/cloudconvert/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/cloudflare-workers/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/cloudflare/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/cloudinary/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20508,8 +20502,6 @@ "@slates-integrations/coassemble/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/coda/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/codacy/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/codemagic/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20518,10 +20510,6 @@ "@slates-integrations/cody/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/cohere/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/coinbase/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/coinmarketcal/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/coinmarketcap/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20544,8 +20532,6 @@ "@slates-integrations/convertapi/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/convertkit/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/convex/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/conveyor/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20562,8 +20548,6 @@ "@slates-integrations/craftmypdf/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/crisp/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/crowdin/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/crowterminal/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20580,8 +20564,6 @@ "@slates-integrations/cursor/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/customerio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/customgpt/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/customjs/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20602,12 +20584,6 @@ "@slates-integrations/databox/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/databricks/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/datadog/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/dataforseo/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/datagma/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/datarobot/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20624,12 +20600,8 @@ "@slates-integrations/deel/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/deepgram/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/deepimage/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/deepseek/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/delighted/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/demio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20654,22 +20626,16 @@ "@slates-integrations/dock-labs-truvera/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/docker-hub/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/docmosis/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/docnify/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/docparser/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/docraptor/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/docsautomator/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/docsbot-ai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/docsumo/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/docugenerate/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/documenso/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20682,8 +20648,6 @@ "@slates-integrations/docuseal/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/docusign/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/doodle/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/doppler-marketing-automation/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20700,16 +20664,12 @@ "@slates-integrations/dreamstudio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/drift/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/drip/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/dripcel/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/dromo/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/dropbox/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/dropcontact/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/dub/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20732,8 +20692,6 @@ "@slates-integrations/elasticsearch/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/elevenlabs/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/elevenreader/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/elorus/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20782,8 +20740,6 @@ "@slates-integrations/everhour/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/evernote/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/eversign/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/exa/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20848,8 +20804,6 @@ "@slates-integrations/fluxguard/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/flyio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/folk/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/follow-up-boss/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20874,18 +20828,8 @@ "@slates-integrations/freshbooks/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/freshdesk/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/freshsales/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/freshservice/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/front/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/fullenrich/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/fullstory/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/gagelist/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/gamma/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -20994,8 +20938,6 @@ "@slates-integrations/gosquared/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/grafana/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/grafbase/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/graphhopper/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21006,12 +20948,8 @@ "@slates-integrations/grist/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/groqcloud/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/gtmetrix/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/gumroad/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/gusto/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/habitica/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21036,10 +20974,6 @@ "@slates-integrations/helloleads/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/hellosign/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/help-scout/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/helpdesk/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/helpwise/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21068,8 +21002,6 @@ "@slates-integrations/hootsuite/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/hotjar/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/hotspotsystem/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/html-to-image/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21078,8 +21010,6 @@ "@slates-integrations/hubspot/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/hugging-face/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/humanitix/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/humanloop/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21142,8 +21072,6 @@ "@slates-integrations/item/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/iterable/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/jenkins/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/jigsawstack/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21166,14 +21094,8 @@ "@slates-integrations/keyword/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/kibana/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/kickbox/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/kit/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/klaviyo/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/klazify/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/klipfolio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21188,8 +21110,6 @@ "@slates-integrations/krakenio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/kubernetes/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/l2s/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/la-growth-machine/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21198,8 +21118,6 @@ "@slates-integrations/landbot/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/langbase/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/laposta/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/lastpass/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21220,8 +21138,6 @@ "@slates-integrations/lemlist/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/lemon-squeezy/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/lessonspace/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/lever/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21272,8 +21188,6 @@ "@slates-integrations/loyverse/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/magento/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/magnetic/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/mailbluster/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21286,12 +21200,8 @@ "@slates-integrations/mailercloud/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mailerlite/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mailersend/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mailgun/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mailsoftly/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/mailsso/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21322,8 +21232,6 @@ "@slates-integrations/membervault/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/meta-ads/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/metabase/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/metatextai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21336,20 +21244,14 @@ "@slates-integrations/microsoft-teams/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/midjourney/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/minerstat/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/miro/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/missive/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mistral-ai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mixmax/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mixpanel/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mocean/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/moco/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21358,14 +21260,8 @@ "@slates-integrations/modelry/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mondaycom/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/moneybird/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mongodb-atlas/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/mongodb/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/moonclerk/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/moosend/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21386,22 +21282,16 @@ "@slates-integrations/mxtoolbox/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/mysql/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/n8n/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/nango/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/nanonets/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/nasa/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/nasdaq/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/needle/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/neon/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/netsuite/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/neuronwriter/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21410,8 +21300,6 @@ "@slates-integrations/neverbounce/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/new-relic/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/newsapi/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/nextdns/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21442,18 +21330,10 @@ "@slates-integrations/nutshell/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/ocr-web-service/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/ocrspace/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/odoo/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/oksign/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/okta/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/ollama/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/omnisend/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/onedesk/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21474,14 +21354,10 @@ "@slates-integrations/openperplex/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/openrouter/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/opensea/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/openweathermap/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/opsgenie/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/optimizely/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/optimoroute/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21494,10 +21370,6 @@ "@slates-integrations/page-x/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/pagerduty/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/pandadoc/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/paperform/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/papertrail/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21526,26 +21398,14 @@ "@slates-integrations/paystack/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/pdf-apiio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/pdf4me/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/pdfco/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/pdfless/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/pdfmonkey/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/pendo/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/penpot/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/people-data-labs/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/perigon/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/perplexity-ai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/persistiq/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/persona/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21560,8 +21420,6 @@ "@slates-integrations/pilvio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/pinecone/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/pingdom/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/pipeline-crm/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21574,8 +21432,6 @@ "@slates-integrations/plain/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/planetscale/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/planly/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/planyo-online-booking/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21602,12 +21458,8 @@ "@slates-integrations/postgrid/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/posthog/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/postman/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/postmark/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/power-bi/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/powerpoint-online/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21656,8 +21508,6 @@ "@slates-integrations/ragie/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/railway/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/raisely/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/ramp/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21674,8 +21524,6 @@ "@slates-integrations/reddit-ads/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/redis/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/referralrock/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/refiner/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21688,20 +21536,14 @@ "@slates-integrations/removebg/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/render/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/renderform/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/rentman/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/repairshopr/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/replicate/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/replyio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/resend/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/resistant-ai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/respondio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21788,8 +21630,6 @@ "@slates-integrations/securitytrails/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/segment/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/segmetrics/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/semantic-scholar/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21804,8 +21644,6 @@ "@slates-integrations/sendfox/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/sendgrid/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/sendlane/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/sendloop/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21830,8 +21668,6 @@ "@slates-integrations/servicem8/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/servicenow/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/sevdesk/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/sharepoint/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21904,8 +21740,6 @@ "@slates-integrations/splitwise/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/splunk/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/spoki/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/spondyr/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21916,14 +21750,8 @@ "@slates-integrations/sprout-social/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/square/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates-integrations/squarespace/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/sslmate-cert-spotter-api/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/stability-ai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/stack-ai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/stack-exchange/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21964,8 +21792,6 @@ "@slates-integrations/suitedash/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/supabase/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/supadata/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/superchat/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -21990,8 +21816,6 @@ "@slates-integrations/tableau/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/taggun/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/talenthr/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/tally/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -22084,8 +21908,6 @@ "@slates-integrations/trayio/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/trello/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/triggercmd/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/tripadvisor/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -22100,8 +21922,6 @@ "@slates-integrations/twilio-flex/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/twilio-sendgrid/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/twitch/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/twocaptcha/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -22166,8 +21986,6 @@ "@slates-integrations/weaviate/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/webflow/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/webscraper-io/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/webscrapingai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -22188,20 +22006,14 @@ "@slates-integrations/witai/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/wix/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/wiza/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/wolfram-alpha-api/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/woocommerce/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/woodpecker-co/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/word-online/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/wordpress/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/workable/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/workato/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -22244,8 +22056,6 @@ "@slates-integrations/zoho-books/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/zoho-crm/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/zoho-desk/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/zoho-inventory/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], @@ -22264,22 +22074,6 @@ "@slates-integrations/zyte-api/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates/aws-sdk-http-handler/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates/google-people-recipes/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates/oauth-microsoft/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates/slack-tools/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - - "@slates/test/@slates/client/@slates/proto": ["@slates/proto@1.0.0-rc.11", "", { "dependencies": { "@lowerdeck/emitter": "^1.0.4", "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-g/MMRAciX5D0UmEiwBy9656qEaade2ZMysIRMYoL64PzwfLC2rorOuTfE+kfBhNfH1IeA9UgmfVT8+8vGbQv3Q=="], - - "@slates/test/@slates/client/@slates/provider-handler": ["@slates/provider-handler@1.0.0-rc.17", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@slates/proto": "1.0.0-rc.11", "@slates/provider": "1.0.0-rc.15", "zod": "^4.2.1" } }, "sha512-DrAHi9x83w5vmhgyPwt8OT2HU1lxDFMAQquvHy54f1h0IjSjjQnNgOcbf7eq4wy1sHgRkKXtVSbQ5d64fTIW3w=="], - - "@slates/test/@slates/profiles/@slates/proto": ["@slates/proto@1.0.0-rc.11", "", { "dependencies": { "@lowerdeck/emitter": "^1.0.4", "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-g/MMRAciX5D0UmEiwBy9656qEaade2ZMysIRMYoL64PzwfLC2rorOuTfE+kfBhNfH1IeA9UgmfVT8+8vGbQv3Q=="], - - "@slates/tool-recipes/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/integrations/1password/README.md b/integrations/1password/README.md index 0add3f0775..0fa1b74f94 100644 --- a/integrations/1password/README.md +++ b/integrations/1password/README.md @@ -1,6 +1,6 @@ # 1password -Manage passwords, secrets, and sensitive credentials stored in encrypted vaults. Retrieve secrets using reference URIs, create/read/update/delete vault items including API keys, passwords, SSH keys, and file attachments. Generate passwords with configurable recipes. Share items securely with expiration and recipient controls. Manage vaults, users, and groups with permissions. Monitor account activity through audit events, item usage events, and sign-in attempt events for SIEM integration. +Manage passwords, secrets, and sensitive credentials stored in encrypted vaults through the 1Password Connect Server API. Create, read, update, delete, list, and search vault items; inspect vault metadata; download item file attachments as Slate attachments; generate passwords; check Connect server health; and monitor account activity through audit, item usage, and sign-in events. ## Tools @@ -12,22 +12,46 @@ Create a new item in a 1Password vault. Supports creating logins, passwords, API Delete an item from a 1Password vault. This permanently removes the item and cannot be undone. To archive an item instead, use the Update Item tool with a patch operation to set the state. +### Get File Metadata + +Retrieve metadata for a specific file attachment on a 1Password item without downloading file bytes. + ### Generate Password Generate a secure password using 1Password's password generator. Creates a temporary PASSWORD item in the specified vault with a generated password field, retrieves the generated value, then deletes the temporary item. Supports configuring length, character sets, and excluded characters. ### Get File Content -Retrieve the content of a file attachment stored on a 1Password item. Use the Get Item tool first to discover file IDs and names attached to an item. Returns the file content as text. +Download the content of a file attachment stored on a 1Password item. Use Get Item or List Files first to discover file IDs and names attached to an item. Returns file bytes through a Slate attachment and keeps structured output to metadata. ### Get Item Retrieve the full details of a specific item from a vault, including all fields, sections, files, and metadata. Use this to read passwords, API keys, notes, and other secrets stored in 1Password. +### Get Prometheus Metrics + +Retrieve Prometheus metrics from the 1Password Connect server. Returns metrics text through a Slate attachment. + +### Get Server Heartbeat + +Ping the 1Password Connect server heartbeat endpoint to verify the server is reachable. + ### Get Server Health Check the health and status of the 1Password Connect server, including its version and the status of dependent services. Useful for verifying connectivity and diagnosing issues. +### Get Vault + +Retrieve metadata for a specific vault accessible to the Connect token. + +### List API Activity + +List recent API activity recorded by the 1Password Connect server, including action, result, actor, and affected resource metadata. + +### List Files + +List file attachments on a 1Password item. Returns metadata only. + ### List Items List items stored in a specific vault. Returns item summaries including titles, categories, tags, and URLs. Use the filter parameter to search by title or tag. For full item details including field values, use the Get Item tool. diff --git a/integrations/1password/docs/SPEC.md b/integrations/1password/docs/SPEC.md index 77492e0eab..d577fb8552 100644 --- a/integrations/1password/docs/SPEC.md +++ b/integrations/1password/docs/SPEC.md @@ -18,6 +18,7 @@ Each service account has a service account token that you can provide as an envi - Service account permissions, vault access, and Environment access are immutable. To change them, you must create a new service account. - Service accounts can't access your built-in Personal, Private, or Employee vault. - Created via 1Password.com (Developer > Directory) or via 1Password CLI. +- 1Password service accounts are exposed through the 1Password CLI and SDKs, not the Connect REST endpoints used by this integration's item, vault, file, and server tools. Those tools require the Connect Server Token auth method and a Connect server URL. ### Connect Server Token (Connect REST API) @@ -45,11 +46,11 @@ Retrieve individual secret values stored in 1Password using secret reference URI ### Item Management -Full programmatic access to 1Password items, including creating, reading, updating, deleting, listing, and sharing information stored in vaults. Supported field types include API Keys, Passwords, Concealed fields, Text fields, Notes, SSH private keys, One-time passwords, URLs, Credit card numbers, Emails, File attachments, Document items, Passkeys, and more. Items can also be archived. +Programmatic access to 1Password Connect vault items, including creating, reading, replacing, patching, deleting, listing, and searching items stored in accessible vaults. Supported field types include passwords, concealed fields, text fields, notes, one-time passwords, URLs, emails, and other Connect item fields. Items can be modified with JSON Patch operations where the Connect API supports them. ### Item Sharing -Securely share items with anyone, whether or not they have a 1Password account. Creates shareable links with configurable expiration (1 hour to 30 days), optional recipient restrictions by email/domain, and one-time view settings. If you have a 1Password Business account, it will also validate the settings against the item sharing policy set by your account owner or administrator. +1Password service accounts and SDKs support item sharing, but the Connect REST API does not expose item sharing. This integration does not currently provide an item sharing tool. ### Password Generation @@ -57,17 +58,17 @@ Generate passwords using a PIN, Random, or Memorable password recipe. PIN codes ### Vault Management -Manage your team's vaults and the permissions groups have in them. Operations include retrieving, creating, updating, deleting, and listing vaults, as well as managing group vault permissions and user vault permissions. +Retrieve and list vaults accessible to the Connect token. Creating, updating, deleting, and permission management for vaults are service-account/SDK or CLI workflows and are not exposed by this Connect-focused integration. ### User and Group Management -Provision users, retrieve users, list users, suspend users, retrieve groups, list groups, create groups, and update group membership. +1Password SDKs list user and group capabilities, but these are not exposed through the Connect REST API tools in this integration. - These operations typically require desktop app authentication rather than service account authentication. ### File Management -Store and retrieve file attachments and document items in vaults. Files can be attached to items and shared via item sharing links. +List file attachments on items, retrieve file metadata, and download file contents through Slate attachments. Connect exposes existing file attachments but does not provide an upload endpoint. ### Events Reporting diff --git a/integrations/1password/package.json b/integrations/1password/package.json index 7af8909df2..275c055385 100644 --- a/integrations/1password/package.json +++ b/integrations/1password/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/1password/slate.json b/integrations/1password/slate.json index 71c9b75a9f..3e5b6a8b1a 100644 --- a/integrations/1password/slate.json +++ b/integrations/1password/slate.json @@ -1,15 +1,13 @@ { "name": "@metorial/1password", - "description": "Manage passwords, secrets, and sensitive credentials stored in encrypted vaults. Retrieve secrets using reference URIs, create/read/update/delete vault items including API keys, passwords, SSH keys, and file attachments. Generate passwords with configurable recipes. Share items securely with expiration and recipient controls. Manage vaults, users, and groups with permissions. Monitor account activity through audit events, item usage events, and sign-in attempt events for SIEM integration.", + "description": "Manage passwords, secrets, and sensitive credentials stored in encrypted vaults through 1Password Connect. Create, read, update, delete, list, and search vault items; inspect vault metadata; retrieve file attachments through Slate attachments; generate passwords; check Connect server health; and monitor audit, item usage, and sign-in events.", "categories": ["apis-and-http-requests", "security"], "skills": [ - "resolve secrets by reference", "manage vault items", + "inspect vault metadata", "generate passwords", - "share items securely", - "manage vaults and permissions", - "manage users and groups", - "store and retrieve files", + "download file attachments", + "monitor connect server health", "monitor audit events", "track sign-in attempts", "track item usage events" diff --git a/integrations/1password/src/index.ts b/integrations/1password/src/index.ts index 817f81c8b3..d24af0cca5 100644 --- a/integrations/1password/src/index.ts +++ b/integrations/1password/src/index.ts @@ -5,8 +5,14 @@ import { deleteItem, generatePassword, getFileContent, + getFileMetadata, getItem, + getPrometheusMetrics, getServerHealth, + getServerHeartbeat, + getVault, + listApiActivity, + listFiles, listItems, listVaults, searchItems, @@ -23,6 +29,7 @@ export let provider = Slate.create({ spec, tools: [ listVaults, + getVault, listItems, searchItems, getItem, @@ -30,8 +37,13 @@ export let provider = Slate.create({ updateItem, deleteItem, generatePassword, + listFiles, + getFileMetadata, getFileContent, - getServerHealth + listApiActivity, + getServerHeartbeat, + getServerHealth, + getPrometheusMetrics ], triggers: [ inboundWebhook, diff --git a/integrations/1password/src/lib/client.ts b/integrations/1password/src/lib/client.ts index 8a72746543..ab7640b1eb 100644 --- a/integrations/1password/src/lib/client.ts +++ b/integrations/1password/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { onePasswordApiError } from './errors'; export interface VaultSummary { id: string; @@ -44,7 +45,8 @@ export interface FileSummary { id: string; name: string; size: number; - contentPath: string; + contentPath?: string; + content_path?: string; section?: { id: string; label?: string }; } @@ -88,30 +90,48 @@ export interface PatchOperation { } export class ConnectClient { - private http; + private apiHttp; + private serverHttp; constructor(config: { token: string; serverUrl: string }) { - this.http = createAxios({ - baseURL: `${config.serverUrl.replace(/\/+$/, '')}/v1`, + let serverUrl = config.serverUrl.replace(/\/+$/, ''); + let headers = { + Authorization: `Bearer ${config.token}`, + 'Content-Type': 'application/json' + }; + + this.apiHttp = createAxios({ + baseURL: `${serverUrl}/v1`, + headers + }); + + this.serverHttp = createAxios({ + baseURL: serverUrl, headers: { - Authorization: `Bearer ${config.token}`, - 'Content-Type': 'application/json' + ...headers } }); } + private async request(operation: string, run: () => Promise<{ data: T }>): Promise { + try { + let res = await run(); + return res.data; + } catch (error) { + throw onePasswordApiError(error, operation); + } + } + // ---- Vaults ---- async listVaults(filter?: string): Promise { let params: Record = {}; if (filter) params.filter = filter; - let res = await this.http.get('/vaults', { params }); - return res.data; + return this.request('list vaults', () => this.apiHttp.get('/vaults', { params })); } async getVault(vaultId: string): Promise { - let res = await this.http.get(`/vaults/${vaultId}`); - return res.data; + return this.request('get vault', () => this.apiHttp.get(`/vaults/${vaultId}`)); } // ---- Items ---- @@ -119,23 +139,27 @@ export class ConnectClient { async listItems(vaultId: string, filter?: string): Promise { let params: Record = {}; if (filter) params.filter = filter; - let res = await this.http.get(`/vaults/${vaultId}/items`, { params }); - return res.data; + return this.request('list items', () => + this.apiHttp.get(`/vaults/${vaultId}/items`, { params }) + ); } async getItem(vaultId: string, itemId: string): Promise { - let res = await this.http.get(`/vaults/${vaultId}/items/${itemId}`); - return res.data; + return this.request('get item', () => + this.apiHttp.get(`/vaults/${vaultId}/items/${itemId}`) + ); } async createItem(vaultId: string, item: CreateItemInput): Promise { - let res = await this.http.post(`/vaults/${vaultId}/items`, item); - return res.data; + return this.request('create item', () => + this.apiHttp.post(`/vaults/${vaultId}/items`, item) + ); } async replaceItem(vaultId: string, itemId: string, item: FullItem): Promise { - let res = await this.http.put(`/vaults/${vaultId}/items/${itemId}`, item); - return res.data; + return this.request('replace item', () => + this.apiHttp.put(`/vaults/${vaultId}/items/${itemId}`, item) + ); } async patchItem( @@ -143,29 +167,60 @@ export class ConnectClient { itemId: string, operations: PatchOperation[] ): Promise { - let res = await this.http.patch(`/vaults/${vaultId}/items/${itemId}`, operations); - return res.data; + return this.request('patch item', () => + this.apiHttp.patch(`/vaults/${vaultId}/items/${itemId}`, operations) + ); } async deleteItem(vaultId: string, itemId: string): Promise { - await this.http.delete(`/vaults/${vaultId}/items/${itemId}`); + await this.request('delete item', () => + this.apiHttp.delete(`/vaults/${vaultId}/items/${itemId}`) + ); } // ---- Files ---- - async getFileContent(vaultId: string, itemId: string, fileId: string): Promise { - let res = await this.http.get( - `/vaults/${vaultId}/items/${itemId}/files/${fileId}/content`, - { - responseType: 'text' - } + async listFiles(vaultId: string, itemId: string): Promise { + return this.request('list files', () => + this.apiHttp.get(`/vaults/${vaultId}/items/${itemId}/files`) ); - return res.data; } - async listFiles(vaultId: string, itemId: string): Promise { - let res = await this.http.get(`/vaults/${vaultId}/items/${itemId}/files`); - return res.data; + async getFile(vaultId: string, itemId: string, fileId: string): Promise { + return this.request('get file metadata', () => + this.apiHttp.get(`/vaults/${vaultId}/items/${itemId}/files/${fileId}`) + ); + } + + async getFileContent( + vaultId: string, + itemId: string, + fileId: string + ): Promise { + try { + let res = await this.apiHttp.get( + `/vaults/${vaultId}/items/${itemId}/files/${fileId}/content`, + { + responseType: 'arraybuffer' + } + ); + let raw = res.data; + let buffer = Buffer.isBuffer(raw) + ? raw + : Buffer.from(raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw); + let contentType = + typeof res.headers?.['content-type'] === 'string' + ? res.headers['content-type'] + : 'application/octet-stream'; + + return { + contentBase64: buffer.toString('base64'), + contentType, + byteLength: buffer.byteLength + }; + } catch (error) { + throw onePasswordApiError(error, 'get file content'); + } } // ---- Activity ---- @@ -174,19 +229,55 @@ export class ConnectClient { let params: Record = {}; if (limit !== undefined) params.limit = limit; if (offset !== undefined) params.offset = offset; - let res = await this.http.get('/activity', { params }); - return res.data; + return this.request('list API activity', () => this.apiHttp.get('/activity', { params })); } // ---- Health ---- async getServerHealth(): Promise { - let res = await this.http.get('/health'); - return res.data; + return this.request('get server health', () => this.serverHttp.get('/health')); } + + async getServerHeartbeat(): Promise { + try { + let res = await this.serverHttp.get('/heartbeat'); + return { + ok: res.status >= 200 && res.status < 300, + status: res.status + }; + } catch (error) { + throw onePasswordApiError(error, 'get server heartbeat'); + } + } + + async getPrometheusMetrics(): Promise { + try { + let res = await this.serverHttp.get('/metrics', { responseType: 'text' }); + let content = typeof res.data === 'string' ? res.data : String(res.data ?? ''); + let contentType = + typeof res.headers?.['content-type'] === 'string' + ? res.headers['content-type'] + : 'text/plain; version=0.0.4'; + + return { + content, + contentType, + byteLength: Buffer.byteLength(content) + }; + } catch (error) { + throw onePasswordApiError(error, 'get Prometheus metrics'); + } + } +} + +export interface DownloadedFileContent { + contentBase64: string; + contentType: string; + byteLength: number; } export interface ApiRequest { + requestID?: string; requestId: string; timestamp: string; action: string; @@ -196,6 +287,7 @@ export interface ApiRequest { account: string; jti: string; userAgent: string; + ip?: string; requestIp: string; }; resource: { @@ -215,3 +307,14 @@ export interface ServerHealth { message?: string; }>; } + +export interface ServerHeartbeat { + ok: boolean; + status: number; +} + +export interface PrometheusMetrics { + content: string; + contentType: string; + byteLength: number; +} diff --git a/integrations/1password/src/lib/connect-tool.ts b/integrations/1password/src/lib/connect-tool.ts new file mode 100644 index 0000000000..5c87ac4182 --- /dev/null +++ b/integrations/1password/src/lib/connect-tool.ts @@ -0,0 +1,31 @@ +import { createApiServiceError } from 'slates'; +import { ConnectClient } from './client'; + +type ConnectToolContext = { + auth: { + token: string; + authType?: string; + }; + config: { + connectServerUrl?: string; + }; +}; + +export let createConnectClient = (ctx: ConnectToolContext) => { + if (ctx.auth.authType !== 'connect') { + throw createApiServiceError( + 'This tool requires the 1Password Connect Server Token auth method. Service Account and Events API tokens cannot call the Connect REST API.' + ); + } + + if (!ctx.config.connectServerUrl) { + throw createApiServiceError( + 'Connect server URL is required. Set connectServerUrl in the integration configuration.' + ); + } + + return new ConnectClient({ + token: ctx.auth.token, + serverUrl: ctx.config.connectServerUrl + }); +}; diff --git a/integrations/1password/src/lib/errors.ts b/integrations/1password/src/lib/errors.ts new file mode 100644 index 0000000000..95cb9db8f2 --- /dev/null +++ b/integrations/1password/src/lib/errors.ts @@ -0,0 +1,21 @@ +import { buildApiServiceError } from 'slates'; + +export let onePasswordApiError = (error: unknown, operation = 'request') => + buildApiServiceError(error, { + providerLabel: '1Password', + operation, + reason: 'onepassword_api_error', + detailKeys: ['message', 'error', 'error_description'], + includeNumbers: false, + nestedKeys: [], + extractStatus: (input, response, helpers) => + response?.status ?? + (helpers.isRecord(input) && typeof input.status === 'number' + ? input.status + : undefined) ?? + (helpers.isRecord(input) && + helpers.isRecord(input.data) && + typeof input.data.status === 'number' + ? input.data.status + : undefined) + }); diff --git a/integrations/1password/src/lib/events-client.ts b/integrations/1password/src/lib/events-client.ts index 883ed07df2..806f945790 100644 --- a/integrations/1password/src/lib/events-client.ts +++ b/integrations/1password/src/lib/events-client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { onePasswordApiError } from './errors'; let EVENTS_API_BASE_URLS: Record = { us: 'https://events.1password.com', @@ -142,24 +143,36 @@ export class EventsClient { async getAuditEvents(params: EventsCursorRequest): Promise> { let body = this.buildRequestBody(params); - let res = await this.http.post('/auditevents', body); - return res.data; + try { + let res = await this.http.post('/auditevents', body); + return res.data; + } catch (error) { + throw onePasswordApiError(error, 'get audit events'); + } } async getItemUsageEvents( params: EventsCursorRequest ): Promise> { let body = this.buildRequestBody(params); - let res = await this.http.post('/itemusages', body); - return res.data; + try { + let res = await this.http.post('/itemusages', body); + return res.data; + } catch (error) { + throw onePasswordApiError(error, 'get item usage events'); + } } async getSignInAttemptEvents( params: EventsCursorRequest ): Promise> { let body = this.buildRequestBody(params); - let res = await this.http.post('/signinattempts', body); - return res.data; + try { + let res = await this.http.post('/signinattempts', body); + return res.data; + } catch (error) { + throw onePasswordApiError(error, 'get sign-in attempt events'); + } } private buildRequestBody(params: EventsCursorRequest): Record { diff --git a/integrations/1password/src/tools.schema.test.ts b/integrations/1password/src/tools.schema.test.ts new file mode 100644 index 0000000000..36f4630271 --- /dev/null +++ b/integrations/1password/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('1Password tool input schemas', provider.actions); diff --git a/integrations/1password/src/tools/create-item.ts b/integrations/1password/src/tools/create-item.ts index 5212680b6b..925a54e12b 100644 --- a/integrations/1password/src/tools/create-item.ts +++ b/integrations/1password/src/tools/create-item.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; export let createItem = SlateTool.create(spec, { @@ -95,14 +95,7 @@ export let createItem = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); let sections = (ctx.input.sections || []).map(s => ({ id: s.sectionId, diff --git a/integrations/1password/src/tools/delete-item.ts b/integrations/1password/src/tools/delete-item.ts index 1422631d5e..e98033f7d2 100644 --- a/integrations/1password/src/tools/delete-item.ts +++ b/integrations/1password/src/tools/delete-item.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; export let deleteItem = SlateTool.create(spec, { @@ -26,14 +26,7 @@ export let deleteItem = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); ctx.progress('Deleting item...'); await client.deleteItem(ctx.input.vaultId, ctx.input.itemId); diff --git a/integrations/1password/src/tools/generate-password.ts b/integrations/1password/src/tools/generate-password.ts index 7d17942dae..b86ef8931c 100644 --- a/integrations/1password/src/tools/generate-password.ts +++ b/integrations/1password/src/tools/generate-password.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; export let generatePassword = SlateTool.create(spec, { @@ -46,14 +46,7 @@ export let generatePassword = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); let charSets = ctx.input.characterSets || ['LETTERS', 'DIGITS']; diff --git a/integrations/1password/src/tools/get-file-content.ts b/integrations/1password/src/tools/get-file-content.ts index 1c16745cf1..9929b5c5c5 100644 --- a/integrations/1password/src/tools/get-file-content.ts +++ b/integrations/1password/src/tools/get-file-content.ts @@ -1,14 +1,14 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; export let getFileContent = SlateTool.create(spec, { name: 'Get File Content', key: 'get_file_content', - description: `Retrieve the content of a file attachment stored on a 1Password item. Use the Get Item tool first to discover file IDs and names attached to an item. Returns the file content as text.`, + description: `Download the content of a file attachment stored on a 1Password item. The file bytes are returned as a Slate attachment, with structured output limited to metadata.`, instructions: [ - 'Use Get Item first to find the fileId of the attachment you want to retrieve.' + 'Use Get Item or List Files first to find the fileId of the attachment you want to retrieve.' ], tags: { readOnly: true @@ -24,21 +24,16 @@ export let getFileContent = SlateTool.create(spec, { .output( z.object({ fileId: z.string().describe('ID of the file'), - content: z.string().describe('Content of the file as text') + byteLength: z.number().describe('Decoded byte length of the returned attachment'), + mimeType: z.string().describe('MIME type of the returned attachment'), + attachmentCount: z.number().describe('Number of attachments returned') }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); ctx.progress('Retrieving file content...'); - let content = await client.getFileContent( + let file = await client.getFileContent( ctx.input.vaultId, ctx.input.itemId, ctx.input.fileId @@ -47,9 +42,12 @@ export let getFileContent = SlateTool.create(spec, { return { output: { fileId: ctx.input.fileId, - content + byteLength: file.byteLength, + mimeType: file.contentType, + attachmentCount: 1 }, - message: `Retrieved content for file \`${ctx.input.fileId}\` (${content.length} characters).` + attachments: [createBase64Attachment(file.contentBase64, file.contentType)], + message: `Retrieved file \`${ctx.input.fileId}\` as an attachment (${file.byteLength} bytes).` }; }) .build(); diff --git a/integrations/1password/src/tools/get-file-metadata.ts b/integrations/1password/src/tools/get-file-metadata.ts new file mode 100644 index 0000000000..cf1a09cd9e --- /dev/null +++ b/integrations/1password/src/tools/get-file-metadata.ts @@ -0,0 +1,49 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createConnectClient } from '../lib/connect-tool'; +import { spec } from '../spec'; + +export let getFileMetadata = SlateTool.create(spec, { + name: 'Get File Metadata', + key: 'get_file_metadata', + description: `Retrieve metadata for a specific file attachment on a 1Password item without downloading the file content.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + vaultId: z.string().describe('ID of the vault containing the item'), + itemId: z.string().describe('ID of the item the file is attached to'), + fileId: z.string().describe('ID of the file attachment to retrieve') + }) + ) + .output( + z.object({ + fileId: z.string().describe('Unique identifier of the file'), + name: z.string().describe('Filename'), + size: z.number().describe('File size in bytes'), + contentPath: z.string().optional().describe('API path to retrieve file content'), + sectionId: z.string().optional().describe('ID of the section containing the file'), + sectionLabel: z.string().optional().describe('Label of the section containing the file') + }) + ) + .handleInvocation(async ctx => { + let client = createConnectClient(ctx); + + ctx.progress('Fetching file metadata...'); + let file = await client.getFile(ctx.input.vaultId, ctx.input.itemId, ctx.input.fileId); + + return { + output: { + fileId: file.id, + name: file.name, + size: file.size, + contentPath: file.contentPath ?? file.content_path, + sectionId: file.section?.id, + sectionLabel: file.section?.label + }, + message: `Retrieved metadata for file **${file.name}** (${file.size} bytes).` + }; + }) + .build(); diff --git a/integrations/1password/src/tools/get-item.ts b/integrations/1password/src/tools/get-item.ts index 1d135e7a09..1ca41745b1 100644 --- a/integrations/1password/src/tools/get-item.ts +++ b/integrations/1password/src/tools/get-item.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; let fieldSchema = z.object({ @@ -25,7 +25,7 @@ let fileSchema = z.object({ fileId: z.string().describe('Unique identifier of the file'), name: z.string().describe('Filename'), size: z.number().describe('File size in bytes'), - contentPath: z.string().describe('API path to retrieve file content') + contentPath: z.string().optional().describe('API path to retrieve file content') }); export let getItem = SlateTool.create(spec, { @@ -71,14 +71,7 @@ export let getItem = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); ctx.progress('Fetching item details...'); let item = await client.getItem(ctx.input.vaultId, ctx.input.itemId); @@ -99,7 +92,7 @@ export let getItem = SlateTool.create(spec, { fileId: f.id, name: f.name, size: f.size, - contentPath: f.contentPath + contentPath: f.contentPath ?? f.content_path })); return { diff --git a/integrations/1password/src/tools/get-prometheus-metrics.ts b/integrations/1password/src/tools/get-prometheus-metrics.ts new file mode 100644 index 0000000000..121b3ef7ca --- /dev/null +++ b/integrations/1password/src/tools/get-prometheus-metrics.ts @@ -0,0 +1,38 @@ +import { createTextAttachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { createConnectClient } from '../lib/connect-tool'; +import { spec } from '../spec'; + +export let getPrometheusMetrics = SlateTool.create(spec, { + name: 'Get Prometheus Metrics', + key: 'get_prometheus_metrics', + description: `Retrieve Prometheus metrics from the 1Password Connect server. Metrics text is returned as a Slate attachment with structured output limited to metadata.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + byteLength: z.number().describe('Byte length of the returned metrics attachment'), + mimeType: z.string().describe('MIME type of the returned metrics attachment'), + attachmentCount: z.number().describe('Number of attachments returned') + }) + ) + .handleInvocation(async ctx => { + let client = createConnectClient(ctx); + + ctx.progress('Fetching Prometheus metrics...'); + let metrics = await client.getPrometheusMetrics(); + + return { + output: { + byteLength: metrics.byteLength, + mimeType: metrics.contentType, + attachmentCount: 1 + }, + attachments: [createTextAttachment(metrics.content, metrics.contentType)], + message: `Retrieved Prometheus metrics as an attachment (${metrics.byteLength} bytes).` + }; + }) + .build(); diff --git a/integrations/1password/src/tools/get-server-health.ts b/integrations/1password/src/tools/get-server-health.ts index d5bc276742..2d92e40fd7 100644 --- a/integrations/1password/src/tools/get-server-health.ts +++ b/integrations/1password/src/tools/get-server-health.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; export let getServerHealth = SlateTool.create(spec, { @@ -28,14 +28,7 @@ export let getServerHealth = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); ctx.progress('Checking server health...'); let health = await client.getServerHealth(); diff --git a/integrations/1password/src/tools/get-server-heartbeat.ts b/integrations/1password/src/tools/get-server-heartbeat.ts new file mode 100644 index 0000000000..6c95e9c658 --- /dev/null +++ b/integrations/1password/src/tools/get-server-heartbeat.ts @@ -0,0 +1,32 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createConnectClient } from '../lib/connect-tool'; +import { spec } from '../spec'; + +export let getServerHeartbeat = SlateTool.create(spec, { + name: 'Get Server Heartbeat', + key: 'get_server_heartbeat', + description: `Ping the 1Password Connect server heartbeat endpoint to verify that the server process is reachable.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + ok: z.boolean().describe('Whether the heartbeat endpoint returned a 2xx response'), + status: z.number().describe('HTTP status returned by the heartbeat endpoint') + }) + ) + .handleInvocation(async ctx => { + let client = createConnectClient(ctx); + + ctx.progress('Checking server heartbeat...'); + let heartbeat = await client.getServerHeartbeat(); + + return { + output: heartbeat, + message: `Connect server heartbeat returned HTTP ${heartbeat.status}.` + }; + }) + .build(); diff --git a/integrations/1password/src/tools/get-vault.ts b/integrations/1password/src/tools/get-vault.ts new file mode 100644 index 0000000000..17551a0552 --- /dev/null +++ b/integrations/1password/src/tools/get-vault.ts @@ -0,0 +1,53 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createConnectClient } from '../lib/connect-tool'; +import { spec } from '../spec'; + +export let getVault = SlateTool.create(spec, { + name: 'Get Vault', + key: 'get_vault', + description: `Retrieve metadata for a specific 1Password vault accessible to the Connect token, including item count, vault type, and content versions.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + vaultId: z.string().describe('ID of the vault to retrieve') + }) + ) + .output( + z.object({ + vaultId: z.string().describe('Unique identifier of the vault'), + name: z.string().describe('Name of the vault'), + description: z.string().describe('Description of the vault'), + itemCount: z.number().describe('Number of active items in the vault'), + type: z.string().describe('Type of vault'), + attributeVersion: z.number().describe('Version of the vault metadata'), + contentVersion: z.number().describe('Version of the vault contents'), + createdAt: z.string().describe('When the vault was created'), + updatedAt: z.string().describe('When the vault was last updated') + }) + ) + .handleInvocation(async ctx => { + let client = createConnectClient(ctx); + + ctx.progress('Fetching vault...'); + let vault = await client.getVault(ctx.input.vaultId); + + return { + output: { + vaultId: vault.id, + name: vault.name, + description: vault.description || '', + itemCount: vault.items, + type: vault.type, + attributeVersion: vault.attributeVersion, + contentVersion: vault.contentVersion, + createdAt: vault.createdAt, + updatedAt: vault.updatedAt + }, + message: `Retrieved vault **${vault.name}** (${vault.items} item(s)).` + }; + }) + .build(); diff --git a/integrations/1password/src/tools/index.ts b/integrations/1password/src/tools/index.ts index 5db69bde1a..94198167b8 100644 --- a/integrations/1password/src/tools/index.ts +++ b/integrations/1password/src/tools/index.ts @@ -2,8 +2,14 @@ export * from './create-item'; export * from './delete-item'; export * from './generate-password'; export * from './get-file-content'; +export * from './get-file-metadata'; export * from './get-item'; +export * from './get-prometheus-metrics'; export * from './get-server-health'; +export * from './get-server-heartbeat'; +export * from './get-vault'; +export * from './list-api-activity'; +export * from './list-files'; export * from './list-items'; export * from './list-vaults'; export * from './search-items'; diff --git a/integrations/1password/src/tools/list-api-activity.ts b/integrations/1password/src/tools/list-api-activity.ts new file mode 100644 index 0000000000..dce27170ff --- /dev/null +++ b/integrations/1password/src/tools/list-api-activity.ts @@ -0,0 +1,81 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createConnectClient } from '../lib/connect-tool'; +import { spec } from '../spec'; + +export let listApiActivity = SlateTool.create(spec, { + name: 'List API Activity', + key: 'list_api_activity', + description: `List recent API activity recorded by the 1Password Connect server, including action, result, actor, and affected resource metadata.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Maximum number of activity records to return'), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Offset into the activity collection') + }) + ) + .output( + z.object({ + activities: z.array( + z.object({ + requestId: z.string().describe('Unique identifier for the API request'), + timestamp: z.string().describe('When the request occurred'), + action: z.string().describe('Action performed by the request'), + result: z.string().describe('Result of the request'), + actorId: z.string().optional().describe('Connect server actor ID'), + accountId: z.string().optional().describe('1Password account ID'), + userAgent: z.string().optional().describe('User agent for the request'), + ip: z.string().optional().describe('Source IP for the request'), + resourceType: z.string().optional().describe('Type of resource accessed'), + vaultId: z.string().optional().describe('Vault ID associated with the request'), + itemId: z.string().optional().describe('Item ID associated with the request'), + itemVersion: z + .number() + .optional() + .describe('Item version associated with the request') + }) + ) + }) + ) + .handleInvocation(async ctx => { + let client = createConnectClient(ctx); + + ctx.progress('Fetching API activity...'); + let activities = await client.getActivity(ctx.input.limit, ctx.input.offset); + let mapped = activities.map(activity => ({ + requestId: activity.requestID ?? activity.requestId, + timestamp: activity.timestamp, + action: activity.action, + result: activity.result, + actorId: activity.actor?.id, + accountId: activity.actor?.account, + userAgent: activity.actor?.userAgent, + ip: activity.actor?.ip ?? activity.actor?.requestIp, + resourceType: activity.resource?.type, + vaultId: activity.resource?.vault?.id, + itemId: activity.resource?.item?.id, + itemVersion: activity.resource?.itemVersion + })); + + return { + output: { + activities: mapped + }, + message: `Found **${mapped.length}** API activity record(s).` + }; + }) + .build(); diff --git a/integrations/1password/src/tools/list-files.ts b/integrations/1password/src/tools/list-files.ts new file mode 100644 index 0000000000..a0dd4d567e --- /dev/null +++ b/integrations/1password/src/tools/list-files.ts @@ -0,0 +1,53 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createConnectClient } from '../lib/connect-tool'; +import { spec } from '../spec'; + +let fileOutputSchema = z.object({ + fileId: z.string().describe('Unique identifier of the file'), + name: z.string().describe('Filename'), + size: z.number().describe('File size in bytes'), + contentPath: z.string().optional().describe('API path to retrieve file content'), + sectionId: z.string().optional().describe('ID of the section containing the file'), + sectionLabel: z.string().optional().describe('Label of the section containing the file') +}); + +export let listFiles = SlateTool.create(spec, { + name: 'List Files', + key: 'list_files', + description: `List file attachments on a 1Password item. Returns file metadata only; use Get File Content to download bytes as a Slate attachment.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + vaultId: z.string().describe('ID of the vault containing the item'), + itemId: z.string().describe('ID of the item whose files should be listed') + }) + ) + .output( + z.object({ + files: z.array(fileOutputSchema).describe('File attachments on the item') + }) + ) + .handleInvocation(async ctx => { + let client = createConnectClient(ctx); + + ctx.progress('Listing files...'); + let files = await client.listFiles(ctx.input.vaultId, ctx.input.itemId); + let mapped = files.map(file => ({ + fileId: file.id, + name: file.name, + size: file.size, + contentPath: file.contentPath ?? file.content_path, + sectionId: file.section?.id, + sectionLabel: file.section?.label + })); + + return { + output: { files: mapped }, + message: `Found **${mapped.length}** file attachment(s).` + }; + }) + .build(); diff --git a/integrations/1password/src/tools/list-items.ts b/integrations/1password/src/tools/list-items.ts index e71c1b0236..e9f796c8d3 100644 --- a/integrations/1password/src/tools/list-items.ts +++ b/integrations/1password/src/tools/list-items.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; export let listItems = SlateTool.create(spec, { @@ -42,14 +42,7 @@ export let listItems = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); ctx.progress('Fetching items...'); let items = await client.listItems(ctx.input.vaultId, ctx.input.filter); diff --git a/integrations/1password/src/tools/list-vaults.ts b/integrations/1password/src/tools/list-vaults.ts index f766e16196..6dbba2ca57 100644 --- a/integrations/1password/src/tools/list-vaults.ts +++ b/integrations/1password/src/tools/list-vaults.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; export let listVaults = SlateTool.create(spec, { @@ -35,14 +35,7 @@ export let listVaults = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); ctx.progress('Fetching vaults...'); let vaults = await client.listVaults(ctx.input.filter); diff --git a/integrations/1password/src/tools/search-items.ts b/integrations/1password/src/tools/search-items.ts index eb32b3e16b..84f123b8ec 100644 --- a/integrations/1password/src/tools/search-items.ts +++ b/integrations/1password/src/tools/search-items.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; export let searchItems = SlateTool.create(spec, { @@ -44,14 +44,7 @@ export let searchItems = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); let filterParts: string[] = []; if (ctx.input.title) filterParts.push(`title co "${ctx.input.title}"`); diff --git a/integrations/1password/src/tools/update-item.ts b/integrations/1password/src/tools/update-item.ts index eba2ead0f7..28f0df5721 100644 --- a/integrations/1password/src/tools/update-item.ts +++ b/integrations/1password/src/tools/update-item.ts @@ -1,6 +1,7 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; -import { ConnectClient, type PatchOperation } from '../lib/client'; +import type { PatchOperation } from '../lib/client'; +import { createConnectClient } from '../lib/connect-tool'; import { spec } from '../spec'; export let updateItem = SlateTool.create(spec, { @@ -63,14 +64,7 @@ export let updateItem = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - if (!ctx.config.connectServerUrl) { - throw new Error('Connect server URL is required. Set it in the configuration.'); - } - - let client = new ConnectClient({ - token: ctx.auth.token, - serverUrl: ctx.config.connectServerUrl - }); + let client = createConnectClient(ctx); let hasConvenienceUpdates = ctx.input.title !== undefined || @@ -113,8 +107,8 @@ export let updateItem = SlateTool.create(spec, { })); result = await client.patchItem(ctx.input.vaultId, ctx.input.itemId, ops); } else { - throw new Error( - 'No updates provided. Specify at least one of: title, tags, favorite, urls, or patchOperations.' + throw createApiServiceError( + 'No updates provided. Specify at least one of title, tags, favorite, urls, or patchOperations.' ); } diff --git a/integrations/1password/vitest.config.ts b/integrations/1password/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/1password/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/activecampaign/README.md b/integrations/activecampaign/README.md index fdb1417cc1..51598b3e7b 100644 --- a/integrations/activecampaign/README.md +++ b/integrations/activecampaign/README.md @@ -8,6 +8,10 @@ Manage contacts, deals, and marketing automation for customer experience. Create Creates a note on a contact, deal, or account. Specify the resource type and ID along with the note content. +### Associate Contact with Account + +Associates a contact with an account/company using ActiveCampaign account-contact relationships. + ### Create or Update Account Creates a new company/organization account or updates an existing one. Accounts can be associated with contacts and deals. Supports custom fields. @@ -24,6 +28,10 @@ Creates a new deal or updates an existing one. When creating, provide title, con Creates a new task or updates an existing one. Tasks are typically associated with deals. Supports setting title, due date, task type, assignee, and status. +### Delete Account + +Deletes an ActiveCampaign account/company by ID. + ### Delete Contact Permanently deletes a contact from ActiveCampaign. This removes the contact and all associated data. @@ -32,6 +40,14 @@ Permanently deletes a contact from ActiveCampaign. This removes the contact and Permanently deletes a deal from ActiveCampaign. +### Delete Task + +Deletes a deal task from ActiveCampaign. + +### Get Account + +Retrieves an ActiveCampaign account/company by ID. + ### Get Contact Retrieves a contact's full details including custom field values, tags, list subscriptions, and deal associations. Can look up by contact ID or search by email. @@ -40,6 +56,10 @@ Retrieves a contact's full details including custom field values, tags, list sub Retrieves the full details of a deal by its ID, including associated contact, pipeline, stage, and custom fields. +### Get Task + +Retrieves a deal task by ID, including its related object and assignment fields. + ### List Automations Lists all available automations with their names, statuses, and entry counts. Use this to find automation IDs for adding or removing contacts. @@ -56,6 +76,18 @@ Lists custom field definitions for contacts, deals, or accounts. Use this to dis Lists all deal pipelines and their stages. Useful for finding pipeline and stage IDs needed when creating or updating deals. +### List Task Types + +Lists configured deal task types. Use this to find taskTypeId values before creating tasks. + +### List Tasks + +Lists deal tasks with filters for related object, status, task type, assignee, due date, and text fields. + +### List Users + +Lists ActiveCampaign account users. Use this to find owner, assignee, and list owner IDs for deals, tasks, and lists. + ### Manage Contact Automation Adds a contact to an automation or removes them from one. When removing, provide the contactAutomation ID (available from Get Contact). @@ -76,6 +108,10 @@ Creates, updates, deletes, or retrieves mailing lists. Lists are used for organi Creates, updates, deletes, or lists tags. Tags can be of type "contact" or "template". Use this to manage tag definitions — to add/remove tags from contacts, use the Manage Contact Tags tool instead. +### Manage Webhooks + +Creates, lists, or deletes ActiveCampaign webhooks for contact, campaign, deal, list, and SMS events. + ### Search Accounts Searches and lists company/organization accounts. Supports text search and pagination. diff --git a/integrations/activecampaign/package.json b/integrations/activecampaign/package.json index 97f8f8b642..fe85bf055b 100644 --- a/integrations/activecampaign/package.json +++ b/integrations/activecampaign/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/activecampaign/src/index.ts b/integrations/activecampaign/src/index.ts index a0f1bc981d..2660ad4438 100644 --- a/integrations/activecampaign/src/index.ts +++ b/integrations/activecampaign/src/index.ts @@ -1,24 +1,33 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + associateContactWithAccount, createNote, createOrUpdateAccount, createOrUpdateContact, createOrUpdateDeal, createOrUpdateTask, + deleteAccount, deleteContact, deleteDeal, + deleteTask, + getAccount, getContact, getDeal, + getTask, listAutomations, listCampaigns, listCustomFields, listPipelinesAndStages, + listTasks, + listTaskTypes, + listUsers, manageContactAutomation, manageContactTags, manageListSubscription, manageLists, manageTags, + manageWebhooks, searchAccounts, searchContacts, searchDeals @@ -44,11 +53,20 @@ export let provider = Slate.create({ listCampaigns, listAutomations, createOrUpdateAccount, + getAccount, searchAccounts, + deleteAccount, + associateContactWithAccount, createNote, createOrUpdateTask, + getTask, + listTasks, + deleteTask, + listTaskTypes, listPipelinesAndStages, - listCustomFields + listCustomFields, + listUsers, + manageWebhooks ], triggers: [contactEvents, dealEvents, campaignEvents, smsEvents] }); diff --git a/integrations/activecampaign/src/lib/client.ts b/integrations/activecampaign/src/lib/client.ts index 9645cd6c10..8182078749 100644 --- a/integrations/activecampaign/src/lib/client.ts +++ b/integrations/activecampaign/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { activeCampaignApiError, activeCampaignServiceError } from './errors'; export interface PaginationParams { limit?: number; @@ -39,9 +40,12 @@ export interface ListInput { stringid: string; senderUrl: string; senderReminder: string; + channel?: 'email' | 'sms'; sendLastBroadcast?: boolean; + carboncopy?: string; subscription_notify?: string; unsubscription_notify?: string; + user?: number; } export interface AccountInput { @@ -74,7 +78,24 @@ export class Client { private axios; constructor(params: { token: string; apiUrl: string }) { - let baseUrl = params.apiUrl.replace(/\/+$/, ''); + if (!params.token?.trim()) { + throw activeCampaignServiceError('ActiveCampaign API key is required.'); + } + + if (!params.apiUrl?.trim()) { + throw activeCampaignServiceError('ActiveCampaign API URL is required.'); + } + + let baseUrl = params.apiUrl + .trim() + .replace(/\/+$/, '') + .replace(/\/api\/3$/i, ''); + if (!/^https:\/\//i.test(baseUrl)) { + throw activeCampaignServiceError( + 'ActiveCampaign API URL must be the full HTTPS URL from Settings > Developer.' + ); + } + this.axios = createAxios({ baseURL: `${baseUrl}/api/3`, headers: { @@ -82,6 +103,11 @@ export class Client { 'Content-Type': 'application/json' } }); + + this.axios.interceptors?.response?.use( + (response: any) => response, + (error: unknown) => Promise.reject(activeCampaignApiError(error)) + ); } // ─── Contacts ─────────────────────────────────────────────── @@ -115,9 +141,12 @@ export class Client { params?: PaginationParams & { search?: string; email?: string; + email_like?: string; listid?: string; tagid?: string; status?: number; + id_greater?: number; + 'orders[id]'?: string; orderBy?: string; } ) { @@ -323,7 +352,24 @@ export class Client { // ─── Lists ───────────────────────────────────────────────── async createList(list: ListInput) { - let res = await this.axios.post('/lists', { list }); + let payload: Record = { + name: list.name, + stringid: list.stringid, + sender_url: list.senderUrl, + sender_reminder: list.senderReminder + }; + + if (list.channel) payload.channel = list.channel; + if (list.sendLastBroadcast !== undefined) + payload.send_last_broadcast = list.sendLastBroadcast; + if (list.carboncopy !== undefined) payload.carboncopy = list.carboncopy; + if (list.subscription_notify !== undefined) + payload.subscription_notify = list.subscription_notify; + if (list.unsubscription_notify !== undefined) + payload.unsubscription_notify = list.unsubscription_notify; + if (list.user !== undefined) payload.user = list.user; + + let res = await this.axios.post('/lists', { list: payload }); return res.data; } @@ -333,7 +379,22 @@ export class Client { } async updateList(listId: string, list: Partial) { - let res = await this.axios.put(`/lists/${listId}`, { list }); + let payload: Record = {}; + if (list.name !== undefined) payload.name = list.name; + if (list.stringid !== undefined) payload.stringid = list.stringid; + if (list.senderUrl !== undefined) payload.sender_url = list.senderUrl; + if (list.senderReminder !== undefined) payload.sender_reminder = list.senderReminder; + if (list.channel !== undefined) payload.channel = list.channel; + if (list.sendLastBroadcast !== undefined) + payload.send_last_broadcast = list.sendLastBroadcast; + if (list.carboncopy !== undefined) payload.carboncopy = list.carboncopy; + if (list.subscription_notify !== undefined) + payload.subscription_notify = list.subscription_notify; + if (list.unsubscription_notify !== undefined) + payload.unsubscription_notify = list.unsubscription_notify; + if (list.user !== undefined) payload.user = list.user; + + let res = await this.axios.put(`/lists/${listId}`, { list: payload }); return res.data; } @@ -402,7 +463,7 @@ export class Client { return res.data; } - async listAccounts(params?: PaginationParams & { search?: string }) { + async listAccounts(params?: PaginationParams & { search?: string; count_deals?: boolean }) { let res = await this.axios.get('/accounts', { params }); return res.data; } @@ -473,10 +534,18 @@ export class Client { async listTasks( params?: PaginationParams & { + 'filters[title]'?: string; 'filters[reltype]'?: string; 'filters[relid]'?: string; 'filters[status]'?: number; - 'filters[dealTasktype]'?: string; + 'filters[note]'?: string; + 'filters[duedate]'?: string; + 'filters[due_after]'?: string; + 'filters[due_before]'?: string; + 'filters[duedate_range]'?: string; + 'filters[d_tasktypeid]'?: string; + 'filters[assignee_userid]'?: string; + 'filters[outcome_id]'?: number; } ) { let res = await this.axios.get('/dealTasks', { params }); diff --git a/integrations/activecampaign/src/lib/errors.ts b/integrations/activecampaign/src/lib/errors.ts new file mode 100644 index 0000000000..d8c74e914f --- /dev/null +++ b/integrations/activecampaign/src/lib/errors.ts @@ -0,0 +1,97 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.error); + pushDetail(details, value.error_description); + pushDetail(details, value.title); + pushDetail(details, value.detail); + pushDetail(details, value.code); + collectDetails(value.errors, details); +}; + +let extractActiveCampaignMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + let code = response.data.code ?? response.data.error; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +}; + +export let activeCampaignServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let activeCampaignApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = activeCampaignServiceError( + `ActiveCampaign API ${operation} failed: ${statusLabelFor(response)}${extractActiveCampaignMessage(error)}` + ); + serviceError.data.reason = 'activecampaign_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/activecampaign/src/tools/associate-contact-with-account.ts b/integrations/activecampaign/src/tools/associate-contact-with-account.ts new file mode 100644 index 0000000000..c6f70e6758 --- /dev/null +++ b/integrations/activecampaign/src/tools/associate-contact-with-account.ts @@ -0,0 +1,59 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let associateContactWithAccount = SlateTool.create(spec, { + name: 'Associate Contact with Account', + key: 'associate_contact_with_account', + description: + 'Associates a contact with an account/company using ActiveCampaign account-contact relationships.', + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + contactId: z.string().describe('ID of the contact to associate'), + accountId: z.string().describe('ID of the account/company to associate'), + jobTitle: z + .string() + .optional() + .describe('Optional job title for the contact at the account') + }) + ) + .output( + z.object({ + success: z.boolean(), + accountContactId: z.string().optional(), + contactId: z.string(), + accountId: z.string(), + jobTitle: z.string().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiUrl: ctx.config.apiUrl + }); + + let result = await client.associateContactWithAccount( + ctx.input.contactId, + ctx.input.accountId, + ctx.input.jobTitle + ); + let accountContact = result.accountContact; + + return { + output: { + success: true, + accountContactId: accountContact?.id || undefined, + contactId: accountContact?.contact || ctx.input.contactId, + accountId: accountContact?.account || ctx.input.accountId, + jobTitle: accountContact?.jobTitle || ctx.input.jobTitle || undefined + }, + message: `Contact (ID: ${ctx.input.contactId}) associated with account (ID: ${ctx.input.accountId}).` + }; + }) + .build(); diff --git a/integrations/activecampaign/src/tools/create-or-update-account.ts b/integrations/activecampaign/src/tools/create-or-update-account.ts index 4113becc7e..7c92f489bf 100644 --- a/integrations/activecampaign/src/tools/create-or-update-account.ts +++ b/integrations/activecampaign/src/tools/create-or-update-account.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { activeCampaignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createOrUpdateAccount = SlateTool.create(spec, { @@ -59,7 +60,9 @@ export let createOrUpdateAccount = SlateTool.create(spec, { if (ctx.input.accountId) { result = await client.updateAccount(ctx.input.accountId, accountInput); } else { - if (!ctx.input.name) throw new Error('name is required for creating an account'); + if (!ctx.input.name) { + throw activeCampaignServiceError('name is required for creating an account'); + } result = await client.createAccount(accountInput); } diff --git a/integrations/activecampaign/src/tools/create-or-update-deal.ts b/integrations/activecampaign/src/tools/create-or-update-deal.ts index 40732f5d73..8aed8c87ae 100644 --- a/integrations/activecampaign/src/tools/create-or-update-deal.ts +++ b/integrations/activecampaign/src/tools/create-or-update-deal.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { activeCampaignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createOrUpdateDeal = SlateTool.create(spec, { @@ -86,6 +87,19 @@ export let createOrUpdateDeal = SlateTool.create(spec, { if (ctx.input.dealId) { result = await client.updateDeal(ctx.input.dealId, dealInput); } else { + if (!ctx.input.title) { + throw activeCampaignServiceError('title is required when creating a deal'); + } + if (!ctx.input.contactId && !ctx.input.accountId) { + throw activeCampaignServiceError( + 'contactId or accountId is required when creating a deal' + ); + } + if (!ctx.input.pipelineId && !ctx.input.stageId) { + throw activeCampaignServiceError( + 'pipelineId or stageId is required when creating a deal' + ); + } result = await client.createDeal(dealInput); } diff --git a/integrations/activecampaign/src/tools/delete-account.ts b/integrations/activecampaign/src/tools/delete-account.ts new file mode 100644 index 0000000000..5046efa9c1 --- /dev/null +++ b/integrations/activecampaign/src/tools/delete-account.ts @@ -0,0 +1,38 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let deleteAccount = SlateTool.create(spec, { + name: 'Delete Account', + key: 'delete_account', + description: 'Deletes an ActiveCampaign account/company by ID.', + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + accountId: z.string().describe('ID of the account to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiUrl: ctx.config.apiUrl + }); + + await client.deleteAccount(ctx.input.accountId); + + return { + output: { deleted: true }, + message: `Account (ID: ${ctx.input.accountId}) has been deleted.` + }; + }) + .build(); diff --git a/integrations/activecampaign/src/tools/delete-task.ts b/integrations/activecampaign/src/tools/delete-task.ts new file mode 100644 index 0000000000..6af045d892 --- /dev/null +++ b/integrations/activecampaign/src/tools/delete-task.ts @@ -0,0 +1,38 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let deleteTask = SlateTool.create(spec, { + name: 'Delete Task', + key: 'delete_task', + description: 'Deletes a deal task from ActiveCampaign.', + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + taskId: z.string().describe('ID of the task to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiUrl: ctx.config.apiUrl + }); + + await client.deleteTask(ctx.input.taskId); + + return { + output: { deleted: true }, + message: `Task (ID: ${ctx.input.taskId}) has been deleted.` + }; + }) + .build(); diff --git a/integrations/activecampaign/src/tools/get-account.ts b/integrations/activecampaign/src/tools/get-account.ts new file mode 100644 index 0000000000..30ffd247ea --- /dev/null +++ b/integrations/activecampaign/src/tools/get-account.ts @@ -0,0 +1,53 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getAccount = SlateTool.create(spec, { + name: 'Get Account', + key: 'get_account', + description: 'Retrieves an ActiveCampaign account/company by ID.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + accountId: z.string().describe('ID of the account to retrieve') + }) + ) + .output( + z.object({ + accountId: z.string(), + name: z.string(), + accountUrl: z.string().optional(), + contactCount: z.number().optional(), + dealCount: z.number().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiUrl: ctx.config.apiUrl + }); + + let result = await client.getAccount(ctx.input.accountId); + let account = result.account; + + return { + output: { + accountId: account.id, + name: account.name, + accountUrl: account.accountUrl || undefined, + contactCount: account.contactCount ? Number(account.contactCount) : undefined, + dealCount: account.dealCount ? Number(account.dealCount) : undefined, + createdAt: account.createdTimestamp || account.cdate || undefined, + updatedAt: account.updatedTimestamp || account.udate || undefined + }, + message: `Retrieved account **${account.name}** (ID: ${account.id}).` + }; + }) + .build(); diff --git a/integrations/activecampaign/src/tools/get-contact.ts b/integrations/activecampaign/src/tools/get-contact.ts index fc94a27ae5..986478cb88 100644 --- a/integrations/activecampaign/src/tools/get-contact.ts +++ b/integrations/activecampaign/src/tools/get-contact.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { activeCampaignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let getContact = SlateTool.create(spec, { @@ -56,7 +57,16 @@ export let getContact = SlateTool.create(spec, { }) ) .optional() - .describe('List subscriptions') + .describe('List subscriptions'), + deals: z + .array( + z.object({ + contactDealId: z.string().optional(), + dealId: z.string().optional() + }) + ) + .optional() + .describe('Deal associations for this contact') }) ) .handleInvocation(async ctx => { @@ -72,12 +82,12 @@ export let getContact = SlateTool.create(spec, { if (searchResult.contacts && searchResult.contacts.length > 0) { contactId = searchResult.contacts[0].id; } else { - throw new Error(`No contact found with email: ${ctx.input.email}`); + throw activeCampaignServiceError(`No contact found with email: ${ctx.input.email}`); } } if (!contactId) { - throw new Error('Either contactId or email must be provided'); + throw activeCampaignServiceError('Either contactId or email must be provided'); } let result = await client.getContact(contactId); @@ -101,6 +111,12 @@ export let getContact = SlateTool.create(spec, { status: String(cl.status) })); + let dealsResult = await client.getContactDeals(contactId); + let deals = (dealsResult.contactDeals || []).map((cd: any) => ({ + contactDealId: cd.id || undefined, + dealId: cd.deal || cd.dealId || undefined + })); + return { output: { contactId: contact.id, @@ -112,7 +128,8 @@ export let getContact = SlateTool.create(spec, { updatedAt: contact.udate || undefined, tags, fieldValues, - lists + lists, + deals }, message: `Retrieved contact **${contact.email}** (ID: ${contact.id}).` }; diff --git a/integrations/activecampaign/src/tools/get-task.ts b/integrations/activecampaign/src/tools/get-task.ts new file mode 100644 index 0000000000..af7aec981e --- /dev/null +++ b/integrations/activecampaign/src/tools/get-task.ts @@ -0,0 +1,62 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getTask = SlateTool.create(spec, { + name: 'Get Task', + key: 'get_task', + description: + 'Retrieves a deal task by ID, including its related object and assignment fields.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + taskId: z.string().describe('ID of the task to retrieve') + }) + ) + .output( + z.object({ + taskId: z.string(), + title: z.string().optional(), + note: z.string().optional(), + duedate: z.string().optional(), + status: z.string().optional(), + relType: z.string().optional(), + relId: z.string().optional(), + taskTypeId: z.string().optional(), + assigneeId: z.string().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiUrl: ctx.config.apiUrl + }); + + let result = await client.getTask(ctx.input.taskId); + let task = result.dealTask; + + return { + output: { + taskId: task.id, + title: task.title || undefined, + note: task.note || undefined, + duedate: task.duedate || undefined, + status: task.status !== undefined ? String(task.status) : undefined, + relType: task.reltype || undefined, + relId: task.relid || undefined, + taskTypeId: task.dealTasktype || undefined, + assigneeId: task.assignee || undefined, + createdAt: task.cdate || undefined, + updatedAt: task.udate || undefined + }, + message: `Retrieved task **${task.title || task.id}** (ID: ${task.id}).` + }; + }) + .build(); diff --git a/integrations/activecampaign/src/tools/index.ts b/integrations/activecampaign/src/tools/index.ts index 1538228444..f39b280cc4 100644 --- a/integrations/activecampaign/src/tools/index.ts +++ b/integrations/activecampaign/src/tools/index.ts @@ -1,21 +1,30 @@ +export * from './associate-contact-with-account'; export * from './create-note'; export * from './create-or-update-account'; export * from './create-or-update-contact'; export * from './create-or-update-deal'; export * from './create-or-update-task'; +export * from './delete-account'; export * from './delete-contact'; export * from './delete-deal'; +export * from './delete-task'; +export * from './get-account'; export * from './get-contact'; export * from './get-deal'; +export * from './get-task'; export * from './list-automations'; export * from './list-campaigns'; export * from './list-custom-fields'; export * from './list-pipelines-and-stages'; +export * from './list-task-types'; +export * from './list-tasks'; +export * from './list-users'; export * from './manage-contact-automation'; export * from './manage-contact-tags'; export * from './manage-list-subscription'; export * from './manage-lists'; export * from './manage-tags'; +export * from './manage-webhooks'; export * from './search-accounts'; export * from './search-contacts'; export * from './search-deals'; diff --git a/integrations/activecampaign/src/tools/list-custom-fields.ts b/integrations/activecampaign/src/tools/list-custom-fields.ts index e2c003426a..29849d81bc 100644 --- a/integrations/activecampaign/src/tools/list-custom-fields.ts +++ b/integrations/activecampaign/src/tools/list-custom-fields.ts @@ -65,13 +65,20 @@ export let listCustomFields = SlateTool.create(spec, { let fields = (result[fieldsKey] || []).map((f: any) => ({ fieldId: f.id, title: f.title || f.fieldLabel || undefined, - type: f.type || undefined, - options: f.options - ? Array.isArray(f.options) - ? f.options - : Object.values(f.options) - : undefined, - isRequired: f.isRequired === '1' || f.isRequired === 1 ? true : undefined + type: f.type || f.fieldType || undefined, + options: + (f.options ?? f.fieldOptions) + ? Array.isArray(f.options ?? f.fieldOptions) + ? (f.options ?? f.fieldOptions) + : Object.values(f.options ?? f.fieldOptions) + : undefined, + isRequired: + f.isRequired === '1' || + f.isRequired === 1 || + f.isrequired === '1' || + f.isrequired === 1 + ? true + : undefined })); return { diff --git a/integrations/activecampaign/src/tools/list-task-types.ts b/integrations/activecampaign/src/tools/list-task-types.ts new file mode 100644 index 0000000000..5dff1e0e35 --- /dev/null +++ b/integrations/activecampaign/src/tools/list-task-types.ts @@ -0,0 +1,46 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listTaskTypes = SlateTool.create(spec, { + name: 'List Task Types', + key: 'list_task_types', + description: + 'Lists configured deal task types. Use this to find taskTypeId values before creating tasks.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + taskTypes: z.array( + z.object({ + taskTypeId: z.string(), + title: z.string().optional(), + description: z.string().optional() + }) + ) + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiUrl: ctx.config.apiUrl + }); + + let result = await client.listTaskTypes(); + let taskTypes = (result.dealTasktypes || []).map((taskType: any) => ({ + taskTypeId: taskType.id, + title: taskType.title || undefined, + description: taskType.description || undefined + })); + + return { + output: { taskTypes }, + message: `Found **${taskTypes.length}** task types.` + }; + }) + .build(); diff --git a/integrations/activecampaign/src/tools/list-tasks.ts b/integrations/activecampaign/src/tools/list-tasks.ts new file mode 100644 index 0000000000..03254eb512 --- /dev/null +++ b/integrations/activecampaign/src/tools/list-tasks.ts @@ -0,0 +1,103 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let mapTask = (task: any) => ({ + taskId: task.id, + title: task.title || undefined, + note: task.note || undefined, + duedate: task.duedate || undefined, + status: task.status !== undefined ? String(task.status) : undefined, + relType: task.reltype || undefined, + relId: task.relid || undefined, + taskTypeId: task.dealTasktype || undefined, + assigneeId: task.assignee || undefined, + createdAt: task.cdate || undefined, + updatedAt: task.udate || undefined +}); + +export let listTasks = SlateTool.create(spec, { + name: 'List Tasks', + key: 'list_tasks', + description: + 'Lists deal tasks with filters for related object, status, task type, assignee, due date, and text fields.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + title: z.string().optional().describe('Filter by task title'), + relType: z.string().optional().describe('Filter by related object type, such as Deal'), + relId: z.string().optional().describe('Filter by related object ID'), + status: z.number().optional().describe('Filter by status: 0=incomplete, 1=complete'), + note: z.string().optional().describe('Filter by note content'), + dueDate: z.string().optional().describe('Filter by exact due date'), + dueAfter: z.string().optional().describe('Filter tasks due after this date'), + dueBefore: z.string().optional().describe('Filter tasks due before this date'), + dueDateRange: z + .string() + .optional() + .describe('Filter by date range or bucket, e.g. upcoming, scheduled, or overdue'), + taskTypeId: z.string().optional().describe('Filter by deal task type ID'), + assigneeUserId: z.string().optional().describe('Filter by assignee user ID'), + outcomeId: z.number().optional().describe('Filter by task outcome ID'), + limit: z.number().optional().describe('Maximum number of tasks to return'), + offset: z.number().optional().describe('Pagination offset') + }) + ) + .output( + z.object({ + tasks: z.array( + z.object({ + taskId: z.string(), + title: z.string().optional(), + note: z.string().optional(), + duedate: z.string().optional(), + status: z.string().optional(), + relType: z.string().optional(), + relId: z.string().optional(), + taskTypeId: z.string().optional(), + assigneeId: z.string().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional() + }) + ), + totalCount: z.number().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiUrl: ctx.config.apiUrl + }); + + let params: Record = {}; + if (ctx.input.title) params['filters[title]'] = ctx.input.title; + if (ctx.input.relType) params['filters[reltype]'] = ctx.input.relType; + if (ctx.input.relId) params['filters[relid]'] = ctx.input.relId; + if (ctx.input.status !== undefined) params['filters[status]'] = ctx.input.status; + if (ctx.input.note) params['filters[note]'] = ctx.input.note; + if (ctx.input.dueDate) params['filters[duedate]'] = ctx.input.dueDate; + if (ctx.input.dueAfter) params['filters[due_after]'] = ctx.input.dueAfter; + if (ctx.input.dueBefore) params['filters[due_before]'] = ctx.input.dueBefore; + if (ctx.input.dueDateRange) params['filters[duedate_range]'] = ctx.input.dueDateRange; + if (ctx.input.taskTypeId) params['filters[d_tasktypeid]'] = ctx.input.taskTypeId; + if (ctx.input.assigneeUserId) + params['filters[assignee_userid]'] = ctx.input.assigneeUserId; + if (ctx.input.outcomeId !== undefined) params['filters[outcome_id]'] = ctx.input.outcomeId; + if (ctx.input.limit) params.limit = ctx.input.limit; + if (ctx.input.offset) params.offset = ctx.input.offset; + + let result = await client.listTasks(params); + let tasks = (result.dealTasks || []).map(mapTask); + let totalCount = result.meta?.total ? Number(result.meta.total) : undefined; + + return { + output: { tasks, totalCount }, + message: `Found **${tasks.length}** tasks${totalCount !== undefined ? ` (out of ${totalCount} total)` : ''}.` + }; + }) + .build(); diff --git a/integrations/activecampaign/src/tools/list-users.ts b/integrations/activecampaign/src/tools/list-users.ts new file mode 100644 index 0000000000..a44688775e --- /dev/null +++ b/integrations/activecampaign/src/tools/list-users.ts @@ -0,0 +1,70 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let mapUser = (user: any) => ({ + userId: user.id, + email: user.email || undefined, + firstName: user.firstName || user.first_name || undefined, + lastName: user.lastName || user.last_name || undefined, + username: user.username || undefined, + fullName: user.fullName || user.fullname || user.name || undefined +}); + +export let listUsers = SlateTool.create(spec, { + name: 'List Users', + key: 'list_users', + description: + 'Lists ActiveCampaign account users. Use this to find owner, assignee, and list owner IDs for deals, tasks, and lists.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + currentUserOnly: z + .boolean() + .optional() + .describe('When true, only return the authenticated API user') + }) + ) + .output( + z.object({ + users: z.array( + z.object({ + userId: z.string(), + email: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + username: z.string().optional(), + fullName: z.string().optional() + }) + ) + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiUrl: ctx.config.apiUrl + }); + + if (ctx.input.currentUserOnly) { + let result = await client.getCurrentUser(); + let user = result.user || result; + return { + output: { users: [mapUser(user)] }, + message: 'Retrieved the current ActiveCampaign user.' + }; + } + + let result = await client.listUsers(); + let users = (result.users || []).map(mapUser); + + return { + output: { users }, + message: `Found **${users.length}** users.` + }; + }) + .build(); diff --git a/integrations/activecampaign/src/tools/manage-contact-automation.ts b/integrations/activecampaign/src/tools/manage-contact-automation.ts index 888c9110b3..f72afe05f5 100644 --- a/integrations/activecampaign/src/tools/manage-contact-automation.ts +++ b/integrations/activecampaign/src/tools/manage-contact-automation.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { activeCampaignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageContactAutomation = SlateTool.create(spec, { @@ -45,7 +46,9 @@ export let manageContactAutomation = SlateTool.create(spec, { if (ctx.input.action === 'add') { if (!ctx.input.automationId) { - throw new Error('automationId is required when adding a contact to an automation'); + throw activeCampaignServiceError( + 'automationId is required when adding a contact to an automation' + ); } let result = await client.addContactToAutomation( ctx.input.contactId, @@ -60,7 +63,7 @@ export let manageContactAutomation = SlateTool.create(spec, { }; } else { if (!ctx.input.contactAutomationId) { - throw new Error( + throw activeCampaignServiceError( 'contactAutomationId is required when removing a contact from an automation' ); } diff --git a/integrations/activecampaign/src/tools/manage-contact-tags.ts b/integrations/activecampaign/src/tools/manage-contact-tags.ts index 32716faf8a..585e81bee0 100644 --- a/integrations/activecampaign/src/tools/manage-contact-tags.ts +++ b/integrations/activecampaign/src/tools/manage-contact-tags.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { activeCampaignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageContactTags = SlateTool.create(spec, { @@ -45,7 +46,7 @@ export let manageContactTags = SlateTool.create(spec, { if (ctx.input.action === 'add') { if (!ctx.input.tagId) { - throw new Error('tagId is required when adding a tag'); + throw activeCampaignServiceError('tagId is required when adding a tag'); } let result = await client.addTagToContact(ctx.input.contactId, ctx.input.tagId); return { @@ -57,7 +58,7 @@ export let manageContactTags = SlateTool.create(spec, { }; } else { if (!ctx.input.contactTagId) { - throw new Error('contactTagId is required when removing a tag'); + throw activeCampaignServiceError('contactTagId is required when removing a tag'); } await client.removeTagFromContact(ctx.input.contactTagId); return { diff --git a/integrations/activecampaign/src/tools/manage-lists.ts b/integrations/activecampaign/src/tools/manage-lists.ts index d08bc0c21e..dfb136f7f2 100644 --- a/integrations/activecampaign/src/tools/manage-lists.ts +++ b/integrations/activecampaign/src/tools/manage-lists.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { activeCampaignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageLists = SlateTool.create(spec, { @@ -79,7 +80,7 @@ export let manageLists = SlateTool.create(spec, { !ctx.input.senderUrl || !ctx.input.senderReminder ) { - throw new Error( + throw activeCampaignServiceError( 'name, stringId, senderUrl, and senderReminder are required for creating a list' ); } @@ -105,7 +106,9 @@ export let manageLists = SlateTool.create(spec, { }; } case 'update': { - if (!ctx.input.listId) throw new Error('listId is required for updating a list'); + if (!ctx.input.listId) { + throw activeCampaignServiceError('listId is required for updating a list'); + } let updatePayload: Record = {}; if (ctx.input.name) updatePayload.name = ctx.input.name; if (ctx.input.stringId) updatePayload.stringid = ctx.input.stringId; @@ -126,7 +129,9 @@ export let manageLists = SlateTool.create(spec, { }; } case 'delete': { - if (!ctx.input.listId) throw new Error('listId is required for deleting a list'); + if (!ctx.input.listId) { + throw activeCampaignServiceError('listId is required for deleting a list'); + } await client.deleteList(ctx.input.listId); return { output: { deleted: true }, @@ -134,7 +139,9 @@ export let manageLists = SlateTool.create(spec, { }; } case 'get': { - if (!ctx.input.listId) throw new Error('listId is required for getting a list'); + if (!ctx.input.listId) { + throw activeCampaignServiceError('listId is required for getting a list'); + } let result = await client.getList(ctx.input.listId); let list = result.list; return { diff --git a/integrations/activecampaign/src/tools/manage-tags.ts b/integrations/activecampaign/src/tools/manage-tags.ts index 9ac352dd7d..94924a1974 100644 --- a/integrations/activecampaign/src/tools/manage-tags.ts +++ b/integrations/activecampaign/src/tools/manage-tags.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { activeCampaignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageTags = SlateTool.create(spec, { @@ -65,7 +66,9 @@ export let manageTags = SlateTool.create(spec, { switch (ctx.input.action) { case 'create': { if (!ctx.input.tagName || !ctx.input.tagType) { - throw new Error('tagName and tagType are required for creating a tag'); + throw activeCampaignServiceError( + 'tagName and tagType are required for creating a tag' + ); } let result = await client.createTag({ tag: ctx.input.tagName, @@ -87,7 +90,7 @@ export let manageTags = SlateTool.create(spec, { } case 'update': { if (!ctx.input.tagId) { - throw new Error('tagId is required for updating a tag'); + throw activeCampaignServiceError('tagId is required for updating a tag'); } let updatePayload: Record = {}; if (ctx.input.tagName) updatePayload.tag = ctx.input.tagName; @@ -111,7 +114,7 @@ export let manageTags = SlateTool.create(spec, { } case 'delete': { if (!ctx.input.tagId) { - throw new Error('tagId is required for deleting a tag'); + throw activeCampaignServiceError('tagId is required for deleting a tag'); } await client.deleteTag(ctx.input.tagId); return { diff --git a/integrations/activecampaign/src/tools/manage-webhooks.ts b/integrations/activecampaign/src/tools/manage-webhooks.ts new file mode 100644 index 0000000000..6f7b27495a --- /dev/null +++ b/integrations/activecampaign/src/tools/manage-webhooks.ts @@ -0,0 +1,161 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { activeCampaignServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let webhookEventSchema = z.enum([ + 'forward', + 'open', + 'share', + 'sent', + 'subscribe', + 'subscriber_note', + 'contact_tag_added', + 'contact_tag_removed', + 'unsubscribe', + 'update', + 'deal_add', + 'deal_note_add', + 'deal_pipeline_add', + 'deal_stage_add', + 'deal_task_add', + 'deal_task_complete', + 'deal_tasktype_add', + 'deal_update', + 'bounce', + 'reply', + 'click', + 'list_add', + 'sms_reply', + 'sms_sent', + 'sms_unsub' +]); + +let webhookSourceSchema = z.enum(['public', 'admin', 'api', 'system']); + +let mapWebhook = (webhook: any) => ({ + webhookId: webhook.id, + name: webhook.name || undefined, + url: webhook.url || undefined, + events: Array.isArray(webhook.events) ? webhook.events : undefined, + sources: Array.isArray(webhook.sources) ? webhook.sources : undefined, + listId: webhook.listid || webhook.list || undefined +}); + +export let manageWebhooks = SlateTool.create(spec, { + name: 'Manage Webhooks', + key: 'manage_webhooks', + description: + 'Creates, lists, or deletes ActiveCampaign webhooks for contact, campaign, deal, list, and SMS events.', + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + action: z.enum(['create', 'list', 'delete']).describe('Action to perform'), + webhookId: z.string().optional().describe('Webhook ID required for delete'), + name: z.string().optional().describe('Webhook name required for create'), + url: z.string().optional().describe('Destination URL required for create'), + events: z + .array(webhookEventSchema) + .optional() + .describe('Webhook events required for create'), + sources: z + .array(webhookSourceSchema) + .optional() + .describe('Webhook sources required for create'), + listId: z.string().optional().describe('Optional list ID to scope the webhook'), + limit: z.number().optional().describe('Maximum number of webhooks to return for list'), + offset: z.number().optional().describe('Pagination offset for list') + }) + ) + .output( + z.object({ + webhook: z + .object({ + webhookId: z.string(), + name: z.string().optional(), + url: z.string().optional(), + events: z.array(z.string()).optional(), + sources: z.array(z.string()).optional(), + listId: z.string().optional() + }) + .optional(), + webhooks: z + .array( + z.object({ + webhookId: z.string(), + name: z.string().optional(), + url: z.string().optional(), + events: z.array(z.string()).optional(), + sources: z.array(z.string()).optional(), + listId: z.string().optional() + }) + ) + .optional(), + deleted: z.boolean().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiUrl: ctx.config.apiUrl + }); + + switch (ctx.input.action) { + case 'create': { + if ( + !ctx.input.name || + !ctx.input.url || + !ctx.input.events?.length || + !ctx.input.sources?.length + ) { + throw activeCampaignServiceError( + 'name, url, events, and sources are required for creating a webhook' + ); + } + + let result = await client.createWebhook({ + name: ctx.input.name, + url: ctx.input.url, + events: ctx.input.events, + sources: ctx.input.sources, + listid: ctx.input.listId + }); + let webhook = mapWebhook(result.webhook); + + return { + output: { webhook }, + message: `Webhook **${webhook.name || webhook.webhookId}** created.` + }; + } + case 'delete': { + if (!ctx.input.webhookId) { + throw activeCampaignServiceError('webhookId is required for deleting a webhook'); + } + + await client.deleteWebhook(ctx.input.webhookId); + return { + output: { deleted: true }, + message: `Webhook (ID: ${ctx.input.webhookId}) deleted.` + }; + } + case 'list': { + let params: Record = {}; + if (ctx.input.limit) params.limit = ctx.input.limit; + if (ctx.input.offset) params.offset = ctx.input.offset; + + let result = await client.listWebhooks(params); + let webhooks = (result.webhooks || []).map(mapWebhook); + + return { + output: { webhooks }, + message: `Found **${webhooks.length}** webhooks.` + }; + } + } + }) + .build(); diff --git a/integrations/activecampaign/src/tools/search-accounts.ts b/integrations/activecampaign/src/tools/search-accounts.ts index 057446d7c7..a9b5436f09 100644 --- a/integrations/activecampaign/src/tools/search-accounts.ts +++ b/integrations/activecampaign/src/tools/search-accounts.ts @@ -15,6 +15,10 @@ export let searchAccounts = SlateTool.create(spec, { .input( z.object({ search: z.string().optional().describe('Search term to filter accounts by name'), + includeCounts: z + .boolean() + .optional() + .describe('Whether ActiveCampaign should compute contact and deal counts'), limit: z.number().optional().describe('Maximum number of accounts to return'), offset: z.number().optional().describe('Pagination offset') }) @@ -41,6 +45,7 @@ export let searchAccounts = SlateTool.create(spec, { let params: Record = {}; if (ctx.input.search) params.search = ctx.input.search; + if (ctx.input.includeCounts !== undefined) params.count_deals = ctx.input.includeCounts; if (ctx.input.limit) params.limit = ctx.input.limit; if (ctx.input.offset) params.offset = ctx.input.offset; diff --git a/integrations/activecampaign/src/tools/search-contacts.ts b/integrations/activecampaign/src/tools/search-contacts.ts index 43ded8df2e..a6e0ca6f1b 100644 --- a/integrations/activecampaign/src/tools/search-contacts.ts +++ b/integrations/activecampaign/src/tools/search-contacts.ts @@ -16,6 +16,10 @@ export let searchContacts = SlateTool.create(spec, { z.object({ search: z.string().optional().describe('Free-text search query across contact fields'), email: z.string().optional().describe('Filter by exact email address'), + emailLike: z + .string() + .optional() + .describe('Filter contacts whose email contains this value'), listId: z.string().optional().describe('Filter by list ID'), tagId: z.string().optional().describe('Filter by tag ID'), status: z @@ -28,7 +32,15 @@ export let searchContacts = SlateTool.create(spec, { .number() .optional() .describe('Maximum number of contacts to return (default 20, max 100)'), - offset: z.number().optional().describe('Number of contacts to skip for pagination') + offset: z.number().optional().describe('Number of contacts to skip for pagination'), + idGreater: z + .number() + .optional() + .describe('Only return contacts with an ID greater than this value'), + orderById: z + .enum(['asc', 'desc']) + .optional() + .describe('Order contacts by ID; use with idGreater for large-account pagination') }) ) .output( @@ -59,11 +71,14 @@ export let searchContacts = SlateTool.create(spec, { let params: Record = {}; if (ctx.input.search) params.search = ctx.input.search; if (ctx.input.email) params.email = ctx.input.email; + if (ctx.input.emailLike) params.email_like = ctx.input.emailLike; if (ctx.input.listId) params.listid = ctx.input.listId; if (ctx.input.tagId) params.tagid = ctx.input.tagId; if (ctx.input.status !== undefined) params.status = ctx.input.status; if (ctx.input.limit) params.limit = ctx.input.limit; if (ctx.input.offset) params.offset = ctx.input.offset; + if (ctx.input.idGreater !== undefined) params.id_greater = ctx.input.idGreater; + if (ctx.input.orderById) params['orders[id]'] = ctx.input.orderById; let result = await client.listContacts(params); diff --git a/integrations/adobe-sign/README.md b/integrations/adobe-sign/README.md index 88e15102de..1d5d901d9f 100644 --- a/integrations/adobe-sign/README.md +++ b/integrations/adobe-sign/README.md @@ -20,22 +20,50 @@ Create an embeddable web form (widget) that generates a unique signing URL. Each Download the audit trail PDF for an agreement. The audit trail captures the complete history of events including creation, viewing, signing, delegation, and authentication actions. +### Download Agreement Document + +List or download documents attached to an Adobe Acrobat Sign agreement. Downloads return the file through a Slate attachment; use documentId "combined" or omit documentId to download one combined PDF. + ### Get Agreement Retrieve detailed information about a specific agreement including its status, participants, documents, and metadata. Optionally fetch signing URLs, form field data, or event history for the agreement. +### Get Agreement Members + +Retrieve sender, participant set, next participant, CC, and share information for an Adobe Acrobat Sign agreement. + +### Get Bulk Send + +Retrieve detailed information about a Send in Bulk (MegaSign) parent agreement. + ### Get Agreement Form Data -Retrieve form field data from a completed or in-progress agreement. Returns the values that participants have entered into form fields, useful for extracting data from signed documents. +Retrieve form field data from a completed or in-progress agreement as a Slate attachment. + +### Get Library Template + +Retrieve detailed information about an Adobe Acrobat Sign library template. ### Get Signing URLs Retrieve signing URLs for an agreement. These URLs can be used for embedded signing within your application. Only available when the agreement is waiting for one or more participants to sign. +### Get User + +Retrieve detailed information for a user in the Adobe Acrobat Sign account. + +### Get Web Form + +Retrieve detailed information about an Adobe Acrobat Sign web form (widget). + ### List Agreements List agreements in the account with optional filtering. Returns a paginated list of agreements with their basic details and current status. +### List Bulk Sends + +List Send in Bulk (MegaSign) parent agreements in the Adobe Acrobat Sign account with pagination. + ### List Library Templates List available library document templates. Returns reusable templates that can be referenced when creating agreements or web forms. @@ -50,7 +78,7 @@ List web forms (widgets) in the account. Returns embeddable signing forms with t ### Send in Bulk -Send the same agreement to a large number of recipients simultaneously (MegaSign). Each recipient receives a personalized signing experience. Useful for mass onboarding, policy acknowledgments, or form collection. +Send the same agreement to many recipients using Adobe Acrobat Sign Send in Bulk (MegaSign). Current v6 bulk sends require a CSV transient document containing child agreement recipient information. ### Send Reminder @@ -60,6 +88,18 @@ Send a reminder to participants who have not yet completed their actions on an a Cancel or expire an agreement by updating its state. Use this to cancel agreements that are in progress, or to perform other state transitions. +### Update Bulk Send State + +Update a Send in Bulk (MegaSign) parent agreement state, primarily to cancel an in-progress bulk send. + +### Update Library Template State + +Update the state of an Adobe Acrobat Sign library template, including activating, returning to authoring, or removing a template. + +### Update Web Form State + +Update an Adobe Acrobat Sign web form state, such as activating, deactivating, moving to authoring, or cancelling a web form. + ### Upload Document Upload a file to Adobe Sign as a transient document. Transient documents are temporary files (valid for 7 days) that can be referenced when creating agreements, web forms, or library templates. You must upload a document before using it in any signing workflow. diff --git a/integrations/adobe-sign/package.json b/integrations/adobe-sign/package.json index 5a632ed56f..dcfcd8ee03 100644 --- a/integrations/adobe-sign/package.json +++ b/integrations/adobe-sign/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/adobe-sign/src/auth.ts b/integrations/adobe-sign/src/auth.ts index dd484cd584..3a1727c6b2 100644 --- a/integrations/adobe-sign/src/auth.ts +++ b/integrations/adobe-sign/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { adobeSignRequest, adobeSignServiceError } from './lib/errors'; let scopes = [ { title: 'Read Users', description: 'Read user information', scope: 'user_read:account' }, @@ -49,6 +50,16 @@ let scopes = [ description: 'Create and modify workflows', scope: 'workflow_write:account' }, + { + title: 'Read Web Forms', + description: 'Read web form details', + scope: 'widget_read:account' + }, + { + title: 'Write Web Forms', + description: 'Create and modify web forms', + scope: 'widget_write:account' + }, { title: 'Read Webhooks', description: 'Read webhook configurations', @@ -84,23 +95,33 @@ function createAdobeSignOauth(name: string, key: string, shard: Shard) { handleCallback: async (ctx: any) => { let ax = createAxios({ baseURL: `https://api.${shard}.adobesign.com` }); - let tokenResponse = await ax.post( - '/oauth/v2/token', - new URLSearchParams({ - grant_type: 'authorization_code', - code: ctx.code, - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - redirect_uri: ctx.redirectUri - }).toString(), - { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + let tokenResponse = await adobeSignRequest('OAuth token exchange', () => + ax.post( + '/oauth/v2/token', + new URLSearchParams({ + grant_type: 'authorization_code', + code: ctx.code, + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + redirect_uri: ctx.redirectUri + }).toString(), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ) ); let data = tokenResponse.data; + if (!data.access_token) { + throw adobeSignServiceError( + 'Adobe Acrobat Sign OAuth response did not include an access token.' + ); + } + let expiresAt = new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString(); - let baseUriResponse = await ax.get('/api/rest/v6/baseUris', { - headers: { Authorization: `Bearer ${data.access_token}` } - }); + let baseUriResponse = await adobeSignRequest('base URI lookup', () => + ax.get('/api/rest/v6/baseUris', { + headers: { Authorization: `Bearer ${data.access_token}` } + }) + ); let apiBaseUrl = baseUriResponse.data.apiAccessPoint || `https://api.${shard}.adobesign.com/`; @@ -116,23 +137,35 @@ function createAdobeSignOauth(name: string, key: string, shard: Shard) { }, handleTokenRefresh: async (ctx: any) => { + if (!ctx.output.refreshToken) { + throw adobeSignServiceError('No Adobe Acrobat Sign refresh token is available.'); + } + let ax = createAxios({ baseURL: `https://api.${shard}.adobesign.com` }); - let refreshResponse = await ax.post( - '/oauth/v2/refresh', - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: ctx.output.refreshToken || '', - client_id: ctx.clientId, - client_secret: ctx.clientSecret - }).toString(), - { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + let refreshResponse = await adobeSignRequest('OAuth token refresh', () => + ax.post( + '/oauth/v2/refresh', + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }).toString(), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ) ); let data = refreshResponse.data; + if (!data.access_token) { + throw adobeSignServiceError( + 'Adobe Acrobat Sign refresh response did not include an access token.' + ); + } + let expiresAt = new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString(); return { output: { token: data.access_token, - refreshToken: ctx.output.refreshToken, + refreshToken: data.refresh_token || ctx.output.refreshToken, expiresAt, apiBaseUrl: ctx.output.apiBaseUrl, shard @@ -143,9 +176,11 @@ function createAdobeSignOauth(name: string, key: string, shard: Shard) { getProfile: async (ctx: any) => { let baseUrl = ctx.output.apiBaseUrl || `https://api.${shard}.adobesign.com/`; let ax = createAxios({ baseURL: baseUrl }); - let response = await ax.get('/api/rest/v6/users/me', { - headers: { Authorization: `Bearer ${ctx.output.token}` } - }); + let response = await adobeSignRequest('profile lookup', () => + ax.get('/api/rest/v6/users/me', { + headers: { Authorization: `Bearer ${ctx.output.token}` } + }) + ); let user = response.data; return { profile: { @@ -172,10 +207,16 @@ function createAdobeSignIntegrationKey(name: string, key: string, shard: Shard) ) }), getOutput: async (ctx: { input: { integrationKey: string } }) => { + if (!ctx.input.integrationKey.trim()) { + throw adobeSignServiceError('integrationKey is required.'); + } + let ax = createAxios({ baseURL: `https://api.${shard}.adobesign.com` }); - let baseUriResponse = await ax.get('/api/rest/v6/baseUris', { - headers: { Authorization: `Bearer ${ctx.input.integrationKey}` } - }); + let baseUriResponse = await adobeSignRequest('base URI lookup', () => + ax.get('/api/rest/v6/baseUris', { + headers: { Authorization: `Bearer ${ctx.input.integrationKey}` } + }) + ); let apiBaseUrl = baseUriResponse.data.apiAccessPoint || `https://api.${shard}.adobesign.com/`; return { @@ -189,9 +230,11 @@ function createAdobeSignIntegrationKey(name: string, key: string, shard: Shard) getProfile: async (ctx: any) => { let baseUrl = ctx.output.apiBaseUrl || `https://api.${shard}.adobesign.com/`; let ax = createAxios({ baseURL: baseUrl }); - let response = await ax.get('/api/rest/v6/users/me', { - headers: { Authorization: `Bearer ${ctx.output.token}` } - }); + let response = await adobeSignRequest('profile lookup', () => + ax.get('/api/rest/v6/users/me', { + headers: { Authorization: `Bearer ${ctx.output.token}` } + }) + ); let user = response.data; return { profile: { diff --git a/integrations/adobe-sign/src/index.ts b/integrations/adobe-sign/src/index.ts index 89269f88d5..13afa59414 100644 --- a/integrations/adobe-sign/src/index.ts +++ b/integrations/adobe-sign/src/index.ts @@ -4,17 +4,27 @@ import { createAgreement, createLibraryTemplate, createWebForm, + downloadAgreementDocument, downloadAuditTrail, getAgreement, + getAgreementMembers, + getBulkSend, getFormData, + getLibraryTemplate, getSigningUrls, + getUser, + getWebForm, listAgreements, + listBulkSends, listLibraryTemplates, listUsers, listWebForms, sendInBulk, sendReminder, updateAgreementState, + updateBulkSendState, + updateLibraryTemplateState, + updateWebFormState, uploadDocument } from './tools'; import { agreementEvents, megaSignEvents, webFormEvents } from './triggers'; @@ -26,16 +36,26 @@ export let provider = Slate.create({ createAgreement, getAgreement, listAgreements, + downloadAgreementDocument, + getAgreementMembers, updateAgreementState, getSigningUrls, sendReminder, downloadAuditTrail, getFormData, createWebForm, + getWebForm, listWebForms, + updateWebFormState, createLibraryTemplate, + getLibraryTemplate, listLibraryTemplates, + updateLibraryTemplateState, sendInBulk, + getBulkSend, + listBulkSends, + updateBulkSendState, + getUser, listUsers ] as any, triggers: [agreementEvents, webFormEvents, megaSignEvents] as any diff --git a/integrations/adobe-sign/src/lib/client.ts b/integrations/adobe-sign/src/lib/client.ts index 998b6e5707..38c15067e6 100644 --- a/integrations/adobe-sign/src/lib/client.ts +++ b/integrations/adobe-sign/src/lib/client.ts @@ -1,13 +1,50 @@ import { createAxios } from 'slates'; +import { adobeSignApiError, adobeSignServiceError } from './errors'; + +type AgreementFileInfo = { + transientDocumentId?: string; + libraryDocumentId?: string; + urlFileInfo?: { url: string; name?: string; mimeType?: string }; +}; + +let validateBase64 = (value: string, fieldName: string) => { + if (!value.trim()) { + throw adobeSignServiceError(`${fieldName} must contain base64-encoded file content.`); + } + + try { + return atob(value); + } catch (error) { + let serviceError = adobeSignServiceError(`${fieldName} must be valid base64 data.`); + if (error instanceof Error) serviceError.setParent(error); + throw serviceError; + } +}; + +let validateFileInfos = (fileInfos: AgreementFileInfo[], label: string) => { + if (fileInfos.length === 0) { + throw adobeSignServiceError(`${label} requires at least one fileInfo.`); + } + + for (let [index, fileInfo] of fileInfos.entries()) { + let sourceCount = [ + fileInfo.transientDocumentId, + fileInfo.libraryDocumentId, + fileInfo.urlFileInfo?.url + ].filter(Boolean).length; + + if (sourceCount !== 1) { + throw adobeSignServiceError( + `${label} fileInfos[${index}] must provide exactly one of transientDocumentId, libraryDocumentId, or urlFileInfo.url.` + ); + } + } +}; export class Client { private ax; - constructor(config: { - token: string; - apiBaseUrl?: string; - shard?: string; - }) { + constructor(config: { token: string; apiBaseUrl?: string; shard?: string }) { let baseURL = config.apiBaseUrl || `https://api.${config.shard || 'na1'}.adobesign.com/`; // Ensure trailing slash is removed for consistent URL joining if (baseURL.endsWith('/')) { @@ -19,6 +56,10 @@ export class Client { Authorization: `Bearer ${config.token}` } }); + this.ax.interceptors.response.use( + (response: any) => response, + (error: unknown) => Promise.reject(adobeSignApiError(error)) + ); } // ── Transient Documents ──────────────────────────────────────────── @@ -28,12 +69,16 @@ export class Client { fileContent: string; // base64 encoded mimeType?: string; }): Promise<{ transientDocumentId: string }> { + if (!params.fileName.trim()) { + throw adobeSignServiceError('fileName is required.'); + } + // Build multipart form data manually let boundary = `----SlatesFormBoundary${Date.now().toString(36)}`; let mimeType = params.mimeType || 'application/pdf'; // Decode base64 to binary - let binaryString = atob(params.fileContent); + let binaryString = validateBase64(params.fileContent, 'fileContent'); let bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); @@ -73,11 +118,7 @@ export class Client { role: string; order?: number; }>; - fileInfos: Array<{ - transientDocumentId?: string; - libraryDocumentId?: string; - urlFileInfo?: { url: string; name?: string; mimeType?: string }; - }>; + fileInfos: AgreementFileInfo[]; signatureType?: string; state?: string; ccs?: Array<{ email: string }>; @@ -86,6 +127,8 @@ export class Client { reminderFrequency?: string; expirationTime?: string; }): Promise { + validateFileInfos(params.fileInfos, 'Agreement creation'); + let body: any = { name: params.name, participantSetsInfo: params.participantSetsInfo, @@ -166,8 +209,20 @@ export class Client { return response.data; } - async getAgreementFormData(agreementId: string): Promise { - let response = await this.ax.get(`/api/rest/v6/agreements/${agreementId}/formData`); + async getAgreementCombinedDocument(agreementId: string): Promise { + let response = await this.ax.get( + `/api/rest/v6/agreements/${agreementId}/combinedDocument`, + { + responseType: 'arraybuffer' + } + ); + return response.data; + } + + async getAgreementFormData(agreementId: string): Promise { + let response = await this.ax.get(`/api/rest/v6/agreements/${agreementId}/formData`, { + responseType: 'text' + }); return response.data; } @@ -185,17 +240,30 @@ export class Client { frequency?: string; firstReminderDelay?: number; }): Promise { + if (!params.recipientParticipantIds || params.recipientParticipantIds.length === 0) { + throw adobeSignServiceError( + 'recipientParticipantIds is required to create an Adobe Acrobat Sign reminder.' + ); + } + let body: any = { - agreementId: params.agreementId + status: 'ACTIVE', + recipientParticipantIds: params.recipientParticipantIds }; - if (params.recipientParticipantIds) - body.recipientParticipantIds = params.recipientParticipantIds; - if (params.comment) body.comment = params.comment; + if (params.comment) body.note = params.comment; if (params.frequency) body.frequency = params.frequency; if (params.firstReminderDelay !== undefined) body.firstReminderDelay = params.firstReminderDelay; - let response = await this.ax.post('/api/rest/v6/reminders', body); + let response = await this.ax.post( + `/api/rest/v6/agreements/${params.agreementId}/reminders`, + body + ); + return response.data; + } + + async listAgreementReminders(agreementId: string): Promise { + let response = await this.ax.get(`/api/rest/v6/agreements/${agreementId}/reminders`); return response.data; } @@ -203,29 +271,34 @@ export class Client { async createWebForm(params: { name: string; - fileInfos: Array<{ - transientDocumentId?: string; - libraryDocumentId?: string; - }>; - participantSetsInfo?: Array<{ - memberInfos: Array<{ email: string }>; + fileInfos: AgreementFileInfo[]; + widgetParticipantSetInfo: { + memberInfos: Array<{ email?: string; securityOption?: any }>; role: string; - }>; + }; state?: string; additionalParticipantSetsInfo?: Array<{ memberInfos: Array<{ email: string }>; role: string; }>; + ccs?: Array<{ email: string }>; }): Promise { + validateFileInfos(params.fileInfos, 'Web form creation'); + if (params.fileInfos.some(fileInfo => fileInfo.libraryDocumentId)) { + throw adobeSignServiceError( + 'Web form creation does not support libraryDocumentId in current Adobe Acrobat Sign v6.' + ); + } + let body: any = { name: params.name, fileInfos: params.fileInfos, + widgetParticipantSetInfo: params.widgetParticipantSetInfo, state: params.state || 'ACTIVE' }; - if (params.participantSetsInfo) - body.widgetParticipantSetInfo = { participantSetInfos: params.participantSetsInfo }; if (params.additionalParticipantSetsInfo) body.additionalParticipantSetsInfo = params.additionalParticipantSetsInfo; + if (params.ccs) body.ccs = params.ccs; let response = await this.ax.post('/api/rest/v6/widgets', body); return response.data; @@ -247,10 +320,9 @@ export class Client { async updateWebFormState(widgetId: string, state: string, message?: string): Promise { let body: any = { - state, - widgetStatus: state + state }; - if (message) body.message = message; + if (message) body.widgetInActiveInfo = { message }; await this.ax.put(`/api/rest/v6/widgets/${widgetId}/state`, body); } @@ -265,13 +337,15 @@ export class Client { sharingMode?: string; state?: string; }): Promise { + validateFileInfos(params.fileInfos, 'Library template creation'); + let body: any = { name: params.name, fileInfos: params.fileInfos, templateTypes: params.templateTypes, + sharingMode: params.sharingMode || 'USER', state: params.state || 'ACTIVE' }; - if (params.sharingMode) body.sharingMode = params.sharingMode; let response = await this.ax.post('/api/rest/v6/libraryDocuments', body); return response.data; @@ -291,6 +365,10 @@ export class Client { return response.data; } + async updateLibraryDocumentState(libraryDocumentId: string, state: string): Promise { + await this.ax.put(`/api/rest/v6/libraryDocuments/${libraryDocumentId}/state`, { state }); + } + // ── MegaSign (Send in Bulk) ──────────────────────────────────────── async createMegaSign(params: { @@ -299,26 +377,33 @@ export class Client { transientDocumentId?: string; libraryDocumentId?: string; }>; - recipientSetInfos: Array<{ - recipientSetMemberInfos: Array<{ email: string }>; - }>; + childAgreementsTransientDocumentId: string; signatureType?: string; state?: string; message?: string; ccs?: Array<{ email: string }>; }): Promise { + validateFileInfos(params.fileInfos, 'Send in Bulk creation'); + if (!params.childAgreementsTransientDocumentId.trim()) { + throw adobeSignServiceError( + 'childAgreementsTransientDocumentId is required for Send in Bulk creation.' + ); + } + let body: any = { name: params.name, fileInfos: params.fileInfos, - megaSignInput: { - recipientSetInfos: params.recipientSetInfos, - signatureType: params.signatureType || 'ESIGN', - name: params.name + childAgreementsInfo: { + fileInfo: { + transientDocumentId: params.childAgreementsTransientDocumentId, + fileType: 'CSV' + } }, + signatureType: params.signatureType || 'ESIGN', state: params.state || 'IN_PROCESS' }; - if (params.message) body.megaSignInput.message = params.message; - if (params.ccs) body.megaSignInput.ccs = params.ccs; + if (params.message) body.message = params.message; + if (params.ccs) body.ccs = params.ccs; let response = await this.ax.post('/api/rest/v6/megaSigns', body); return response.data; @@ -338,6 +423,18 @@ export class Client { return response.data; } + async updateMegaSignState( + megaSignId: string, + state: string, + params?: { cancellationInfo?: { comment?: string; notifyOthers?: boolean } } + ): Promise { + let body: any = { state }; + if (params?.cancellationInfo) { + body.megaSignCancellationInfo = params.cancellationInfo; + } + await this.ax.put(`/api/rest/v6/megaSigns/${megaSignId}/state`, body); + } + // ── Users ────────────────────────────────────────────────────────── async listUsers(params?: { cursor?: string; pageSize?: number }): Promise { diff --git a/integrations/adobe-sign/src/lib/errors.ts b/integrations/adobe-sign/src/lib/errors.ts new file mode 100644 index 0000000000..c52a46ffce --- /dev/null +++ b/integrations/adobe-sign/src/lib/errors.ts @@ -0,0 +1,90 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) details.push(detail); +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) collectDetails(item, details); + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.reason); + pushDetail(details, value.message); + pushDetail(details, value.error_description); + pushDetail(details, value.error); + pushDetail(details, value.code); + collectDetails(value.errors, details); +}; + +let extractAdobeSignMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) return details.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) return undefined; + + let code = response.data.code ?? response.data.error; + if (typeof code === 'string' || typeof code === 'number') return String(code); + + let reason = response.data.reason; + if (typeof reason !== 'string') return undefined; + return reason.split(':')[0]?.trim(); +}; + +export let adobeSignServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let adobeSignApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = adobeSignServiceError( + `Adobe Acrobat Sign API ${operation} failed: ${statusLabelFor(response)}${extractAdobeSignMessage(error)}` + ); + serviceError.data.reason = 'adobe_sign_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) serviceError.setParent(error); + + return serviceError; +}; + +export let adobeSignRequest = async (operation: string, run: () => Promise) => { + try { + return await run(); + } catch (error) { + throw adobeSignApiError(error, operation); + } +}; diff --git a/integrations/adobe-sign/src/tools/create-web-form.ts b/integrations/adobe-sign/src/tools/create-web-form.ts index ec9fcabeb0..b339e7eaaf 100644 --- a/integrations/adobe-sign/src/tools/create-web-form.ts +++ b/integrations/adobe-sign/src/tools/create-web-form.ts @@ -9,7 +9,8 @@ export let createWebForm = SlateTool.create(spec, { description: `Create an embeddable web form (widget) that generates a unique signing URL. Each time a participant fills in the web form, a new agreement is generated. Web forms can be embedded on websites or shared via link.`, instructions: [ 'Upload the document first using the Upload Document tool, then reference the transientDocumentId.', - 'A library template ID can also be used instead of a transient document.' + 'Current Adobe Acrobat Sign v6 web form creation supports transientDocumentId or urlFileInfo documents, not libraryDocumentId.', + 'The primary web form participant has an unknown email at creation time; set participantRole and optional participantSecurityOption instead of an email address.' ], tags: { destructive: false, @@ -26,14 +27,31 @@ export let createWebForm = SlateTool.create(spec, { .string() .optional() .describe('ID of a previously uploaded transient document'), - libraryDocumentId: z - .string() + urlFileInfo: z + .object({ + url: z.string().describe('Public URL of the document'), + name: z.string().optional().describe('Display name for the document'), + mimeType: z.string().optional().describe('MIME type of the document') + }) .optional() - .describe('ID of a library document template') + .describe('URL-based document reference') }) ) .describe('Documents to use in the web form'), - participantSetsInfo: z + participantRole: z + .enum(['SIGNER', 'APPROVER', 'ACCEPTOR', 'FORM_FILLER', 'CERTIFIED_RECIPIENT']) + .optional() + .describe('Role of the unknown primary web form participant. Defaults to SIGNER.'), + participantSecurityOption: z + .object({ + authenticationMethod: z + .enum(['NONE', 'PASSWORD', 'PHONE', 'KBA', 'EMAIL_OTP']) + .optional() + .describe('Authentication method for the unknown primary participant') + }) + .optional() + .describe('Optional security settings for the unknown primary web form participant'), + additionalParticipantSetsInfo: z .array( z.object({ memberInfos: z @@ -49,7 +67,15 @@ export let createWebForm = SlateTool.create(spec, { }) ) .optional() - .describe('Pre-defined participant sets for the web form'), + .describe('Additional participants that act after the web form signer'), + ccs: z + .array( + z.object({ + email: z.string().describe('Email address to CC') + }) + ) + .optional() + .describe('Email addresses to CC when web form agreements complete'), state: z .enum(['ACTIVE', 'DRAFT', 'AUTHORING']) .optional() @@ -73,7 +99,16 @@ export let createWebForm = SlateTool.create(spec, { let result = await client.createWebForm({ name: ctx.input.name, fileInfos: ctx.input.fileInfos, - participantSetsInfo: ctx.input.participantSetsInfo, + widgetParticipantSetInfo: { + role: ctx.input.participantRole || 'SIGNER', + memberInfos: [ + ctx.input.participantSecurityOption + ? { securityOption: ctx.input.participantSecurityOption } + : {} + ] + }, + additionalParticipantSetsInfo: ctx.input.additionalParticipantSetsInfo, + ccs: ctx.input.ccs, state: ctx.input.state }); diff --git a/integrations/adobe-sign/src/tools/download-agreement-document.ts b/integrations/adobe-sign/src/tools/download-agreement-document.ts new file mode 100644 index 0000000000..3ed95098d4 --- /dev/null +++ b/integrations/adobe-sign/src/tools/download-agreement-document.ts @@ -0,0 +1,126 @@ +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let toBase64AttachmentContent = (data: unknown) => { + if (typeof data === 'string') { + let buffer = Buffer.from(data, 'binary'); + return { base64: buffer.toString('base64'), byteLength: buffer.byteLength }; + } + + if (data instanceof ArrayBuffer) { + let buffer = Buffer.from(data); + return { base64: buffer.toString('base64'), byteLength: buffer.byteLength }; + } + + if (ArrayBuffer.isView(data)) { + let buffer = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + return { base64: buffer.toString('base64'), byteLength: buffer.byteLength }; + } + + let buffer = Buffer.from(JSON.stringify(data), 'utf8'); + return { base64: buffer.toString('base64'), byteLength: buffer.byteLength }; +}; + +export let downloadAgreementDocument = SlateTool.create(spec, { + name: 'Download Agreement Document', + key: 'download_agreement_document', + description: `List or download documents attached to an Adobe Acrobat Sign agreement. Downloads return the file through a Slate attachment; use documentId "combined" or omit documentId to download one combined PDF.`, + instructions: [ + 'Set listOnly to true to inspect available document IDs before downloading.', + 'Omit documentId, or set it to "combined", to download the combined agreement PDF.', + 'Use a specific documentId from listOnly output to download one agreement document.' + ], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + agreementId: z.string().describe('ID of the agreement containing the document'), + documentId: z + .string() + .optional() + .describe('Document ID to download. Use "combined" or omit for the combined PDF.'), + listOnly: z + .boolean() + .optional() + .default(false) + .describe('If true, only list available documents without downloading content') + }) + ) + .output( + z.object({ + agreementId: z.string().describe('ID of the agreement'), + documents: z + .array( + z.object({ + documentId: z.string().describe('ID of the document'), + label: z.string().optional().describe('Document label or name'), + createdDate: z.string().optional().describe('Document creation date'), + numPages: z.number().optional().describe('Number of pages') + }) + ) + .describe('Available agreement documents'), + documentId: z.string().optional().describe('Downloaded document ID or "combined"'), + documentName: z.string().optional().describe('Best-effort display name'), + mimeType: z.string().optional().describe('MIME type of the attachment'), + byteLength: z.number().optional().describe('Attachment size in bytes'), + attachmentCount: z.number().optional().describe('Number of attachments returned') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + let documentList = await client.getAgreementDocuments(ctx.input.agreementId); + let documents = (documentList.documents || []).map((document: any) => ({ + documentId: document.id, + label: document.label, + createdDate: document.createdDate, + numPages: document.numPages + })); + + if (ctx.input.listOnly) { + return { + output: { + agreementId: ctx.input.agreementId, + documents + }, + message: `Agreement \`${ctx.input.agreementId}\` has **${documents.length}** document(s).` + }; + } + + let targetDocumentId = ctx.input.documentId || 'combined'; + let data = + targetDocumentId === 'combined' + ? await client.getAgreementCombinedDocument(ctx.input.agreementId) + : await client.downloadAgreementDocument(ctx.input.agreementId, targetDocumentId); + let attachment = toBase64AttachmentContent(data); + let targetDocument = documents.find( + (document: { documentId: string }) => document.documentId === targetDocumentId + ); + let documentName = + targetDocumentId === 'combined' + ? 'Combined Agreement Document' + : targetDocument?.label || `Document ${targetDocumentId}`; + + return { + output: { + agreementId: ctx.input.agreementId, + documents, + documentId: targetDocumentId, + documentName, + mimeType: 'application/pdf', + byteLength: attachment.byteLength, + attachmentCount: 1 + }, + attachments: [createBase64Attachment(attachment.base64, 'application/pdf')], + message: `Downloaded **${documentName}** for agreement \`${ctx.input.agreementId}\`.` + }; + }); diff --git a/integrations/adobe-sign/src/tools/get-agreement-members.ts b/integrations/adobe-sign/src/tools/get-agreement-members.ts new file mode 100644 index 0000000000..0a82379466 --- /dev/null +++ b/integrations/adobe-sign/src/tools/get-agreement-members.ts @@ -0,0 +1,54 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getAgreementMembers = SlateTool.create(spec, { + name: 'Get Agreement Members', + key: 'get_agreement_members', + description: `Retrieve sender, participant set, next participant, CC, and share information for an Adobe Acrobat Sign agreement. Use this to find participant IDs needed for reminders or recipient-level operations.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + agreementId: z.string().describe('ID of the agreement whose members should be retrieved') + }) + ) + .output( + z.object({ + agreementId: z.string().describe('ID of the agreement'), + senderInfo: z.any().optional().describe('Sender information'), + participantSets: z.array(z.any()).describe('All participant sets on the agreement'), + nextParticipantSets: z + .array(z.any()) + .describe('Participant sets currently expected to act next'), + ccsInfo: z.array(z.any()).describe('CC participants on the agreement'), + sharesInfo: z.array(z.any()).describe('Agreement share participants') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + let result = await client.getAgreementMembers(ctx.input.agreementId); + let participantSets = result.participantSets || []; + let nextParticipantSets = result.nextParticipantSets || []; + + return { + output: { + agreementId: ctx.input.agreementId, + senderInfo: result.senderInfo, + participantSets, + nextParticipantSets, + ccsInfo: result.ccsInfo || [], + sharesInfo: result.sharesInfo || [] + }, + message: `Agreement \`${ctx.input.agreementId}\` has **${participantSets.length}** participant set(s) and **${nextParticipantSets.length}** next participant set(s).` + }; + }); diff --git a/integrations/adobe-sign/src/tools/get-agreement.ts b/integrations/adobe-sign/src/tools/get-agreement.ts index f28f786100..fc4b5dbca8 100644 --- a/integrations/adobe-sign/src/tools/get-agreement.ts +++ b/integrations/adobe-sign/src/tools/get-agreement.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; @@ -50,12 +50,18 @@ export let getAgreement = SlateTool.create(spec, { .describe( 'Signing URLs for pending signers (only populated if includeSigningUrls is true)' ), - formData: z - .any() + formDataMimeType: z + .string() .optional() - .describe( - 'Form field data from the agreement (only populated if includeFormData is true)' - ), + .describe('MIME type of the form data attachment, if requested'), + formDataByteLength: z + .number() + .optional() + .describe('Size of the form data attachment in bytes, if requested'), + formDataAttachmentCount: z + .number() + .optional() + .describe('Number of form data attachments returned, if requested'), events: z .array(z.any()) .optional() @@ -82,6 +88,8 @@ export let getAgreement = SlateTool.create(spec, { documentVisibilityEnabled: agreement.documentVisibilityEnabled }; + let attachments: ReturnType[] = []; + if (ctx.input.includeSigningUrls) { try { let signingUrlsData = await client.getSigningUrls(ctx.input.agreementId); @@ -95,7 +103,11 @@ export let getAgreement = SlateTool.create(spec, { if (ctx.input.includeFormData) { try { let formData = await client.getAgreementFormData(ctx.input.agreementId); - output.formData = formData; + let content = typeof formData === 'string' ? formData : JSON.stringify(formData); + output.formDataMimeType = 'text/csv'; + output.formDataByteLength = Buffer.byteLength(content, 'utf8'); + output.formDataAttachmentCount = 1; + attachments.push(createTextAttachment(content, 'text/csv')); } catch (e: any) { ctx.warn(`Could not fetch form data: ${e.message}`); } @@ -112,6 +124,7 @@ export let getAgreement = SlateTool.create(spec, { return { output, + attachments, message: `Agreement **${agreement.name}** is in status **${agreement.status}**.` }; }); diff --git a/integrations/adobe-sign/src/tools/get-bulk-send.ts b/integrations/adobe-sign/src/tools/get-bulk-send.ts new file mode 100644 index 0000000000..1c76e6c4e1 --- /dev/null +++ b/integrations/adobe-sign/src/tools/get-bulk-send.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getBulkSend = SlateTool.create(spec, { + name: 'Get Bulk Send', + key: 'get_bulk_send', + description: `Retrieve detailed information about a Send in Bulk (MegaSign) parent agreement, including status, sender, child agreement metadata, recipient configuration, and reminder settings.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + bulkSendId: z.string().describe('ID of the Send in Bulk parent agreement') + }) + ) + .output( + z.object({ + bulkSendId: z.string().describe('ID of the Send in Bulk parent agreement'), + name: z.string().optional().describe('Name of the Send in Bulk operation'), + status: z.string().optional().describe('Current status'), + state: z.string().optional().describe('Current state'), + senderEmail: z.string().optional().describe('Email address of the sender'), + numChildren: z.number().optional().describe('Number of child agreements'), + signatureType: z.string().optional().describe('Signature type'), + childAgreementsInfo: z.any().optional().describe('Child agreement metadata'), + raw: z.any().describe('Raw Send in Bulk detail returned by Adobe Acrobat Sign') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + let bulkSend = await client.getMegaSign(ctx.input.bulkSendId); + + return { + output: { + bulkSendId: bulkSend.id || ctx.input.bulkSendId, + name: bulkSend.name, + status: bulkSend.status, + state: bulkSend.state, + senderEmail: bulkSend.senderEmail, + numChildren: bulkSend.numChildren, + signatureType: bulkSend.signatureType, + childAgreementsInfo: bulkSend.childAgreementsInfo, + raw: bulkSend + }, + message: `Retrieved Send in Bulk \`${bulkSend.id || ctx.input.bulkSendId}\`${bulkSend.status ? ` in status **${bulkSend.status}**` : ''}.` + }; + }); diff --git a/integrations/adobe-sign/src/tools/get-form-data.ts b/integrations/adobe-sign/src/tools/get-form-data.ts index 0ab6e68ba4..6c5bdb9ac5 100644 --- a/integrations/adobe-sign/src/tools/get-form-data.ts +++ b/integrations/adobe-sign/src/tools/get-form-data.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; @@ -20,9 +20,9 @@ export let getFormData = SlateTool.create(spec, { .output( z.object({ agreementId: z.string().describe('ID of the agreement'), - formData: z - .any() - .describe('Form field data as CSV or structured content from the agreement') + mimeType: z.string().describe('MIME type of the returned attachment'), + byteLength: z.number().describe('Size of the returned attachment in bytes'), + attachmentCount: z.number().describe('Number of attachments returned') }) ) .handleInvocation(async ctx => { @@ -33,12 +33,16 @@ export let getFormData = SlateTool.create(spec, { }); let result = await client.getAgreementFormData(ctx.input.agreementId); + let content = typeof result === 'string' ? result : JSON.stringify(result); return { output: { agreementId: ctx.input.agreementId, - formData: result + mimeType: 'text/csv', + byteLength: Buffer.byteLength(content, 'utf8'), + attachmentCount: 1 }, + attachments: [createTextAttachment(content, 'text/csv')], message: `Retrieved form data for agreement \`${ctx.input.agreementId}\`.` }; }); diff --git a/integrations/adobe-sign/src/tools/get-library-template.ts b/integrations/adobe-sign/src/tools/get-library-template.ts new file mode 100644 index 0000000000..e81c0a317b --- /dev/null +++ b/integrations/adobe-sign/src/tools/get-library-template.ts @@ -0,0 +1,58 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getLibraryTemplate = SlateTool.create(spec, { + name: 'Get Library Template', + key: 'get_library_template', + description: `Retrieve detailed information about an Adobe Acrobat Sign library template, including sharing mode, template types, state/status, owner, and file metadata.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + libraryDocumentId: z.string().describe('ID of the library template to retrieve') + }) + ) + .output( + z.object({ + libraryDocumentId: z.string().describe('ID of the library template'), + name: z.string().optional().describe('Template name'), + status: z.string().optional().describe('Template status'), + state: z.string().optional().describe('Template state'), + sharingMode: z.string().optional().describe('Template sharing scope'), + templateTypes: z.array(z.string()).optional().describe('Template types'), + ownerEmail: z.string().optional().describe('Email of the template owner'), + createdDate: z.string().optional().describe('Date the template was created'), + modifiedDate: z.string().optional().describe('Date the template was last modified'), + raw: z.any().describe('Raw library template detail returned by Adobe Acrobat Sign') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + let template = await client.getLibraryDocument(ctx.input.libraryDocumentId); + + return { + output: { + libraryDocumentId: template.id || ctx.input.libraryDocumentId, + name: template.name, + status: template.status, + state: template.state, + sharingMode: template.sharingMode, + templateTypes: template.templateTypes, + ownerEmail: template.ownerEmail, + createdDate: template.createdDate, + modifiedDate: template.modifiedDate, + raw: template + }, + message: `Retrieved library template \`${template.id || ctx.input.libraryDocumentId}\`${template.status ? ` in status **${template.status}**` : ''}.` + }; + }); diff --git a/integrations/adobe-sign/src/tools/get-user.ts b/integrations/adobe-sign/src/tools/get-user.ts new file mode 100644 index 0000000000..fd34142447 --- /dev/null +++ b/integrations/adobe-sign/src/tools/get-user.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getUser = SlateTool.create(spec, { + name: 'Get User', + key: 'get_user', + description: `Retrieve detailed information for a user in the Adobe Acrobat Sign account, including status, role flags, locale, company, and group/account metadata returned by the API.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + userId: z.string().describe('ID of the Adobe Acrobat Sign user to retrieve') + }) + ) + .output( + z.object({ + userId: z.string().describe('ID of the user'), + email: z.string().optional().describe('Email address'), + firstName: z.string().optional().describe('First name'), + lastName: z.string().optional().describe('Last name'), + company: z.string().optional().describe('Company name'), + locale: z.string().optional().describe('User locale'), + status: z.string().optional().describe('User status'), + isAccountAdmin: z.boolean().optional().describe('Whether the user is an account admin'), + raw: z.any().describe('Raw user detail returned by Adobe Acrobat Sign') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + let user = await client.getUser(ctx.input.userId); + + return { + output: { + userId: user.id || ctx.input.userId, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + company: user.company, + locale: user.locale, + status: user.status, + isAccountAdmin: user.isAccountAdmin, + raw: user + }, + message: `Retrieved Adobe Acrobat Sign user \`${user.id || ctx.input.userId}\`${user.email ? ` (${user.email})` : ''}.` + }; + }); diff --git a/integrations/adobe-sign/src/tools/get-web-form.ts b/integrations/adobe-sign/src/tools/get-web-form.ts new file mode 100644 index 0000000000..5ed6c4b156 --- /dev/null +++ b/integrations/adobe-sign/src/tools/get-web-form.ts @@ -0,0 +1,63 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getWebForm = SlateTool.create(spec, { + name: 'Get Web Form', + key: 'get_web_form', + description: `Retrieve detailed information about an Adobe Acrobat Sign web form (widget), including status, URL, owner, participants, files, and creation/modification metadata.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + webFormId: z.string().describe('ID of the web form to retrieve') + }) + ) + .output( + z.object({ + webFormId: z.string().describe('ID of the web form'), + name: z.string().optional().describe('Name of the web form'), + status: z.string().optional().describe('Current web form status'), + url: z.string().optional().describe('Public web form URL'), + javascript: z.string().optional().describe('Embeddable JavaScript snippet'), + ownerEmail: z.string().optional().describe('Email of the web form owner'), + createdDate: z.string().optional().describe('Date the web form was created'), + modifiedDate: z.string().optional().describe('Date the web form was last modified'), + participantSetInfo: z.any().optional().describe('Primary web form participant set'), + additionalParticipantSetsInfo: z + .array(z.any()) + .optional() + .describe('Additional participant sets on the web form'), + raw: z.any().describe('Raw web form detail returned by Adobe Acrobat Sign') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + let webForm = await client.getWebForm(ctx.input.webFormId); + + return { + output: { + webFormId: webForm.id || ctx.input.webFormId, + name: webForm.name, + status: webForm.status, + url: webForm.url, + javascript: webForm.javascript, + ownerEmail: webForm.ownerEmail, + createdDate: webForm.createdDate, + modifiedDate: webForm.modifiedDate, + participantSetInfo: webForm.widgetParticipantSetInfo, + additionalParticipantSetsInfo: webForm.additionalParticipantSetsInfo, + raw: webForm + }, + message: `Retrieved web form \`${webForm.id || ctx.input.webFormId}\`${webForm.status ? ` in status **${webForm.status}**` : ''}.` + }; + }); diff --git a/integrations/adobe-sign/src/tools/index.ts b/integrations/adobe-sign/src/tools/index.ts index 7930316208..d50db6523c 100644 --- a/integrations/adobe-sign/src/tools/index.ts +++ b/integrations/adobe-sign/src/tools/index.ts @@ -1,15 +1,25 @@ export * from './create-agreement'; export * from './create-library-template'; export * from './create-web-form'; +export * from './download-agreement-document'; export * from './download-audit-trail'; export * from './get-agreement'; +export * from './get-agreement-members'; +export * from './get-bulk-send'; export * from './get-form-data'; +export * from './get-library-template'; export * from './get-signing-urls'; +export * from './get-user'; +export * from './get-web-form'; export * from './list-agreements'; +export * from './list-bulk-sends'; export * from './list-library-templates'; export * from './list-users'; export * from './list-web-forms'; export * from './send-in-bulk'; export * from './send-reminder'; export * from './update-agreement-state'; +export * from './update-bulk-send-state'; +export * from './update-library-template-state'; +export * from './update-web-form-state'; export * from './upload-document'; diff --git a/integrations/adobe-sign/src/tools/list-bulk-sends.ts b/integrations/adobe-sign/src/tools/list-bulk-sends.ts new file mode 100644 index 0000000000..5937f80d11 --- /dev/null +++ b/integrations/adobe-sign/src/tools/list-bulk-sends.ts @@ -0,0 +1,64 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listBulkSends = SlateTool.create(spec, { + name: 'List Bulk Sends', + key: 'list_bulk_sends', + description: `List Send in Bulk (MegaSign) parent agreements in the Adobe Acrobat Sign account with pagination.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + cursor: z.string().optional().describe('Pagination cursor from a previous response'), + pageSize: z.number().optional().describe('Number of bulk sends per page') + }) + ) + .output( + z.object({ + bulkSends: z + .array( + z.object({ + bulkSendId: z.string().describe('ID of the Send in Bulk parent agreement'), + name: z.string().optional().describe('Name of the Send in Bulk operation'), + status: z.string().optional().describe('Current status'), + displayDate: z.string().optional().describe('Display date') + }) + ) + .describe('List of Send in Bulk parent agreements'), + cursor: z.string().optional().describe('Cursor for next page, if more results exist'), + totalHits: z.number().optional().describe('Total number of matching results') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + let result = await client.listMegaSigns({ + cursor: ctx.input.cursor, + pageSize: ctx.input.pageSize + }); + + let bulkSends = (result.megaSignList || []).map((bulkSend: any) => ({ + bulkSendId: bulkSend.id, + name: bulkSend.name, + status: bulkSend.status, + displayDate: bulkSend.displayDate + })); + + return { + output: { + bulkSends, + cursor: result.page?.nextCursor, + totalHits: result.page?.totalHits + }, + message: `Found **${bulkSends.length}** Send in Bulk item(s).` + }; + }); diff --git a/integrations/adobe-sign/src/tools/send-in-bulk.ts b/integrations/adobe-sign/src/tools/send-in-bulk.ts index 30bcf175a7..6f825987c2 100644 --- a/integrations/adobe-sign/src/tools/send-in-bulk.ts +++ b/integrations/adobe-sign/src/tools/send-in-bulk.ts @@ -6,10 +6,11 @@ import { spec } from '../spec'; export let sendInBulk = SlateTool.create(spec, { name: 'Send in Bulk', key: 'send_in_bulk', - description: `Send the same agreement to a large number of recipients simultaneously (MegaSign). Each recipient receives a personalized signing experience. Useful for mass onboarding, policy acknowledgments, or form collection.`, + description: `Send the same agreement to many recipients using Adobe Acrobat Sign Send in Bulk (MegaSign). Current v6 bulk sends require a CSV transient document that contains the child agreement recipient information.`, instructions: [ - 'Upload the document first using the Upload Document tool, or use a library template ID.', - 'Each recipient set gets their own copy of the agreement.' + 'Upload the agreement document first using Upload Document, or use a library template ID.', + 'Upload the Adobe Acrobat Sign Send in Bulk recipient CSV using Upload Document with mimeType "text/csv", then pass its transientDocumentId as childAgreementsTransientDocumentId.', + 'Each CSV row creates a child agreement for the corresponding recipient data.' ], tags: { destructive: false, @@ -33,7 +34,11 @@ export let sendInBulk = SlateTool.create(spec, { }) ) .describe('Documents to include'), - recipientEmails: z.array(z.string()).describe('List of recipient email addresses'), + childAgreementsTransientDocumentId: z + .string() + .describe( + 'Transient document ID of the Send in Bulk CSV containing child agreement recipient information' + ), signatureType: z .enum(['ESIGN', 'WRITTEN']) .optional() @@ -51,8 +56,7 @@ export let sendInBulk = SlateTool.create(spec, { ) .output( z.object({ - megaSignId: z.string().describe('ID of the bulk send operation'), - totalRecipients: z.number().describe('Number of recipients') + bulkSendId: z.string().describe('ID of the Send in Bulk operation') }) ) .handleInvocation(async ctx => { @@ -62,14 +66,10 @@ export let sendInBulk = SlateTool.create(spec, { shard: ctx.auth.shard }); - let recipientSetInfos = ctx.input.recipientEmails.map(email => ({ - recipientSetMemberInfos: [{ email }] - })); - let result = await client.createMegaSign({ name: ctx.input.name, fileInfos: ctx.input.fileInfos, - recipientSetInfos, + childAgreementsTransientDocumentId: ctx.input.childAgreementsTransientDocumentId, signatureType: ctx.input.signatureType, message: ctx.input.message, ccs: ctx.input.ccs @@ -77,9 +77,8 @@ export let sendInBulk = SlateTool.create(spec, { return { output: { - megaSignId: result.id, - totalRecipients: ctx.input.recipientEmails.length + bulkSendId: result.id }, - message: `Sent **${ctx.input.name}** in bulk to **${ctx.input.recipientEmails.length}** recipients. MegaSign ID: \`${result.id}\`.` + message: `Created Send in Bulk **${ctx.input.name}** with ID \`${result.id}\`.` }; }); diff --git a/integrations/adobe-sign/src/tools/send-reminder.ts b/integrations/adobe-sign/src/tools/send-reminder.ts index f1ce340661..b878fea76d 100644 --- a/integrations/adobe-sign/src/tools/send-reminder.ts +++ b/integrations/adobe-sign/src/tools/send-reminder.ts @@ -1,8 +1,24 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { adobeSignServiceError } from '../lib/errors'; import { spec } from '../spec'; +let collectParticipantIds = (members: any) => { + let sets = [...(members.nextParticipantSets || []), ...(members.participantSets || [])]; + let ids: string[] = []; + + for (let set of sets) { + for (let member of set.memberInfos || []) { + if (typeof member.id === 'string' && !ids.includes(member.id)) { + ids.push(member.id); + } + } + } + + return ids; +}; + export let sendReminder = SlateTool.create(spec, { name: 'Send Reminder', key: 'send_reminder', @@ -32,7 +48,7 @@ export let sendReminder = SlateTool.create(spec, { .array(z.string()) .optional() .describe( - 'Specific participant IDs to send reminders to. If omitted, sends to all pending participants.' + 'Specific participant IDs to send reminders to. If omitted, the tool uses the agreement member list to target pending participants.' ), firstReminderDelay: z .number() @@ -42,7 +58,12 @@ export let sendReminder = SlateTool.create(spec, { ) .output( z.object({ - reminderId: z.string().describe('ID of the created reminder') + agreementId: z.string().describe('ID of the agreement'), + reminderId: z.string().optional().describe('ID of the created reminder, if returned'), + recipientParticipantIds: z + .array(z.string()) + .describe('Participant IDs targeted by the reminder'), + status: z.string().describe('Requested reminder status') }) ) .handleInvocation(async ctx => { @@ -52,16 +73,33 @@ export let sendReminder = SlateTool.create(spec, { shard: ctx.auth.shard }); + let recipientParticipantIds = ctx.input.recipientParticipantIds; + if (!recipientParticipantIds || recipientParticipantIds.length === 0) { + let members = await client.getAgreementMembers(ctx.input.agreementId); + recipientParticipantIds = collectParticipantIds(members); + } + + if (recipientParticipantIds.length === 0) { + throw adobeSignServiceError( + 'No participant IDs were available for this reminder. Provide recipientParticipantIds explicitly.' + ); + } + let result = await client.createReminder({ agreementId: ctx.input.agreementId, comment: ctx.input.comment, frequency: ctx.input.frequency, - recipientParticipantIds: ctx.input.recipientParticipantIds, + recipientParticipantIds, firstReminderDelay: ctx.input.firstReminderDelay }); return { - output: { reminderId: result.id }, + output: { + agreementId: ctx.input.agreementId, + reminderId: result?.reminderId || result?.id, + recipientParticipantIds, + status: result?.status || 'ACTIVE' + }, message: `Reminder sent for agreement \`${ctx.input.agreementId}\` with frequency **${ctx.input.frequency || 'ONCE'}**.` }; }); diff --git a/integrations/adobe-sign/src/tools/update-bulk-send-state.ts b/integrations/adobe-sign/src/tools/update-bulk-send-state.ts new file mode 100644 index 0000000000..004d46914b --- /dev/null +++ b/integrations/adobe-sign/src/tools/update-bulk-send-state.ts @@ -0,0 +1,60 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let updateBulkSendState = SlateTool.create(spec, { + name: 'Update Bulk Send State', + key: 'update_bulk_send_state', + description: `Update a Send in Bulk (MegaSign) parent agreement state, primarily to cancel an in-progress bulk send.`, + instructions: ['Use CANCELLED to recall an in-progress Send in Bulk operation.'], + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + bulkSendId: z.string().describe('ID of the Send in Bulk parent agreement'), + state: z.enum(['IN_PROCESS', 'CANCELLED']).describe('Target state'), + cancellationComment: z + .string() + .optional() + .describe('Comment explaining why the bulk send is being cancelled'), + notifyOthers: z + .boolean() + .optional() + .describe('Whether to notify participants about the cancellation. Defaults to true.') + }) + ) + .output( + z.object({ + bulkSendId: z.string().describe('ID of the updated Send in Bulk operation'), + state: z.string().describe('Requested new state') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + await client.updateMegaSignState(ctx.input.bulkSendId, ctx.input.state, { + cancellationInfo: + ctx.input.cancellationComment || ctx.input.notifyOthers !== undefined + ? { + comment: ctx.input.cancellationComment, + notifyOthers: ctx.input.notifyOthers ?? true + } + : undefined + }); + + return { + output: { + bulkSendId: ctx.input.bulkSendId, + state: ctx.input.state + }, + message: `Send in Bulk \`${ctx.input.bulkSendId}\` state update requested: **${ctx.input.state}**.` + }; + }); diff --git a/integrations/adobe-sign/src/tools/update-library-template-state.ts b/integrations/adobe-sign/src/tools/update-library-template-state.ts new file mode 100644 index 0000000000..bb4b08da7b --- /dev/null +++ b/integrations/adobe-sign/src/tools/update-library-template-state.ts @@ -0,0 +1,46 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let updateLibraryTemplateState = SlateTool.create(spec, { + name: 'Update Library Template State', + key: 'update_library_template_state', + description: `Update the state of an Adobe Acrobat Sign library template, including activating, returning to authoring, or removing a template.`, + instructions: ['Use REMOVED to retire a suite-owned or obsolete template.'], + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + libraryDocumentId: z.string().describe('ID of the library template to update'), + state: z + .enum(['AUTHORING', 'ACTIVE', 'REMOVED']) + .describe('Target state for the library template') + }) + ) + .output( + z.object({ + libraryDocumentId: z.string().describe('ID of the updated library template'), + state: z.string().describe('Requested new state') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + await client.updateLibraryDocumentState(ctx.input.libraryDocumentId, ctx.input.state); + + return { + output: { + libraryDocumentId: ctx.input.libraryDocumentId, + state: ctx.input.state + }, + message: `Library template \`${ctx.input.libraryDocumentId}\` state update requested: **${ctx.input.state}**.` + }; + }); diff --git a/integrations/adobe-sign/src/tools/update-web-form-state.ts b/integrations/adobe-sign/src/tools/update-web-form-state.ts new file mode 100644 index 0000000000..92cdc0122c --- /dev/null +++ b/integrations/adobe-sign/src/tools/update-web-form-state.ts @@ -0,0 +1,53 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let updateWebFormState = SlateTool.create(spec, { + name: 'Update Web Form State', + key: 'update_web_form_state', + description: `Update an Adobe Acrobat Sign web form state, such as activating, deactivating, moving to authoring, or cancelling a web form.`, + instructions: [ + 'Use INACTIVE to disable an active web form without deleting historical agreements.', + 'Use CANCELLED only for web forms that should no longer be used.' + ], + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + webFormId: z.string().describe('ID of the web form to update'), + state: z + .enum(['ACTIVE', 'INACTIVE', 'AUTHORING', 'CANCELLED']) + .describe('Target state for the web form'), + message: z + .string() + .optional() + .describe('Optional inactive-state message shown to visitors') + }) + ) + .output( + z.object({ + webFormId: z.string().describe('ID of the updated web form'), + state: z.string().describe('Requested new state') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + apiBaseUrl: ctx.auth.apiBaseUrl, + shard: ctx.auth.shard + }); + + await client.updateWebFormState(ctx.input.webFormId, ctx.input.state, ctx.input.message); + + return { + output: { + webFormId: ctx.input.webFormId, + state: ctx.input.state + }, + message: `Web form \`${ctx.input.webFormId}\` state update requested: **${ctx.input.state}**.` + }; + }); diff --git a/integrations/affinda/package.json b/integrations/affinda/package.json index 96f4650d73..c72e2779fb 100644 --- a/integrations/affinda/package.json +++ b/integrations/affinda/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/affinda/src/auth.ts b/integrations/affinda/src/auth.ts index c903407708..41a52a232a 100644 --- a/integrations/affinda/src/auth.ts +++ b/integrations/affinda/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { affindaApiError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -33,15 +34,19 @@ export let auth = SlateAuth.create() } }); - let response = await httpClient.get('/organizations'); - let orgs = response.data as Array<{ identifier: string; name: string }>; - let firstOrg = orgs[0]; + try { + let response = await httpClient.get('/organizations'); + let orgs = response.data as Array<{ identifier: string; name: string }>; + let firstOrg = orgs[0]; - return { - profile: { - id: firstOrg?.identifier, - name: firstOrg?.name - } - }; + return { + profile: { + id: firstOrg?.identifier, + name: firstOrg?.name + } + }; + } catch (error) { + throw affindaApiError(error, 'load auth profile'); + } } }); diff --git a/integrations/affinda/src/index.ts b/integrations/affinda/src/index.ts index c5d53d45ca..9c9ab4ef77 100644 --- a/integrations/affinda/src/index.ts +++ b/integrations/affinda/src/index.ts @@ -10,10 +10,14 @@ import { listDocuments, listDocumentTypes, listOrganizations, + listValidationResults, listWorkspaces, + manageSearchIndexes, + manageTags, matchResumeToJob, redactResume, searchAndMatch, + updateDocument, uploadDocument } from './tools'; import { documentEvents } from './triggers'; @@ -33,6 +37,10 @@ export let provider = Slate.create({ deleteWorkspace, listAnnotations, batchUpdateAnnotations, + updateDocument, + manageTags, + manageSearchIndexes, + listValidationResults, listDocumentTypes, listOrganizations ], diff --git a/integrations/affinda/src/lib/client.ts b/integrations/affinda/src/lib/client.ts index 84d510c24b..24ae19cd33 100644 --- a/integrations/affinda/src/lib/client.ts +++ b/integrations/affinda/src/lib/client.ts @@ -1,5 +1,6 @@ import { Buffer } from 'buffer'; import { createAxios } from 'slates'; +import { affindaApiError } from './errors'; let BASE_URLS: Record = { global: 'https://api.affinda.com/v3', @@ -25,10 +26,19 @@ export class Client { }); } + private async request(operation: string, run: () => Promise<{ data: T }>): Promise { + try { + let response = await run(); + return response.data; + } catch (error) { + throw affindaApiError(error, operation); + } + } + // ---- Documents ---- async uploadDocument(params: { - file?: { name: string; data: string }; + file?: { name: string; data: string; mimeType?: string }; url?: string; workspace?: string; collection?: string; @@ -43,6 +53,10 @@ export class Client { compact?: boolean; deleteAfterParse?: boolean; enableValidationTool?: boolean; + expiryTime?: string; + useOcr?: boolean; + llmHint?: string; + limitToExamples?: string[]; }): Promise { let data: Record = {}; @@ -51,35 +65,37 @@ export class Client { } if (params.workspace) data.workspace = params.workspace; if (params.collection) data.collection = params.collection; - if (params.documentType) data.document_type = params.documentType; + if (params.documentType) data.documentType = params.documentType; if (params.wait !== undefined) data.wait = params.wait; if (params.identifier) data.identifier = params.identifier; - if (params.customIdentifier) data.custom_identifier = params.customIdentifier; - if (params.fileName) data.file_name = params.fileName; + if (params.customIdentifier) data.customIdentifier = params.customIdentifier; + if (params.fileName) data.fileName = params.fileName; if (params.language) data.language = params.language; - if (params.rejectDuplicates !== undefined) - data.reject_duplicates = params.rejectDuplicates; - if (params.lowPriority !== undefined) data.low_priority = params.lowPriority; + if (params.rejectDuplicates !== undefined) data.rejectDuplicates = params.rejectDuplicates; + if (params.lowPriority !== undefined) data.lowPriority = params.lowPriority; if (params.compact !== undefined) data.compact = params.compact; - if (params.deleteAfterParse !== undefined) - data.delete_after_parse = params.deleteAfterParse; + if (params.deleteAfterParse !== undefined) data.deleteAfterParse = params.deleteAfterParse; if (params.enableValidationTool !== undefined) - data.enable_validation_tool = params.enableValidationTool; + data.enableValidationTool = params.enableValidationTool; + if (params.expiryTime) data.expiryTime = params.expiryTime; + if (params.useOcr !== undefined) data.useOcr = params.useOcr; + if (params.llmHint) data.llmHint = params.llmHint; + if (params.limitToExamples) data.limitToExamples = JSON.stringify(params.limitToExamples); + let formData = new FormData(); if (params.file) { let buffer = Buffer.from(params.file.data, 'base64'); - let blob = new Blob([buffer]); - let formData = new FormData(); + let blob = new Blob([buffer], { + type: params.file.mimeType ?? 'application/octet-stream' + }); formData.append('file', blob, params.file.name); - for (let [key, value] of Object.entries(data)) { - formData.append(key, String(value)); - } - let response = await this.axios.post('/documents', formData); - return response.data; } - let response = await this.axios.post('/documents', data); - return response.data; + for (let [key, value] of Object.entries(data)) { + formData.append(key, String(value)); + } + + return this.request('upload document', () => this.axios.post('/documents', formData)); } async getDocument( @@ -89,20 +105,23 @@ export class Client { let queryParams: Record = {}; if (params?.format) queryParams.format = params.format; if (params?.compact !== undefined) queryParams.compact = params.compact; - let response = await this.axios.get(`/documents/${identifier}`, { params: queryParams }); - return response.data; + return this.request('get document', () => + this.axios.get(`/documents/${identifier}`, { params: queryParams }) + ); } async listDocuments(params?: { workspace?: string; collection?: string; state?: string; - tags?: string[]; + tags?: number[]; search?: string; + createdDt?: string; offset?: number; limit?: number; - ordering?: string; + ordering?: string[]; includeData?: boolean; + exclude?: string[]; inReview?: boolean; failed?: boolean; ready?: boolean; @@ -110,6 +129,7 @@ export class Client { hasChallenges?: boolean; customIdentifier?: string; compact?: boolean; + count?: boolean; }): Promise { let queryParams: Record = {}; if (params?.workspace) queryParams.workspace = params.workspace; @@ -117,10 +137,12 @@ export class Client { if (params?.state) queryParams.state = params.state; if (params?.tags) queryParams.tags = params.tags; if (params?.search) queryParams.search = params.search; + if (params?.createdDt) queryParams.created_dt = params.createdDt; if (params?.offset !== undefined) queryParams.offset = params.offset; if (params?.limit !== undefined) queryParams.limit = params.limit; if (params?.ordering) queryParams.ordering = params.ordering; if (params?.includeData !== undefined) queryParams.include_data = params.includeData; + if (params?.exclude) queryParams.exclude = params.exclude; if (params?.inReview !== undefined) queryParams.in_review = params.inReview; if (params?.failed !== undefined) queryParams.failed = params.failed; if (params?.ready !== undefined) queryParams.ready = params.ready; @@ -128,58 +150,46 @@ export class Client { if (params?.hasChallenges !== undefined) queryParams.has_challenges = params.hasChallenges; if (params?.customIdentifier) queryParams.custom_identifier = params.customIdentifier; if (params?.compact !== undefined) queryParams.compact = params.compact; + if (params?.count !== undefined) queryParams.count = params.count; - let response = await this.axios.get('/documents', { params: queryParams }); - return response.data; + return this.request('list documents', () => + this.axios.get('/documents', { params: queryParams }) + ); } - async updateDocument(identifier: string, data: Record): Promise { - let response = await this.axios.patch(`/documents/${identifier}`, data); - return response.data; - } + async updateDocument( + identifier: string, + data: Record, + params?: { compact?: boolean } + ): Promise { + let queryParams: Record = {}; + if (params?.compact !== undefined) queryParams.compact = params.compact; - async deleteDocument(identifier: string): Promise { - await this.axios.delete(`/documents/${identifier}`); + return this.request('update document', () => + this.axios.patch(`/documents/${identifier}`, data, { params: queryParams }) + ); } - async getRedactedDocument( - identifier: string, - params?: { - redactHeadshot?: boolean; - redactPersonalDetails?: boolean; - redactWorkDetails?: boolean; - redactEducationDetails?: boolean; - redactReferees?: boolean; - redactLocations?: boolean; - redactDates?: boolean; - redactGender?: boolean; - redactPdfMetadata?: boolean; - } - ): Promise { - let queryParams: Record = {}; - if (params?.redactHeadshot !== undefined) - queryParams.redact_headshot = params.redactHeadshot; - if (params?.redactPersonalDetails !== undefined) - queryParams.redact_personal_details = params.redactPersonalDetails; - if (params?.redactWorkDetails !== undefined) - queryParams.redact_work_details = params.redactWorkDetails; - if (params?.redactEducationDetails !== undefined) - queryParams.redact_education_details = params.redactEducationDetails; - if (params?.redactReferees !== undefined) - queryParams.redact_referees = params.redactReferees; - if (params?.redactLocations !== undefined) - queryParams.redact_locations = params.redactLocations; - if (params?.redactDates !== undefined) queryParams.redact_dates = params.redactDates; - if (params?.redactGender !== undefined) queryParams.redact_gender = params.redactGender; - if (params?.redactPdfMetadata !== undefined) - queryParams.redact_pdf_metadata = params.redactPdfMetadata; - - let response = await this.axios.get(`/documents/${identifier}/redacted`, { - params: queryParams, - responseType: 'arraybuffer' - }); - let buffer = Buffer.from(response.data as ArrayBuffer); - return buffer.toString('base64'); + async deleteDocument(identifier: string): Promise { + await this.request('delete document', () => this.axios.delete(`/documents/${identifier}`)); + } + + async getRedactedDocument(identifier: string): Promise<{ + contentBase64: string; + mimeType: string; + byteLength: number; + }> { + let data = await this.request('get redacted document', () => + this.axios.get(`/documents/${identifier}/redacted`, { + responseType: 'arraybuffer' + }) + ); + let buffer = Buffer.from(data); + return { + contentBase64: buffer.toString('base64'), + mimeType: 'application/pdf', + byteLength: buffer.byteLength + }; } // ---- Workspaces ---- @@ -187,39 +197,39 @@ export class Client { async listWorkspaces(organization: string, name?: string): Promise { let params: Record = { organization }; if (name) params.name = name; - let response = await this.axios.get('/workspaces', { params }); - return response.data; + return this.request('list workspaces', () => this.axios.get('/workspaces', { params })); } async getWorkspace(identifier: string): Promise { - let response = await this.axios.get(`/workspaces/${identifier}`); - return response.data; + return this.request('get workspace', () => this.axios.get(`/workspaces/${identifier}`)); } async createWorkspace(data: Record): Promise { - let response = await this.axios.post('/workspaces', data); - return response.data; + return this.request('create workspace', () => this.axios.post('/workspaces', data)); } async updateWorkspace(identifier: string, data: Record): Promise { - let response = await this.axios.patch(`/workspaces/${identifier}`, data); - return response.data; + return this.request('update workspace', () => + this.axios.patch(`/workspaces/${identifier}`, data) + ); } async deleteWorkspace(identifier: string): Promise { - await this.axios.delete(`/workspaces/${identifier}`); + await this.request('delete workspace', () => + this.axios.delete(`/workspaces/${identifier}`) + ); } // ---- Organizations ---- async listOrganizations(): Promise { - let response = await this.axios.get('/organizations'); - return response.data; + return this.request('list organizations', () => this.axios.get('/organizations')); } async getOrganization(identifier: string): Promise { - let response = await this.axios.get(`/organizations/${identifier}`); - return response.data; + return this.request('get organization', () => + this.axios.get(`/organizations/${identifier}`) + ); } // ---- Document Types ---- @@ -228,96 +238,132 @@ export class Client { organization?: string; workspace?: string; }): Promise { - let response = await this.axios.get('/document_types', { params }); - return response.data; + return this.request('list document types', () => + this.axios.get('/document_types', { params }) + ); } async getDocumentType(identifier: string): Promise { - let response = await this.axios.get(`/document_types/${identifier}`); - return response.data; + return this.request('get document type', () => + this.axios.get(`/document_types/${identifier}`) + ); } // ---- Annotations ---- async listAnnotations(documentIdentifier: string): Promise { - let response = await this.axios.get('/annotations', { - params: { document: documentIdentifier } - }); - return response.data; + return this.request('list annotations', () => + this.axios.get('/annotations', { + params: { document: documentIdentifier } + }) + ); } async updateAnnotation(annotationId: number, data: Record): Promise { - let response = await this.axios.patch(`/annotations/${annotationId}`, data); - return response.data; + return this.request('update annotation', () => + this.axios.patch(`/annotations/${annotationId}`, data) + ); } async batchUpdateAnnotations(data: Record[]): Promise { - let response = await this.axios.post('/annotations/batch_update', data); - return response.data; + return this.request('batch update annotations', () => + this.axios.post('/annotations/batch_update', data) + ); } async batchCreateAnnotations(data: Record[]): Promise { - let response = await this.axios.post('/annotations/batch_create', data); - return response.data; + return this.request('batch create annotations', () => + this.axios.post('/annotations/batch_create', data) + ); } async batchDeleteAnnotations(data: number[]): Promise { - let response = await this.axios.post('/annotations/batch_delete', data); - return response.data; + return this.request('batch delete annotations', () => + this.axios.post('/annotations/batch_delete', data) + ); } // ---- Validation Results ---- - async listValidationResults(documentIdentifier: string): Promise { - let response = await this.axios.get('/validation_results', { - params: { document: documentIdentifier } - }); - return response.data; + async listValidationResults(params: { + documentIdentifier: string; + offset?: number; + limit?: number; + }): Promise { + let queryParams: Record = { + document: params.documentIdentifier + }; + if (params.offset !== undefined) queryParams.offset = params.offset; + if (params.limit !== undefined) queryParams.limit = params.limit; + + return this.request('list validation results', () => + this.axios.get('/validation_results', { + params: queryParams + }) + ); } async createValidationResult(data: Record): Promise { - let response = await this.axios.post('/validation_results', data); - return response.data; + return this.request('create validation result', () => + this.axios.post('/validation_results', data) + ); } // ---- Tags ---- - async listTags(workspace: string): Promise { - let response = await this.axios.get('/tags', { params: { workspace } }); - return response.data; + async listTags(params?: { + workspace?: string; + name?: string; + limit?: number; + offset?: number; + }): Promise { + return this.request('list tags', () => this.axios.get('/tags', { params })); + } + + async getTag(tagId: number): Promise { + return this.request('get tag', () => this.axios.get(`/tags/${tagId}`)); } async createTag(data: { name: string; workspace: string }): Promise { - let response = await this.axios.post('/tags', data); - return response.data; + return this.request('create tag', () => this.axios.post('/tags', data)); + } + + async updateTag(tagId: number, data: { name?: string; workspace?: string }): Promise { + return this.request('update tag', () => this.axios.patch(`/tags/${tagId}`, data)); + } + + async deleteTag(tagId: number): Promise { + await this.request('delete tag', () => this.axios.delete(`/tags/${tagId}`)); } async batchAddTag(tagId: number, documentIdentifiers: string[]): Promise { - let response = await this.axios.post('/documents/batch_add_tag', { - tag: tagId, - documents: documentIdentifiers - }); - return response.data; + return this.request('add tag to documents', () => + this.axios.post('/documents/batch_add_tag', { + tag: tagId, + identifiers: documentIdentifiers + }) + ); } async batchRemoveTag(tagId: number, documentIdentifiers: string[]): Promise { - let response = await this.axios.post('/documents/batch_remove_tag', { - tag: tagId, - documents: documentIdentifiers - }); - return response.data; + return this.request('remove tag from documents', () => + this.axios.post('/documents/batch_remove_tag', { + tag: tagId, + identifiers: documentIdentifiers + }) + ); } // ---- Search & Match ---- async searchResumes(params: Record): Promise { - let response = await this.axios.post('/resume_search', params); - return response.data; + return this.request('search resumes', () => this.axios.post('/resume_search', params)); } async getResumeSearchDetails(identifier: string, params: Record): Promise { - let response = await this.axios.post(`/resume_search/details/${identifier}`, params); - return response.data; + return this.request('get resume search details', () => + this.axios.post(`/resume_search/details/${identifier}`, params) + ); } async matchResumeToJob( @@ -330,13 +376,15 @@ export class Client { job_description: jobDescriptionIdentifier, ...params }; - let response = await this.axios.get('/resume_search/match', { params: queryParams }); - return response.data; + return this.request('match resume to job', () => + this.axios.get('/resume_search/match', { params: queryParams }) + ); } async searchJobDescriptions(params: Record): Promise { - let response = await this.axios.post('/job_description_search', params); - return response.data; + return this.request('search job descriptions', () => + this.axios.post('/job_description_search', params) + ); } // ---- Resthook Subscriptions ---- @@ -348,33 +396,95 @@ export class Client { workspace?: string; version?: string; }): Promise { - let response = await this.axios.post('/resthook_subscriptions', data); - return response.data; + return this.request('create resthook subscription', () => + this.axios.post('/resthook_subscriptions', data) + ); } async activateResthookSubscription(hookSecret: string): Promise { - let response = await this.axios.post('/resthook_subscriptions/activate', null, { - headers: { 'X-Hook-Secret': hookSecret } - }); - return response.data; + return this.request('activate resthook subscription', () => + this.axios.post('/resthook_subscriptions/activate', null, { + headers: { 'X-Hook-Secret': hookSecret } + }) + ); } async listResthookSubscriptions(params?: { offset?: number; limit?: number }): Promise { - let response = await this.axios.get('/resthook_subscriptions', { params }); - return response.data; + return this.request('list resthook subscriptions', () => + this.axios.get('/resthook_subscriptions', { params }) + ); } async deleteResthookSubscription(subscriptionId: number): Promise { - await this.axios.delete(`/resthook_subscriptions/${subscriptionId}`); + await this.request('delete resthook subscription', () => + this.axios.delete(`/resthook_subscriptions/${subscriptionId}`) + ); } // ---- Indexes ---- - async listIndexes(params?: { documentType?: string; name?: string }): Promise { + async listIndexes(params?: { + documentType?: string; + name?: string; + offset?: number; + limit?: number; + }): Promise { let queryParams: Record = {}; if (params?.documentType) queryParams.document_type = params.documentType; if (params?.name) queryParams.name = params.name; - let response = await this.axios.get('/index', { params: queryParams }); - return response.data; + if (params?.offset !== undefined) queryParams.offset = params.offset; + if (params?.limit !== undefined) queryParams.limit = params.limit; + return this.request('list search indexes', () => + this.axios.get('/index', { params: queryParams }) + ); + } + + async createIndex(data: { name: string; docType: string }): Promise { + return this.request('create search index', () => this.axios.post('/index', data)); + } + + async updateIndex(name: string, data: { name: string }): Promise { + return this.request('update search index', () => + this.axios.patch(`/index/${encodeURIComponent(name)}`, data) + ); + } + + async deleteIndex(name: string): Promise { + await this.request('delete search index', () => + this.axios.delete(`/index/${encodeURIComponent(name)}`) + ); + } + + async listIndexedDocuments( + name: string, + params?: { offset?: number; limit?: number } + ): Promise { + return this.request('list indexed documents', () => + this.axios.get(`/index/${encodeURIComponent(name)}/documents`, { params }) + ); + } + + async indexDocument(name: string, documentIdentifier: string): Promise { + return this.request('index document', () => + this.axios.post(`/index/${encodeURIComponent(name)}/documents`, { + document: documentIdentifier + }) + ); + } + + async deleteIndexedDocument(name: string, documentIdentifier: string): Promise { + await this.request('delete indexed document', () => + this.axios.delete( + `/index/${encodeURIComponent(name)}/documents/${encodeURIComponent(documentIdentifier)}` + ) + ); + } + + async reindexDocument(name: string, documentIdentifier: string): Promise { + await this.request('re-index document', () => + this.axios.post( + `/index/${encodeURIComponent(name)}/documents/${encodeURIComponent(documentIdentifier)}/re_index` + ) + ); } } diff --git a/integrations/affinda/src/lib/errors.ts b/integrations/affinda/src/lib/errors.ts new file mode 100644 index 0000000000..49d1ebaf77 --- /dev/null +++ b/integrations/affinda/src/lib/errors.ts @@ -0,0 +1,100 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let collectAffindaMessages = (value: unknown, messages: string[]) => { + if (!isRecord(value)) { + addMessage(messages, value); + return; + } + + for (let key of ['message', 'detail', 'error', 'type', 'code']) { + addMessage(messages, value[key]); + } + + let nestedError = isRecord(value.error) ? value.error : undefined; + if (nestedError) { + for (let key of ['errorCode', 'errorDetail', 'message', 'detail']) { + addMessage(messages, nestedError[key]); + } + } + + if (Array.isArray(value.errors)) { + for (let error of value.errors) { + collectAffindaMessages(error, messages); + } + } else if (isRecord(value.errors)) { + collectAffindaMessages(value.errors, messages); + } +}; + +let extractAffindaMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectAffindaMessages(response?.data, messages); + if (isRecord(error)) { + collectAffindaMessages(error.data, messages); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getAffindaErrorStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let affindaServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let affindaApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getAffindaErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = affindaServiceError( + `Affinda API ${operation} failed: ${statusLabel}${extractAffindaMessage(error)}` + ); + serviceError.data.reason = 'affinda_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/affinda/src/tools.schema.test.ts b/integrations/affinda/src/tools.schema.test.ts new file mode 100644 index 0000000000..58d3ff59b4 --- /dev/null +++ b/integrations/affinda/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Affinda tool input schemas', provider.actions); diff --git a/integrations/affinda/src/tools/get-document.ts b/integrations/affinda/src/tools/get-document.ts index 183191c618..3fe6fc9e0a 100644 --- a/integrations/affinda/src/tools/get-document.ts +++ b/integrations/affinda/src/tools/get-document.ts @@ -17,7 +17,7 @@ export let getDocument = SlateTool.create(spec, { .string() .describe('Unique identifier of the document to retrieve.'), format: z - .enum(['json', 'xml']) + .enum(['json', 'xml', 'hr-xml']) .optional() .describe('Response format for the extracted data.'), compact: z @@ -31,8 +31,14 @@ export let getDocument = SlateTool.create(spec, { documentIdentifier: z.string().describe('Unique identifier of the document.'), fileName: z.string().optional().describe('Name of the processed file.'), state: z.string().optional().describe('Current processing state of the document.'), + customIdentifier: z.string().optional().describe('Custom document identifier.'), ready: z.boolean().optional().describe('Whether the document has finished processing.'), failed: z.boolean().optional().describe('Whether parsing has failed.'), + reviewUrl: z.string().optional().describe('Affinda validation/review URL.'), + pdfUrl: z + .string() + .optional() + .describe('Temporary URL for the source PDF, if available.'), workspaceIdentifier: z .string() .optional() @@ -41,6 +47,8 @@ export let getDocument = SlateTool.create(spec, { .string() .optional() .describe('Collection the document belongs to.'), + documentTypeIdentifier: z.string().optional().describe('Document type identifier.'), + tags: z.array(z.any()).optional().describe('Tags attached to the document.'), extractedData: z .any() .optional() @@ -65,10 +73,15 @@ export let getDocument = SlateTool.create(spec, { documentIdentifier: meta.identifier ?? ctx.input.documentIdentifier, fileName: meta.fileName, state: meta.state, + customIdentifier: meta.customIdentifier, ready: meta.ready, failed: meta.failed, + reviewUrl: meta.reviewUrl, + pdfUrl: meta.pdf, workspaceIdentifier: meta.workspace?.identifier, collectionIdentifier: meta.collection?.identifier, + documentTypeIdentifier: meta.documentType, + tags: meta.tags, extractedData: result.data }, message: `Retrieved document **${meta.fileName ?? ctx.input.documentIdentifier}** (state: ${meta.state ?? 'unknown'}).` diff --git a/integrations/affinda/src/tools/index.ts b/integrations/affinda/src/tools/index.ts index d5c9c8aced..3cfa3b2455 100644 --- a/integrations/affinda/src/tools/index.ts +++ b/integrations/affinda/src/tools/index.ts @@ -3,9 +3,13 @@ export * from './get-document'; export * from './list-document-types'; export * from './list-documents'; export * from './list-organizations'; +export * from './manage-search-indexes'; +export * from './manage-tags'; export * from './manage-workspace'; export * from './match-resume-to-job'; export * from './redact-resume'; export * from './search-and-match'; export * from './update-annotations'; +export * from './update-document'; export * from './upload-document'; +export * from './validation-results'; diff --git a/integrations/affinda/src/tools/list-documents.ts b/integrations/affinda/src/tools/list-documents.ts index 0ff417f473..2774dbd1c8 100644 --- a/integrations/affinda/src/tools/list-documents.ts +++ b/integrations/affinda/src/tools/list-documents.ts @@ -21,12 +21,15 @@ export let listDocuments = SlateTool.create(spec, { ), collectionIdentifier: z.string().optional().describe('Filter by collection identifier.'), state: z - .string() + .enum(['uploaded', 'review', 'validated', 'archived', 'rejected']) .optional() - .describe( - 'Filter by document state (e.g., "uploaded", "parsed", "validated", "rejected").' - ), + .describe('Filter by document state.'), + tagIds: z.array(z.number()).optional().describe('Filter by tag IDs.'), search: z.string().optional().describe('Search by filename or tag name.'), + createdDate: z + .enum(['today', 'yesterday', 'week', 'month', 'year']) + .optional() + .describe('Filter by created date bucket.'), inReview: z.boolean().optional().describe('Filter for documents currently in review.'), failed: z.boolean().optional().describe('Filter for documents that failed parsing.'), ready: z.boolean().optional().describe('Filter for documents that are ready.'), @@ -34,17 +37,25 @@ export let listDocuments = SlateTool.create(spec, { offset: z.number().optional().describe('Pagination offset (number of items to skip).'), limit: z.number().optional().describe('Maximum number of documents to return.'), ordering: z - .string() + .array(z.string()) .optional() - .describe('Sort field (e.g., "created_dt", "-created_dt", "file_name").'), + .describe('Sort fields, e.g. ["created_dt"] or ["-created_dt", "file_name"].'), includeData: z .boolean() .optional() .describe('If true, includes a summary of parsed data for each document.'), + excludeDocumentIdentifiers: z + .array(z.string()) + .optional() + .describe('Document identifiers to exclude from the list response.'), compact: z .boolean() .optional() - .describe('If true, returns compact document representations.') + .describe('If true, returns compact document representations.'), + includeCount: z + .boolean() + .optional() + .describe('If false, skips computing the total count for large result sets.') }) ) .output( @@ -56,12 +67,16 @@ export let listDocuments = SlateTool.create(spec, { documentIdentifier: z.string().describe('Unique identifier of the document.'), fileName: z.string().optional().describe('Name of the file.'), state: z.string().optional().describe('Current processing state.'), + customIdentifier: z.string().optional().describe('Custom document identifier.'), ready: z .boolean() .optional() .describe('Whether the document has finished processing.'), failed: z.boolean().optional().describe('Whether parsing has failed.'), createdAt: z.string().optional().describe('ISO 8601 creation timestamp.'), + workspaceIdentifier: z.string().optional().describe('Workspace identifier.'), + collectionIdentifier: z.string().optional().describe('Collection identifier.'), + tags: z.array(z.any()).optional().describe('Tags attached to the document.'), extractedData: z .any() .optional() @@ -83,7 +98,9 @@ export let listDocuments = SlateTool.create(spec, { workspace, collection: ctx.input.collectionIdentifier, state: ctx.input.state, + tags: ctx.input.tagIds, search: ctx.input.search, + createdDt: ctx.input.createdDate, inReview: ctx.input.inReview, failed: ctx.input.failed, ready: ctx.input.ready, @@ -92,7 +109,9 @@ export let listDocuments = SlateTool.create(spec, { limit: ctx.input.limit, ordering: ctx.input.ordering, includeData: ctx.input.includeData, - compact: ctx.input.compact + exclude: ctx.input.excludeDocumentIdentifiers, + compact: ctx.input.compact, + count: ctx.input.includeCount }); let results = result.results ?? result; @@ -102,9 +121,13 @@ export let listDocuments = SlateTool.create(spec, { documentIdentifier: meta.identifier ?? '', fileName: meta.fileName, state: meta.state, + customIdentifier: meta.customIdentifier, ready: meta.ready, failed: meta.failed, createdAt: meta.createdDt, + workspaceIdentifier: meta.workspace?.identifier, + collectionIdentifier: meta.collection?.identifier, + tags: meta.tags, extractedData: doc.data }; }); diff --git a/integrations/affinda/src/tools/manage-search-indexes.ts b/integrations/affinda/src/tools/manage-search-indexes.ts new file mode 100644 index 0000000000..e93729eaf7 --- /dev/null +++ b/integrations/affinda/src/tools/manage-search-indexes.ts @@ -0,0 +1,241 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { affindaServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let documentTypeSchema = z.enum(['resumes', 'job_descriptions']); + +let searchIndexSchema = z.object({ + name: z.string().describe('Search index name.'), + documentType: documentTypeSchema.optional().describe('Indexed document type.'), + user: z.any().optional().describe('Affinda user metadata returned for the index.') +}); + +let indexedDocumentSchema = z.object({ + documentIdentifier: z.string().describe('Indexed Affinda document identifier.') +}); + +let mapIndex = (index: any) => ({ + name: index.name, + documentType: index.docType, + user: index.user +}); + +let mapIndexedDocument = (document: any) => ({ + documentIdentifier: document.document ?? document.identifier ?? document +}); + +let requireIndexName = (indexName: string | undefined, action: string) => { + if (!indexName) { + throw affindaServiceError(`indexName is required for "${action}".`); + } + + return indexName; +}; + +let requireDocumentIdentifier = (documentIdentifier: string | undefined, action: string) => { + if (!documentIdentifier) { + throw affindaServiceError(`documentIdentifier is required for "${action}".`); + } + + return documentIdentifier; +}; + +export let manageSearchIndexes = SlateTool.create(spec, { + name: 'Manage Search Indexes', + key: 'manage_search_indexes', + description: `List and manage Affinda Search & Match indexes and indexed documents. Use indexes to make parsed resumes or job descriptions searchable before calling Search & Match tools.`, + instructions: [ + 'For "list", optional filters are documentType, indexName, offset, and limit.', + 'For "create", provide indexName and documentType.', + 'For "update", provide indexName and newIndexName.', + 'For "delete" and "list_documents", provide indexName.', + 'For "add_document", "delete_document", or "reindex_document", provide indexName and documentIdentifier.' + ], + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z + .enum([ + 'list', + 'create', + 'update', + 'delete', + 'list_documents', + 'add_document', + 'delete_document', + 'reindex_document' + ]) + .describe('Search index operation to perform.'), + indexName: z + .string() + .optional() + .describe('Index name. Required for every action except list.'), + newIndexName: z.string().optional().describe('New index name for update.'), + documentType: documentTypeSchema + .optional() + .describe('Index document type for create or list filtering.'), + documentIdentifier: z + .string() + .optional() + .describe('Document identifier for add/delete/reindex document actions.'), + offset: z.number().optional().describe('Pagination offset for list actions.'), + limit: z.number().optional().describe('Maximum number of results to return.') + }) + ) + .output( + z.object({ + action: z.string().describe('Operation that was performed.'), + index: searchIndexSchema.optional().describe('Index returned by create/update.'), + indexes: z.array(searchIndexSchema).optional().describe('Indexes returned by list.'), + documents: z + .array(indexedDocumentSchema) + .optional() + .describe('Documents returned by list_documents.'), + count: z.number().optional().describe('Number of returned items.'), + documentIdentifier: z.string().optional().describe('Indexed document identifier.'), + deleted: z.boolean().optional().describe('Whether an item was deleted.'), + reindexed: z.boolean().optional().describe('Whether a document was re-indexed.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + region: ctx.config.region + }); + + if (ctx.input.action === 'list') { + let result = await client.listIndexes({ + documentType: ctx.input.documentType, + name: ctx.input.indexName, + offset: ctx.input.offset, + limit: ctx.input.limit + }); + let indexes = (Array.isArray(result) ? result : (result.results ?? [])).map(mapIndex); + + return { + output: { + action: ctx.input.action, + indexes, + count: result.count ?? indexes.length + }, + message: `Found **${result.count ?? indexes.length}** search index(es).` + }; + } + + if (ctx.input.action === 'create') { + if (!ctx.input.documentType) { + throw affindaServiceError('documentType is required for "create".'); + } + + let result = await client.createIndex({ + name: requireIndexName(ctx.input.indexName, ctx.input.action), + docType: ctx.input.documentType + }); + + return { + output: { + action: ctx.input.action, + index: mapIndex(result) + }, + message: `Created search index **${result.name}**.` + }; + } + + if (ctx.input.action === 'update') { + let indexName = requireIndexName(ctx.input.indexName, ctx.input.action); + if (!ctx.input.newIndexName) { + throw affindaServiceError('newIndexName is required for "update".'); + } + + let result = await client.updateIndex(indexName, { name: ctx.input.newIndexName }); + + return { + output: { + action: ctx.input.action, + index: mapIndex(result) + }, + message: `Updated search index **${indexName}**.` + }; + } + + if (ctx.input.action === 'delete') { + let indexName = requireIndexName(ctx.input.indexName, ctx.input.action); + await client.deleteIndex(indexName); + + return { + output: { + action: ctx.input.action, + deleted: true + }, + message: `Deleted search index **${indexName}**.` + }; + } + + if (ctx.input.action === 'list_documents') { + let indexName = requireIndexName(ctx.input.indexName, ctx.input.action); + let result = await client.listIndexedDocuments(indexName, { + offset: ctx.input.offset, + limit: ctx.input.limit + }); + let documents = (Array.isArray(result) ? result : (result.results ?? [])).map( + mapIndexedDocument + ); + + return { + output: { + action: ctx.input.action, + documents, + count: result.count ?? documents.length + }, + message: `Found **${result.count ?? documents.length}** indexed document(s).` + }; + } + + let indexName = requireIndexName(ctx.input.indexName, ctx.input.action); + let documentIdentifier = requireDocumentIdentifier( + ctx.input.documentIdentifier, + ctx.input.action + ); + + if (ctx.input.action === 'add_document') { + let result = await client.indexDocument(indexName, documentIdentifier); + + return { + output: { + action: ctx.input.action, + documentIdentifier: result.document ?? documentIdentifier + }, + message: `Indexed document \`${documentIdentifier}\` in **${indexName}**.` + }; + } + + if (ctx.input.action === 'delete_document') { + await client.deleteIndexedDocument(indexName, documentIdentifier); + + return { + output: { + action: ctx.input.action, + deleted: true, + documentIdentifier + }, + message: `Deleted indexed document \`${documentIdentifier}\` from **${indexName}**.` + }; + } + + await client.reindexDocument(indexName, documentIdentifier); + + return { + output: { + action: ctx.input.action, + reindexed: true, + documentIdentifier + }, + message: `Re-indexed document \`${documentIdentifier}\` in **${indexName}**.` + }; + }) + .build(); diff --git a/integrations/affinda/src/tools/manage-tags.ts b/integrations/affinda/src/tools/manage-tags.ts new file mode 100644 index 0000000000..08c4ad5b78 --- /dev/null +++ b/integrations/affinda/src/tools/manage-tags.ts @@ -0,0 +1,233 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { affindaServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let tagOutputSchema = z.object({ + tagId: z.number().describe('Affinda tag ID.'), + name: z.string().optional().describe('Tag name.'), + workspaceIdentifier: z.string().optional().describe('Workspace identifier.'), + documentCount: z.number().optional().describe('Number of documents using this tag.') +}); + +let mapTag = (tag: any) => ({ + tagId: tag.id, + name: tag.name, + workspaceIdentifier: tag.workspace, + documentCount: tag.documentCount +}); + +let requireTagId = (tagId: number | undefined, action: string) => { + if (tagId === undefined) { + throw affindaServiceError(`tagId is required for "${action}".`); + } + + return tagId; +}; + +let requireWorkspace = (workspace: string | undefined, action: string) => { + if (!workspace) { + throw affindaServiceError(`workspaceIdentifier is required for "${action}".`); + } + + return workspace; +}; + +let requireName = (name: string | undefined, action: string) => { + if (!name) { + throw affindaServiceError(`name is required for "${action}".`); + } + + return name; +}; + +let requireDocuments = (documentIdentifiers: string[] | undefined, action: string) => { + if (!documentIdentifiers || documentIdentifiers.length === 0) { + throw affindaServiceError(`documentIdentifiers is required for "${action}".`); + } + + return documentIdentifiers; +}; + +export let manageTags = SlateTool.create(spec, { + name: 'Manage Tags', + key: 'manage_tags', + description: `List, get, create, update, delete, add, and remove Affinda tags. Tags group documents and can be used as filters when listing documents.`, + instructions: [ + 'For "create", provide name and workspaceIdentifier.', + 'For "update", provide tagId plus at least one of name or workspaceIdentifier.', + 'For "get" or "delete", provide tagId.', + 'For "add_to_documents" or "remove_from_documents", provide tagId and documentIdentifiers.' + ], + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z + .enum([ + 'list', + 'get', + 'create', + 'update', + 'delete', + 'add_to_documents', + 'remove_from_documents' + ]) + .describe('Tag operation to perform.'), + tagId: z + .number() + .optional() + .describe( + 'Required for get, update, delete, add_to_documents, and remove_from_documents.' + ), + name: z + .string() + .optional() + .describe('Tag name for create/update, or name filter for list.'), + workspaceIdentifier: z + .string() + .optional() + .describe('Workspace identifier for create/update, or workspace filter for list.'), + documentIdentifiers: z + .array(z.string()) + .optional() + .describe('Document identifiers for add_to_documents and remove_from_documents.'), + limit: z.number().optional().describe('Maximum number of tags to return for list.'), + offset: z.number().optional().describe('Pagination offset for list.') + }) + ) + .output( + z.object({ + action: z.string().describe('Operation that was performed.'), + tag: tagOutputSchema.optional().describe('Tag returned by get/create/update.'), + tags: z.array(tagOutputSchema).optional().describe('Tags returned by list.'), + count: z.number().optional().describe('Number of tags returned.'), + affectedDocumentCount: z + .number() + .optional() + .describe('Number of documents affected by add/remove operations.'), + deleted: z.boolean().optional().describe('Whether a tag was deleted.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + region: ctx.config.region + }); + + if (ctx.input.action === 'list') { + let result = await client.listTags({ + workspace: ctx.input.workspaceIdentifier, + name: ctx.input.name, + limit: ctx.input.limit, + offset: ctx.input.offset + }); + let tags = (Array.isArray(result) ? result : (result.results ?? [])).map(mapTag); + + return { + output: { + action: ctx.input.action, + tags, + count: result.count ?? tags.length + }, + message: `Found **${result.count ?? tags.length}** tag(s).` + }; + } + + if (ctx.input.action === 'get') { + let tagId = requireTagId(ctx.input.tagId, ctx.input.action); + let result = await client.getTag(tagId); + + return { + output: { + action: ctx.input.action, + tag: mapTag(result) + }, + message: `Retrieved tag \`${tagId}\`.` + }; + } + + if (ctx.input.action === 'create') { + let result = await client.createTag({ + name: requireName(ctx.input.name, ctx.input.action), + workspace: requireWorkspace(ctx.input.workspaceIdentifier, ctx.input.action) + }); + + return { + output: { + action: ctx.input.action, + tag: mapTag(result) + }, + message: `Created tag **${result.name}**.` + }; + } + + if (ctx.input.action === 'update') { + let tagId = requireTagId(ctx.input.tagId, ctx.input.action); + if (!ctx.input.name && !ctx.input.workspaceIdentifier) { + throw affindaServiceError('Provide name or workspaceIdentifier for "update".'); + } + + let result = await client.updateTag(tagId, { + name: ctx.input.name, + workspace: ctx.input.workspaceIdentifier + }); + + return { + output: { + action: ctx.input.action, + tag: mapTag(result) + }, + message: `Updated tag \`${tagId}\`.` + }; + } + + if (ctx.input.action === 'delete') { + let tagId = requireTagId(ctx.input.tagId, ctx.input.action); + await client.deleteTag(tagId); + + return { + output: { + action: ctx.input.action, + deleted: true + }, + message: `Deleted tag \`${tagId}\`.` + }; + } + + if (ctx.input.action === 'add_to_documents') { + let tagId = requireTagId(ctx.input.tagId, ctx.input.action); + let documentIdentifiers = requireDocuments( + ctx.input.documentIdentifiers, + ctx.input.action + ); + await client.batchAddTag(tagId, documentIdentifiers); + + return { + output: { + action: ctx.input.action, + affectedDocumentCount: documentIdentifiers.length + }, + message: `Added tag \`${tagId}\` to **${documentIdentifiers.length}** document(s).` + }; + } + + let tagId = requireTagId(ctx.input.tagId, ctx.input.action); + let documentIdentifiers = requireDocuments( + ctx.input.documentIdentifiers, + ctx.input.action + ); + await client.batchRemoveTag(tagId, documentIdentifiers); + + return { + output: { + action: ctx.input.action, + affectedDocumentCount: documentIdentifiers.length + }, + message: `Removed tag \`${tagId}\` from **${documentIdentifiers.length}** document(s).` + }; + }) + .build(); diff --git a/integrations/affinda/src/tools/match-resume-to-job.ts b/integrations/affinda/src/tools/match-resume-to-job.ts index 1c2da6db29..deeaab8598 100644 --- a/integrations/affinda/src/tools/match-resume-to-job.ts +++ b/integrations/affinda/src/tools/match-resume-to-job.ts @@ -20,6 +20,10 @@ Both the resume and job description must already be uploaded and parsed in Affin .string() .describe('Identifier of the parsed job description document.'), indexName: z.string().optional().describe('Specific index to use for matching.'), + searchExpression: z + .string() + .optional() + .describe('Keywords to add to the search criteria.'), jobTitlesWeight: z.number().optional().describe('Weight for job titles (0 to 1).'), skillsWeight: z.number().optional().describe('Weight for skills (0 to 1).'), educationWeight: z.number().optional().describe('Weight for education (0 to 1).'), @@ -32,7 +36,12 @@ Both the resume and job description must already be uploaded and parsed in Affin managementLevelWeight: z .number() .optional() - .describe('Weight for management level (0 to 1).') + .describe('Weight for management level (0 to 1).'), + searchExpressionWeight: z + .number() + .optional() + .describe('Weight for keyword search expression (0 to 1).'), + socCodesWeight: z.number().optional().describe('Weight for SOC codes (0 to 1).') }) ) .output( @@ -49,6 +58,7 @@ Both the resume and job description must already be uploaded and parsed in Affin let extraParams: Record = {}; if (ctx.input.indexName) extraParams.index = ctx.input.indexName; + if (ctx.input.searchExpression) extraParams.search_expression = ctx.input.searchExpression; if (ctx.input.jobTitlesWeight !== undefined) extraParams.job_titles_weight = ctx.input.jobTitlesWeight; if (ctx.input.skillsWeight !== undefined) @@ -63,6 +73,10 @@ Both the resume and job description must already be uploaded and parsed in Affin extraParams.languages_weight = ctx.input.languagesWeight; if (ctx.input.managementLevelWeight !== undefined) extraParams.management_level_weight = ctx.input.managementLevelWeight; + if (ctx.input.searchExpressionWeight !== undefined) + extraParams.search_expression_weight = ctx.input.searchExpressionWeight; + if (ctx.input.socCodesWeight !== undefined) + extraParams.soc_codes_weight = ctx.input.socCodesWeight; let result = await client.matchResumeToJob( ctx.input.resumeIdentifier, diff --git a/integrations/affinda/src/tools/redact-resume.ts b/integrations/affinda/src/tools/redact-resume.ts index edc05fe4a2..251edbadb7 100644 --- a/integrations/affinda/src/tools/redact-resume.ts +++ b/integrations/affinda/src/tools/redact-resume.ts @@ -1,17 +1,15 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; export let redactResume = SlateTool.create(spec, { - name: 'Redact Resume', + name: 'Get Redacted Document', key: 'redact_resume', - description: `Generate a redacted version of a parsed resume document. Select which categories of personally identifiable information (PII) to redact, including personal details (name, address, phone, email), work details (company names), education details (university names), headshots, referees, locations, dates, gender, and PDF metadata. - -Returns a base64-encoded PDF with the selected fields redacted. The original document is not modified.`, + description: `Retrieve Affinda's redacted PDF for a parsed document. The original document is not modified. The PDF file is returned as a Slate attachment and structured output is limited to metadata.`, instructions: [ 'The document must already be uploaded and parsed in Affinda before redaction.', - 'All redaction options default to true if not specified — set specific options to false to preserve those fields.' + 'Affinda applies redaction according to the document and account configuration for this endpoint.' ], tags: { readOnly: true @@ -21,47 +19,15 @@ Returns a base64-encoded PDF with the selected fields redacted. The original doc z.object({ documentIdentifier: z .string() - .describe('Identifier of the parsed resume document to redact.'), - redactHeadshot: z - .boolean() - .optional() - .describe('Redact the candidate headshot photo. Defaults to true.'), - redactPersonalDetails: z - .boolean() - .optional() - .describe('Redact personal details (name, address, phone, email). Defaults to true.'), - redactWorkDetails: z - .boolean() - .optional() - .describe('Redact work details such as company names. Defaults to true.'), - redactEducationDetails: z - .boolean() - .optional() - .describe('Redact education details such as university names. Defaults to true.'), - redactReferees: z - .boolean() - .optional() - .describe('Redact referee information. Defaults to true.'), - redactLocations: z - .boolean() - .optional() - .describe('Redact location names. Defaults to true.'), - redactDates: z.boolean().optional().describe('Redact dates. Defaults to true.'), - redactGender: z - .boolean() - .optional() - .describe('Redact gender information. Defaults to true.'), - redactPdfMetadata: z - .boolean() - .optional() - .describe('Redact PDF metadata. Defaults to true.') + .describe('Identifier of the parsed document to retrieve redacted PDF for.') }) ) .output( z.object({ - redactedPdfBase64: z - .string() - .describe('Base64-encoded content of the redacted PDF file.') + documentIdentifier: z.string().describe('Identifier of the redacted document.'), + mimeType: z.string().describe('MIME type of the returned attachment.'), + byteLength: z.number().describe('Decoded byte length of the returned attachment.'), + attachmentCount: z.number().describe('Number of Slate attachments returned.') }) ) .handleInvocation(async ctx => { @@ -72,23 +38,17 @@ Returns a base64-encoded PDF with the selected fields redacted. The original doc ctx.info('Generating redacted document...'); - let base64Pdf = await client.getRedactedDocument(ctx.input.documentIdentifier, { - redactHeadshot: ctx.input.redactHeadshot, - redactPersonalDetails: ctx.input.redactPersonalDetails, - redactWorkDetails: ctx.input.redactWorkDetails, - redactEducationDetails: ctx.input.redactEducationDetails, - redactReferees: ctx.input.redactReferees, - redactLocations: ctx.input.redactLocations, - redactDates: ctx.input.redactDates, - redactGender: ctx.input.redactGender, - redactPdfMetadata: ctx.input.redactPdfMetadata - }); + let file = await client.getRedactedDocument(ctx.input.documentIdentifier); return { output: { - redactedPdfBase64: base64Pdf + documentIdentifier: ctx.input.documentIdentifier, + mimeType: file.mimeType, + byteLength: file.byteLength, + attachmentCount: 1 }, - message: `Redacted PDF generated for document \`${ctx.input.documentIdentifier}\`.` + attachments: [createBase64Attachment(file.contentBase64, file.mimeType)], + message: `Redacted PDF generated for document \`${ctx.input.documentIdentifier}\` as an attachment.` }; }) .build(); diff --git a/integrations/affinda/src/tools/search-and-match.ts b/integrations/affinda/src/tools/search-and-match.ts index b3d1198d52..6e8a72a26f 100644 --- a/integrations/affinda/src/tools/search-and-match.ts +++ b/integrations/affinda/src/tools/search-and-match.ts @@ -5,8 +5,13 @@ import { spec } from '../spec'; let locationSchema = z.object({ name: z.string().optional().describe('Location name.'), - latitude: z.number().optional().describe('Latitude coordinate.'), - longitude: z.number().optional().describe('Longitude coordinate.'), + coordinates: z + .object({ + latitude: z.number().describe('Latitude coordinate.'), + longitude: z.number().describe('Longitude coordinate.') + }) + .optional() + .describe('Location coordinates.'), distance: z.number().optional().describe('Search radius distance.'), unit: z.string().optional().describe('Distance unit (e.g., "km", "mi").') }); @@ -62,6 +67,10 @@ Returns a ranked shortlist with matching scores for each category.`, .boolean() .optional() .describe('Whether job title match is required.'), + jobTitlesCurrentOnly: z + .boolean() + .optional() + .describe('Match only current job titles when searching resumes.'), skills: z.array(skillSchema).optional().describe('Skills to search for.'), skillsWeight: z.number().optional().describe('Weight for skills matching (0 to 1).'), locations: z.array(locationSchema).optional().describe('Locations to search for.'), @@ -140,6 +149,8 @@ Returns a ranked shortlist with matching scores for each category.`, searchParams.jobTitlesWeight = ctx.input.jobTitlesWeight; if (ctx.input.jobTitlesRequired !== undefined) searchParams.jobTitlesRequired = ctx.input.jobTitlesRequired; + if (ctx.input.jobTitlesCurrentOnly !== undefined) + searchParams.jobTitlesCurrentOnly = ctx.input.jobTitlesCurrentOnly; if (ctx.input.skills) searchParams.skills = ctx.input.skills; if (ctx.input.skillsWeight !== undefined) searchParams.skillsWeight = ctx.input.skillsWeight; diff --git a/integrations/affinda/src/tools/update-document.ts b/integrations/affinda/src/tools/update-document.ts new file mode 100644 index 0000000000..a00fdddf15 --- /dev/null +++ b/integrations/affinda/src/tools/update-document.ts @@ -0,0 +1,131 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { affindaServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let updateDocument = SlateTool.create(spec, { + name: 'Update Document', + key: 'update_document', + description: `Update Affinda document metadata and lifecycle state. Use this to rename a document, move it to another workspace or collection, update custom identifiers, set an expiry time, archive, confirm, reject, or skip parsing.`, + instructions: [ + 'Provide at least one field to update.', + 'Use delete_document only when the document should be permanently removed.' + ], + tags: { + destructive: false + } +}) + .input( + z.object({ + documentIdentifier: z.string().describe('Identifier of the document to update.'), + workspaceIdentifier: z + .string() + .optional() + .describe('Move the document to this workspace.'), + collectionIdentifier: z + .string() + .optional() + .describe('Move the document to this collection.'), + documentTypeIdentifier: z + .string() + .optional() + .describe('Set the document type identifier.'), + fileName: z.string().optional().describe('New document file name.'), + expiryTime: z + .string() + .optional() + .describe('ISO-8601 date-time when Affinda should automatically delete the document.'), + isConfirmed: z.boolean().optional().describe('Mark the document as confirmed.'), + isRejected: z.boolean().optional().describe('Mark the document as rejected.'), + isArchived: z.boolean().optional().describe('Mark the document as archived.'), + skipParse: z.boolean().optional().describe('Skip parsing for this document.'), + language: z.string().optional().describe('Language code in ISO 639-1 format.'), + identifier: z + .string() + .optional() + .describe('Deprecated Affinda identifier override. Prefer customIdentifier.'), + customIdentifier: z + .string() + .optional() + .describe('Custom identifier for external reference.'), + llmHint: z + .string() + .optional() + .describe( + 'Optional hint inserted into the LLM prompt while processing this document.' + ), + compact: z + .boolean() + .optional() + .describe('If true, returns compact parsed data in the response.') + }) + ) + .output( + z.object({ + documentIdentifier: z.string().describe('Identifier of the updated document.'), + fileName: z.string().optional().describe('Updated file name.'), + state: z.string().optional().describe('Current processing state.'), + customIdentifier: z.string().optional().describe('Custom identifier.'), + ready: z.boolean().optional().describe('Whether the document has finished processing.'), + failed: z.boolean().optional().describe('Whether parsing has failed.'), + isConfirmed: z.boolean().optional().describe('Whether the document is confirmed.'), + isRejected: z.boolean().optional().describe('Whether the document is rejected.'), + isArchived: z.boolean().optional().describe('Whether the document is archived.'), + workspaceIdentifier: z.string().optional().describe('Workspace identifier.'), + collectionIdentifier: z.string().optional().describe('Collection identifier.'), + documentTypeIdentifier: z.string().optional().describe('Document type identifier.'), + extractedData: z.any().optional().describe('Updated parsed data, when returned.') + }) + ) + .handleInvocation(async ctx => { + let data: Record = {}; + + if (ctx.input.workspaceIdentifier) data.workspace = ctx.input.workspaceIdentifier; + if (ctx.input.collectionIdentifier) data.collection = ctx.input.collectionIdentifier; + if (ctx.input.documentTypeIdentifier) data.documentType = ctx.input.documentTypeIdentifier; + if (ctx.input.fileName) data.fileName = ctx.input.fileName; + if (ctx.input.expiryTime) data.expiryTime = ctx.input.expiryTime; + if (ctx.input.isConfirmed !== undefined) data.isConfirmed = ctx.input.isConfirmed; + if (ctx.input.isRejected !== undefined) data.isRejected = ctx.input.isRejected; + if (ctx.input.isArchived !== undefined) data.isArchived = ctx.input.isArchived; + if (ctx.input.skipParse !== undefined) data.skipParse = ctx.input.skipParse; + if (ctx.input.language) data.language = ctx.input.language; + if (ctx.input.identifier) data.identifier = ctx.input.identifier; + if (ctx.input.customIdentifier) data.customIdentifier = ctx.input.customIdentifier; + if (ctx.input.llmHint) data.llmHint = ctx.input.llmHint; + + if (Object.keys(data).length === 0) { + throw affindaServiceError('Provide at least one document field to update.'); + } + + let client = new Client({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await client.updateDocument(ctx.input.documentIdentifier, data, { + compact: ctx.input.compact + }); + let meta = result.meta ?? result; + + return { + output: { + documentIdentifier: meta.identifier ?? ctx.input.documentIdentifier, + fileName: meta.fileName, + state: meta.state, + customIdentifier: meta.customIdentifier, + ready: meta.ready, + failed: meta.failed, + isConfirmed: meta.isConfirmed, + isRejected: meta.isRejected, + isArchived: meta.isArchived, + workspaceIdentifier: meta.workspace?.identifier, + collectionIdentifier: meta.collection?.identifier, + documentTypeIdentifier: meta.documentType, + extractedData: result.data + }, + message: `Updated document \`${meta.identifier ?? ctx.input.documentIdentifier}\`.` + }; + }) + .build(); diff --git a/integrations/affinda/src/tools/upload-document.ts b/integrations/affinda/src/tools/upload-document.ts index a9ac45b942..3eef1fa906 100644 --- a/integrations/affinda/src/tools/upload-document.ts +++ b/integrations/affinda/src/tools/upload-document.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { affindaServiceError } from '../lib/errors'; import { spec } from '../spec'; export let uploadDocument = SlateTool.create(spec, { @@ -10,7 +11,7 @@ export let uploadDocument = SlateTool.create(spec, { Set **wait** to \`true\` to receive parsed results immediately, or \`false\` to get the document identifier and poll later.`, instructions: [ - 'Either a URL or a workspace/collection must be provided.', + 'Provide exactly one document source: fileContentBase64 or url.', 'If uploading to a workspace without specifying a collection, the document will be automatically classified and routed.', 'If uploading to a specific collection, the extractor associated with that collection will be applied.' ], @@ -25,6 +26,10 @@ Set **wait** to \`true\` to receive parsed results immediately, or \`false\` to .string() .optional() .describe('Publicly accessible URL of the document to process.'), + fileContentBase64: z + .string() + .optional() + .describe('Base64-encoded document file content to upload directly.'), workspaceIdentifier: z .string() .optional() @@ -55,7 +60,12 @@ Set **wait** to \`true\` to receive parsed results immediately, or \`false\` to fileName: z .string() .optional() - .describe('Override file name for the uploaded document.'), + .describe('File name for file uploads or override file name for URL uploads.'), + fileMimeType: z.string().optional().describe('MIME type for fileContentBase64 uploads.'), + expiryTime: z + .string() + .optional() + .describe('ISO-8601 date-time when Affinda should automatically delete the document.'), language: z .string() .optional() @@ -78,7 +88,21 @@ Set **wait** to \`true\` to receive parsed results immediately, or \`false\` to enableValidationTool: z .boolean() .optional() - .describe('If true, makes the document viewable in the Affinda validation interface.') + .describe('If true, makes the document viewable in the Affinda validation interface.'), + useOcr: z + .boolean() + .optional() + .describe('Force OCR on or off. If omitted, Affinda chooses automatically.'), + llmHint: z + .string() + .optional() + .describe( + 'Optional hint inserted into the LLM prompt while processing this document.' + ), + limitToExamples: z + .array(z.string()) + .optional() + .describe('Restrict LLM example selection to these document identifiers.') }) ) .output( @@ -86,15 +110,39 @@ Set **wait** to \`true\` to receive parsed results immediately, or \`false\` to documentIdentifier: z.string().describe('Unique identifier of the uploaded document.'), fileName: z.string().optional().describe('Name of the processed file.'), state: z.string().optional().describe('Current processing state of the document.'), + ready: z.boolean().optional().describe('Whether the document has finished processing.'), + failed: z.boolean().optional().describe('Whether parsing has failed.'), + customIdentifier: z + .string() + .optional() + .describe('Custom identifier assigned to the document.'), + reviewUrl: z + .string() + .optional() + .describe('Affinda validation/review URL for the document.'), extractedData: z .any() .optional() .describe( 'Structured JSON data extracted from the document (only present when wait is true).' - ) + ), + warnings: z.array(z.any()).optional().describe('Warnings returned by Affinda.') }) ) .handleInvocation(async ctx => { + let hasUrl = typeof ctx.input.url === 'string' && ctx.input.url.length > 0; + let hasFile = + typeof ctx.input.fileContentBase64 === 'string' && + ctx.input.fileContentBase64.length > 0; + + if (hasUrl === hasFile) { + throw affindaServiceError('Provide exactly one of url or fileContentBase64.'); + } + + if (hasFile && !ctx.input.fileName) { + throw affindaServiceError('fileName is required when uploading fileContentBase64.'); + } + let client = new Client({ token: ctx.auth.token, region: ctx.config.region @@ -105,6 +153,13 @@ Set **wait** to \`true\` to receive parsed results immediately, or \`false\` to ctx.info('Uploading document to Affinda...'); let result = await client.uploadDocument({ + file: hasFile + ? { + name: ctx.input.fileName!, + data: ctx.input.fileContentBase64!, + mimeType: ctx.input.fileMimeType + } + : undefined, url: ctx.input.url, workspace, collection: ctx.input.collectionIdentifier, @@ -113,24 +168,34 @@ Set **wait** to \`true\` to receive parsed results immediately, or \`false\` to identifier: ctx.input.identifier, customIdentifier: ctx.input.customIdentifier, fileName: ctx.input.fileName, + expiryTime: ctx.input.expiryTime, language: ctx.input.language, rejectDuplicates: ctx.input.rejectDuplicates, lowPriority: ctx.input.lowPriority, compact: ctx.input.compact, deleteAfterParse: ctx.input.deleteAfterParse, - enableValidationTool: ctx.input.enableValidationTool + enableValidationTool: ctx.input.enableValidationTool, + useOcr: ctx.input.useOcr, + llmHint: ctx.input.llmHint, + limitToExamples: ctx.input.limitToExamples }); + let meta = result.meta ?? result; return { output: { - documentIdentifier: result.meta?.identifier ?? result.identifier ?? '', - fileName: result.meta?.fileName ?? result.fileName, - state: result.meta?.state ?? result.state, - extractedData: result.data + documentIdentifier: meta.identifier ?? '', + fileName: meta.fileName, + state: meta.state, + ready: meta.ready, + failed: meta.failed, + customIdentifier: meta.customIdentifier, + reviewUrl: meta.reviewUrl, + extractedData: result.data, + warnings: result.warnings }, message: ctx.input.wait - ? `Document **${result.meta?.fileName ?? result.fileName ?? 'uploaded'}** has been parsed successfully.` - : `Document uploaded and processing has started. Document identifier: \`${result.meta?.identifier ?? result.identifier}\`` + ? `Document **${meta.fileName ?? 'uploaded'}** has been parsed successfully.` + : `Document uploaded and processing has started. Document identifier: \`${meta.identifier}\`` }; }) .build(); diff --git a/integrations/affinda/src/tools/validation-results.ts b/integrations/affinda/src/tools/validation-results.ts new file mode 100644 index 0000000000..c2382091ed --- /dev/null +++ b/integrations/affinda/src/tools/validation-results.ts @@ -0,0 +1,74 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let validationResultSchema = z.object({ + validationResultId: z.number().describe('Affinda validation result ID.'), + annotationIds: z + .array(z.number()) + .optional() + .describe('Annotation IDs that were validated.'), + passed: z.boolean().nullable().optional().describe('Whether validation passed.'), + ruleSlug: z.string().optional().describe('Validation rule slug.'), + message: z.string().optional().describe('Validation message.'), + documentIdentifier: z.string().optional().describe('Document identifier.') +}); + +let mapValidationResult = (result: any) => ({ + validationResultId: result.id, + annotationIds: result.annotations, + passed: result.passed, + ruleSlug: result.ruleSlug, + message: result.message, + documentIdentifier: result.document +}); + +export let listValidationResults = SlateTool.create(spec, { + name: 'List Validation Results', + key: 'list_validation_results', + description: `List Affinda validation results for a document. Use this to inspect validation rule outcomes after a document has been parsed and reviewed.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + documentIdentifier: z + .string() + .describe('Document identifier to filter validation results by.'), + offset: z.number().optional().describe('Pagination offset.'), + limit: z.number().optional().describe('Maximum number of validation results to return.') + }) + ) + .output( + z.object({ + count: z.number().describe('Number of validation results returned.'), + validationResults: z + .array(validationResultSchema) + .describe('Validation results for the document.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await client.listValidationResults({ + documentIdentifier: ctx.input.documentIdentifier, + offset: ctx.input.offset, + limit: ctx.input.limit + }); + let results = Array.isArray(result) ? result : (result.results ?? []); + let validationResults = results.map(mapValidationResult); + + return { + output: { + count: result.count ?? validationResults.length, + validationResults + }, + message: `Found **${result.count ?? validationResults.length}** validation result(s).` + }; + }) + .build(); diff --git a/integrations/affinda/src/triggers/document-events.ts b/integrations/affinda/src/triggers/document-events.ts index 3598ecd0ec..95b2212fbd 100644 --- a/integrations/affinda/src/triggers/document-events.ts +++ b/integrations/affinda/src/triggers/document-events.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { affindaServiceError } from '../lib/errors'; import { spec } from '../spec'; export let documentEvents = SlateTrigger.create(spec, { @@ -53,7 +54,7 @@ export let documentEvents = SlateTrigger.create(spec, { let orgIdentifier = orgList[0]?.identifier; if (!orgIdentifier) { - throw new Error( + throw affindaServiceError( 'No organization found. An organization is required to register webhooks.' ); } diff --git a/integrations/affinda/vitest.config.ts b/integrations/affinda/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/affinda/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/amplitude/README.md b/integrations/amplitude/README.md index 4845e4cfaa..313c8abe9d 100644 --- a/integrations/amplitude/README.md +++ b/integrations/amplitude/README.md @@ -1,6 +1,6 @@ # Amplitude -Track user behavior, analyze engagement, and run experiments on a product analytics platform. Ingest event data via HTTP or batch APIs, query dashboard analytics including segmentation, funnels, retention, and session metrics. Export raw event data, look up user profiles, and manage behavioral cohorts. Create and manage feature flags and experiments, evaluate flag variants for users. Manage taxonomy (event types, event properties, user properties), upload lookup tables to enrich event data, and handle chart annotations. Modify user and group properties via Identify APIs, merge user identities, and manage user privacy including GDPR/CCPA deletion and data subject access requests. Provision users and permission groups via SCIM. Stream events, user updates, and cohort membership changes to external webhooks, and receive alerts when KPIs change. +Track user behavior, query product analytics, export raw events, and maintain core Amplitude project data. Ingest events through HTTP V2 or Batch APIs, update user and group properties, map identities, query Dashboard REST analytics, retrieve saved chart CSV results, manage behavioral cohorts, maintain taxonomy metadata, handle chart annotations, look up US-region user profiles, and submit or inspect user privacy deletion jobs. Enterprise/admin-only APIs such as Experiment, SCIM, lookup tables, releases, DSAR exports, and outbound streaming are not exposed by this integration. ## Tools @@ -8,9 +8,13 @@ Track user behavior, analyze engagement, and run experiments on a product analyt Request deletion of user data from Amplitude for privacy compliance (GDPR/CCPA). Supports deleting a single user or multiple users in bulk. You can also check the status of pending deletion jobs. +### Export Events + +Export raw Amplitude event files for an uploaded-time range as a ZIP attachment. Structured output contains only metadata such as MIME type, byte length, and attachment count. + ### Get Chart Results -Fetch results from a saved chart in Amplitude by its chart ID. Returns the same data that the chart displays in the Amplitude dashboard. The chart ID can be found in the URL when viewing a chart. +Fetch CSV results from a saved chart in Amplitude by its chart ID. The chart ID can be found in the URL when viewing a chart, and CSV content is returned as a Slate attachment. ### Get User Profile @@ -26,7 +30,7 @@ Manage chart annotations in Amplitude. Annotations mark important events on time ### Manage Cohorts -List, retrieve, create, or update behavioral cohorts in Amplitude. Cohorts are groups of users defined by shared behavior or characteristics. Use this to list all cohorts, get details of a specific cohort, or upload/update a cohort with specific user IDs. +List cohorts, retrieve a cohort from the discoverable cohort list, check Behavioral Cohorts Download API usage, upload static cohorts, and incrementally add or remove cohort membership. ### Manage Taxonomy diff --git a/integrations/amplitude/package.json b/integrations/amplitude/package.json index ec83372fbf..a3ccc3c6ca 100644 --- a/integrations/amplitude/package.json +++ b/integrations/amplitude/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/amplitude/src/index.ts b/integrations/amplitude/src/index.ts index c190ee33b5..a7fbd366ea 100644 --- a/integrations/amplitude/src/index.ts +++ b/integrations/amplitude/src/index.ts @@ -2,6 +2,7 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { deleteUserDataTool, + exportEventsTool, getChartResultsTool, getUserProfileTool, identifyUserTool, @@ -29,6 +30,7 @@ export let provider = Slate.create({ queryRetentionTool, querySessionsTool, queryUserCompositionTool, + exportEventsTool, getUserProfileTool, getChartResultsTool, manageCohortsTool, diff --git a/integrations/amplitude/src/lib/client.ts b/integrations/amplitude/src/lib/client.ts index 9f785f9175..be0887ebd8 100644 --- a/integrations/amplitude/src/lib/client.ts +++ b/integrations/amplitude/src/lib/client.ts @@ -1,4 +1,6 @@ +import { Buffer } from 'node:buffer'; import { createAxios } from 'slates'; +import { amplitudeApiError, amplitudeServiceError } from './errors'; export type AmplitudeRegion = 'US' | 'EU'; @@ -16,6 +18,10 @@ let getIngestionBaseUrl = (region: AmplitudeRegion) => { return region === 'EU' ? 'https://api.eu.amplitude.com' : 'https://api2.amplitude.com'; }; +let getUserMappingBaseUrl = (region: AmplitudeRegion) => { + return region === 'EU' ? 'https://api.eu.amplitude.com' : 'https://api.amplitude.com'; +}; + let getProfileBaseUrl = (region: AmplitudeRegion) => { return region === 'EU' ? 'https://profile-api.eu.amplitude.com' @@ -73,12 +79,67 @@ export class AmplitudeClient { this.config = config; } + private withErrorHandling(ax: ReturnType) { + ax.interceptors.response.use( + response => response, + error => Promise.reject(amplitudeApiError(error)) + ); + return ax; + } + + private getIngestionAxios() { + return this.withErrorHandling( + createAxios({ + baseURL: getIngestionBaseUrl(this.config.region) + }) + ); + } + + private getUserMappingAxios() { + return this.withErrorHandling( + createAxios({ + baseURL: getUserMappingBaseUrl(this.config.region) + }) + ); + } + + private getAnalyticsAxios() { + return this.withErrorHandling( + createAxios({ + baseURL: getApiBaseUrl(this.config.region), + headers: { + Authorization: `Basic ${this.config.token}` + } + }) + ); + } + + private getProfileAxios() { + return this.withErrorHandling( + createAxios({ + baseURL: getProfileBaseUrl(this.config.region), + headers: { + Authorization: `Api-Key ${this.config.secretKey}` + } + }) + ); + } + + private getExportAxios() { + return this.withErrorHandling( + createAxios({ + baseURL: getBaseUrl(this.config.region), + headers: { + Authorization: `Basic ${this.config.token}` + } + }) + ); + } + // --- Event Ingestion --- async trackEvents(events: AmplitudeEvent[], options?: { minIdLength?: number }) { - let ax = createAxios({ - baseURL: getIngestionBaseUrl(this.config.region) - }); + let ax = this.getIngestionAxios(); let body: Record = { api_key: this.config.apiKey, @@ -94,9 +155,7 @@ export class AmplitudeClient { } async batchTrackEvents(events: AmplitudeEvent[], options?: { minIdLength?: number }) { - let ax = createAxios({ - baseURL: getIngestionBaseUrl(this.config.region) - }); + let ax = this.getIngestionAxios(); let body: Record = { api_key: this.config.apiKey, @@ -158,9 +217,7 @@ export class AmplitudeClient { deviceId?: string; userProperties: Record; }) { - let ax = createAxios({ - baseURL: getIngestionBaseUrl(this.config.region) - }); + let ax = this.getIngestionAxios(); let identifyPayload = { user_id: identification.userId, @@ -182,9 +239,7 @@ export class AmplitudeClient { groupValue: string, groupProperties: Record ) { - let ax = createAxios({ - baseURL: getIngestionBaseUrl(this.config.region) - }); + let ax = this.getIngestionAxios(); let identifyPayload = { group_type: groupType, @@ -204,9 +259,7 @@ export class AmplitudeClient { // --- User Mapping (Aliasing) --- async mapUserIdentities(mapping: { userId: string; globalUserId: string }) { - let ax = createAxios({ - baseURL: getIngestionBaseUrl(this.config.region) - }); + let ax = this.getUserMappingAxios(); let response = await ax.post('/usermap', null, { params: { @@ -222,15 +275,6 @@ export class AmplitudeClient { // --- Dashboard REST API --- - private getAnalyticsAxios() { - return createAxios({ - baseURL: getApiBaseUrl(this.config.region), - headers: { - Authorization: `Basic ${this.config.token}` - } - }); - } - async getActiveAndNewUserCounts(params: { start: string; end: string; @@ -307,19 +351,28 @@ export class AmplitudeClient { async getChartResults(chartId: string) { let ax = this.getAnalyticsAxios(); - let response = await ax.get(`/3/chart/${chartId}/query`); - return response.data; + let response = await ax.get(`/3/chart/${chartId}/csv`, { + responseType: 'text' + }); + let content = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data); + let contentTypeHeader = response.headers?.['content-type']; + let contentType = + typeof contentTypeHeader === 'string' + ? contentTypeHeader.split(';')[0]?.trim() + : undefined; + + return { + content, + contentType: contentType || 'text/csv', + byteLength: Buffer.byteLength(content) + }; } // --- User Profile API --- async getUserProfile(params: { userId?: string; amplitudeId?: number }) { - let ax = createAxios({ - baseURL: getProfileBaseUrl(this.config.region), - headers: { - Authorization: `Api-Key ${this.config.secretKey}` - } - }); + let ax = this.getProfileAxios(); let queryParams: Record = {}; if (params.userId) queryParams.user_id = params.userId; @@ -338,9 +391,18 @@ export class AmplitudeClient { } async getCohort(cohortId: string) { - let ax = this.getAnalyticsAxios(); - let response = await ax.get(`/3/cohorts/${cohortId}`); - return response.data; + let result = await this.listCohorts(); + let cohorts = Array.isArray(result) ? result : (result.cohorts ?? []); + let cohort = cohorts.find((item: any) => { + let id = item.id ?? item.cohort_id; + return id !== undefined && String(id) === cohortId; + }); + + if (!cohort) { + throw amplitudeServiceError(`Amplitude cohort "${cohortId}" was not found.`); + } + + return cohort; } async downloadCohort(cohortId: string, props?: boolean) { @@ -353,7 +415,7 @@ export class AmplitudeClient { async getCohortDownloadStatus(requestId: string) { let ax = this.getAnalyticsAxios(); - let response = await ax.get(`/5/cohorts/request/status/${requestId}`); + let response = await ax.get(`/5/cohorts/request-status/${requestId}`); return response.data; } @@ -363,6 +425,10 @@ export class AmplitudeClient { idType: 'BY_AMP_ID' | 'BY_USER_ID'; ids: string[]; owner?: string; + published?: boolean; + skipSave?: boolean; + skipInvalidIds?: boolean; + countGroup?: string; existingCohortId?: string; }) { let ax = this.getAnalyticsAxios(); @@ -373,12 +439,48 @@ export class AmplitudeClient { ids: params.ids }; if (params.owner) body.owner = params.owner; + if (params.published !== undefined) body.published = params.published; + if (params.skipSave !== undefined) body.skip_save = params.skipSave; + if (params.skipInvalidIds !== undefined) body.skip_invalid_ids = params.skipInvalidIds; + if (params.countGroup) body.cg = params.countGroup; if (params.existingCohortId) body.existing_cohort_id = params.existingCohortId; let response = await ax.post('/3/cohorts/upload', body); return response.data; } + async getCohortUsage() { + let ax = this.getAnalyticsAxios(); + let response = await ax.get('/3/cohorts/usage'); + return response.data; + } + + async updateCohortMembership(params: { + cohortId: string; + memberships: Array<{ + ids: string[]; + idType: 'BY_ID' | 'BY_NAME'; + operation: 'ADD' | 'REMOVE'; + }>; + countGroup?: string; + skipInvalidIds?: boolean; + }) { + let ax = this.getAnalyticsAxios(); + let body: Record = { + cohort_id: params.cohortId, + memberships: params.memberships.map(membership => ({ + ids: membership.ids, + id_type: membership.idType, + operation: membership.operation + })) + }; + if (params.countGroup) body.count_group = params.countGroup; + if (params.skipInvalidIds !== undefined) body.skip_invalid_ids = params.skipInvalidIds; + + let response = await ax.post('/3/cohorts/membership', body); + return response.data; + } + // --- Taxonomy API --- async getEventTypes() { @@ -599,25 +701,35 @@ export class AmplitudeClient { async listAnnotations() { let ax = this.getAnalyticsAxios(); - let response = await ax.get('/2/annotations'); + let response = await ax.get('/3/annotations'); return response.data; } async getAnnotation(annotationId: string) { let ax = this.getAnalyticsAxios(); - let response = await ax.get(`/2/annotations/${annotationId}`); + let response = await ax.get(`/3/annotations/${annotationId}`); return response.data; } - async createAnnotation(params: { label: string; date: string; details?: string }) { + async createAnnotation(params: { + label: string; + start: string; + details?: string; + end?: string; + category?: string; + chartId?: string; + }) { let ax = this.getAnalyticsAxios(); let body: Record = { label: params.label, - date: params.date + start: params.start }; if (params.details) body.details = params.details; + if (params.end) body.end = params.end; + if (params.category) body.category = params.category; + if (params.chartId) body.chart_id = params.chartId; - let response = await ax.post('/2/annotations', body); + let response = await ax.post('/3/annotations', body); return response.data; } @@ -625,39 +737,53 @@ export class AmplitudeClient { annotationId: string, params: { label?: string; - date?: string; + start?: string; details?: string; + end?: string | null; + category?: string; + chartId?: string | null; } ) { let ax = this.getAnalyticsAxios(); let body: Record = {}; if (params.label) body.label = params.label; - if (params.date) body.date = params.date; - if (params.details) body.details = params.details; + if (params.start) body.start = params.start; + if (params.details !== undefined) body.details = params.details; + if (params.end !== undefined) body.end = params.end; + if (params.category) body.category = params.category; + if (params.chartId !== undefined) body.chart_id = params.chartId; - let response = await ax.put(`/2/annotations/${annotationId}`, body); + let response = await ax.put(`/3/annotations/${annotationId}`, body); return response.data; } async deleteAnnotation(annotationId: string) { let ax = this.getAnalyticsAxios(); - let response = await ax.delete(`/2/annotations/${annotationId}`); + let response = await ax.delete(`/3/annotations/${annotationId}`); return response.data; } // --- Export API --- async exportEvents(params: { start: string; end: string }) { - let ax = createAxios({ - baseURL: getBaseUrl(this.config.region), - headers: { - Authorization: `Basic ${this.config.token}` - } - }); + let ax = this.getExportAxios(); let response = await ax.get('/api/2/export', { - params: { start: params.start, end: params.end } + params: { start: params.start, end: params.end }, + responseType: 'arraybuffer' }); - return response.data; + + let buffer = Buffer.isBuffer(response.data) ? response.data : Buffer.from(response.data); + let contentTypeHeader = response.headers?.['content-type']; + let contentType = + typeof contentTypeHeader === 'string' + ? contentTypeHeader.split(';')[0]?.trim() + : undefined; + + return { + contentBase64: buffer.toString('base64'), + contentType: contentType || 'application/zip', + byteLength: buffer.byteLength + }; } // --- User Privacy / Deletion --- diff --git a/integrations/amplitude/src/lib/errors.ts b/integrations/amplitude/src/lib/errors.ts new file mode 100644 index 0000000000..b24e2fc34e --- /dev/null +++ b/integrations/amplitude/src/lib/errors.ts @@ -0,0 +1,77 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + let text = typeof value === 'string' ? value.trim() : ''; + if (text && !details.includes(text)) { + details.push(text); + } +}; + +let getErrorResponse = (error: unknown) => + isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + +let extractAmplitudeMessage = (error: unknown) => { + let data = getErrorResponse(error)?.data; + let details: string[] = []; + + if (isRecord(data)) { + addDetail(details, data.error); + addDetail(details, data.message); + addDetail(details, data.type); + addDetail(details, data.status); + + let metadata = data.metadata; + if (isRecord(metadata)) { + addDetail(details, metadata.message); + addDetail(details, metadata.reason); + } + } else if (typeof data === 'string') { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let amplitudeServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let amplitudeApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = getErrorResponse(error); + let statusLabel = + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + let serviceError = amplitudeServiceError( + `Amplitude API ${operation} failed: ${statusLabel}${extractAmplitudeMessage(error)}` + ); + + serviceError.data.reason = 'amplitude_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/amplitude/src/tools.schema.test.ts b/integrations/amplitude/src/tools.schema.test.ts new file mode 100644 index 0000000000..51c92493f8 --- /dev/null +++ b/integrations/amplitude/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Amplitude tool input schemas', provider.actions); diff --git a/integrations/amplitude/src/tools/delete-user-data.ts b/integrations/amplitude/src/tools/delete-user-data.ts index 7ad5a335a8..00dd21b24d 100644 --- a/integrations/amplitude/src/tools/delete-user-data.ts +++ b/integrations/amplitude/src/tools/delete-user-data.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AmplitudeClient } from '../lib/client'; +import { amplitudeServiceError } from '../lib/errors'; import { spec } from '../spec'; export let deleteUserDataTool = SlateTool.create(spec, { @@ -78,6 +79,14 @@ export let deleteUserDataTool = SlateTool.create(spec, { }); if (ctx.input.action === 'delete') { + if (!ctx.input.userId && ctx.input.amplitudeId === undefined) { + throw amplitudeServiceError('Provide userId or amplitudeId for "delete" action.'); + } + + if (ctx.input.userId && ctx.input.amplitudeId !== undefined) { + throw amplitudeServiceError('Provide only one of userId or amplitudeId.'); + } + let result = await client.requestUserDeletion({ userId: ctx.input.userId, amplitudeId: ctx.input.amplitudeId, @@ -90,7 +99,22 @@ export let deleteUserDataTool = SlateTool.create(spec, { } if (ctx.input.action === 'bulk_delete') { - if (!ctx.input.bulkDelete) throw new Error('bulkDelete parameters are required.'); + if (!ctx.input.bulkDelete) { + throw amplitudeServiceError('bulkDelete parameters are required.'); + } + + let count = + (ctx.input.bulkDelete.userIds?.length ?? 0) + + (ctx.input.bulkDelete.amplitudeIds?.length ?? 0); + if (count === 0) { + throw amplitudeServiceError( + 'bulkDelete must include at least one userId or amplitudeId.' + ); + } + if (count > 100) { + throw amplitudeServiceError('Bulk deletion accepts at most 100 IDs.'); + } + let result = await client.requestBulkUserDeletion({ userIds: ctx.input.bulkDelete.userIds, amplitudeIds: ctx.input.bulkDelete.amplitudeIds, @@ -98,9 +122,6 @@ export let deleteUserDataTool = SlateTool.create(spec, { deleteFromOrg: ctx.input.bulkDelete.deleteFromOrg, ignoreInvalidId: ctx.input.bulkDelete.ignoreInvalidId }); - let count = - (ctx.input.bulkDelete.userIds?.length ?? 0) + - (ctx.input.bulkDelete.amplitudeIds?.length ?? 0); return { output: { deletionResult: result }, message: `Bulk deletion requested for **${count}** user(s).` @@ -115,6 +136,6 @@ export let deleteUserDataTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw amplitudeServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/amplitude/src/tools/export-events.ts b/integrations/amplitude/src/tools/export-events.ts new file mode 100644 index 0000000000..4cf84ff059 --- /dev/null +++ b/integrations/amplitude/src/tools/export-events.ts @@ -0,0 +1,63 @@ +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { AmplitudeClient } from '../lib/client'; +import { spec } from '../spec'; + +export let exportEventsTool = SlateTool.create(spec, { + name: 'Export Events', + key: 'export_events', + description: `Export Amplitude raw event files for an uploaded-time range as a ZIP attachment. Use this for downstream archival, warehouse backfills, or offline inspection of raw JSON event exports.`, + constraints: [ + 'The Export API returns data by server upload time, not event time.', + 'Data may take up to two hours to become available.', + 'Use whole-day ranges from T00 to T23 when exporting a full day.', + 'Each export may be up to 4 GB and date ranges may not exceed 365 days.' + ], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + start: z + .string() + .regex(/^\d{8}T\d{2}$/) + .describe('Start hour in YYYYMMDDTHH format, for example 20260131T00.'), + end: z + .string() + .regex(/^\d{8}T\d{2}$/) + .describe('End hour in YYYYMMDDTHH format, for example 20260131T23.') + }) + ) + .output( + z.object({ + contentType: z.string().describe('MIME type of the exported attachment.'), + byteLength: z.number().describe('Size of the exported ZIP attachment in bytes.'), + attachmentCount: z.number().describe('Number of attachments returned.') + }) + ) + .handleInvocation(async ctx => { + let client = new AmplitudeClient({ + apiKey: ctx.auth.apiKey, + secretKey: ctx.auth.secretKey, + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await client.exportEvents({ + start: ctx.input.start, + end: ctx.input.end + }); + + return { + output: { + contentType: result.contentType, + byteLength: result.byteLength, + attachmentCount: 1 + }, + attachments: [createBase64Attachment(result.contentBase64, result.contentType)], + message: `Exported Amplitude events from ${ctx.input.start} to ${ctx.input.end} as a ZIP attachment.` + }; + }) + .build(); diff --git a/integrations/amplitude/src/tools/get-chart-results.ts b/integrations/amplitude/src/tools/get-chart-results.ts index d8629edf6f..7a79580ef1 100644 --- a/integrations/amplitude/src/tools/get-chart-results.ts +++ b/integrations/amplitude/src/tools/get-chart-results.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; import { AmplitudeClient } from '../lib/client'; import { spec } from '../spec'; @@ -6,7 +6,7 @@ import { spec } from '../spec'; export let getChartResultsTool = SlateTool.create(spec, { name: 'Get Chart Results', key: 'get_chart_results', - description: `Fetch results from a saved chart in Amplitude by its chart ID. Returns the same data that the chart displays in the Amplitude dashboard. The chart ID can be found in the URL when viewing a chart.`, + description: `Fetch CSV results from a saved chart in Amplitude by its chart ID. The chart ID can be found in the URL when viewing a chart. The CSV content is returned as a Slate attachment.`, tags: { destructive: false, readOnly: true @@ -21,7 +21,9 @@ export let getChartResultsTool = SlateTool.create(spec, { ) .output( z.object({ - chartData: z.any().describe('Full chart data including series, labels, and metadata.') + contentType: z.string().describe('MIME type of the exported chart attachment.'), + byteLength: z.number().describe('Size of the exported chart CSV in bytes.'), + attachmentCount: z.number().describe('Number of attachments returned.') }) ) .handleInvocation(async ctx => { @@ -35,8 +37,13 @@ export let getChartResultsTool = SlateTool.create(spec, { let result = await client.getChartResults(ctx.input.chartId); return { - output: { chartData: result.data ?? result }, - message: `Retrieved results for chart **${ctx.input.chartId}**.` + output: { + contentType: result.contentType, + byteLength: result.byteLength, + attachmentCount: 1 + }, + attachments: [createTextAttachment(result.content, result.contentType)], + message: `Retrieved CSV results for chart **${ctx.input.chartId}** as an attachment.` }; }) .build(); diff --git a/integrations/amplitude/src/tools/get-user-profile.ts b/integrations/amplitude/src/tools/get-user-profile.ts index 79aaa63902..36f1fac35d 100644 --- a/integrations/amplitude/src/tools/get-user-profile.ts +++ b/integrations/amplitude/src/tools/get-user-profile.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AmplitudeClient } from '../lib/client'; +import { amplitudeServiceError } from '../lib/errors'; import { spec } from '../spec'; export let getUserProfileTool = SlateTool.create(spec, { @@ -49,6 +50,20 @@ export let getUserProfileTool = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.config.region === 'EU') { + throw amplitudeServiceError( + 'Amplitude User Profile API is not available for EU data region projects.' + ); + } + + if (!ctx.input.userId && ctx.input.amplitudeId === undefined) { + throw amplitudeServiceError('Provide either userId or amplitudeId.'); + } + + if (ctx.input.userId && ctx.input.amplitudeId !== undefined) { + throw amplitudeServiceError('Provide only one of userId or amplitudeId.'); + } + let client = new AmplitudeClient({ apiKey: ctx.auth.apiKey, secretKey: ctx.auth.secretKey, diff --git a/integrations/amplitude/src/tools/identify-user.ts b/integrations/amplitude/src/tools/identify-user.ts index 59529d1e9c..0c21291a68 100644 --- a/integrations/amplitude/src/tools/identify-user.ts +++ b/integrations/amplitude/src/tools/identify-user.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AmplitudeClient } from '../lib/client'; +import { amplitudeServiceError } from '../lib/errors'; import { spec } from '../spec'; export let identifyUserTool = SlateTool.create(spec, { @@ -62,6 +63,28 @@ export let identifyUserTool = SlateTool.create(spec, { let actions: string[] = []; + if (ctx.input.userProperties && !ctx.input.userId && !ctx.input.deviceId) { + throw amplitudeServiceError( + 'userId or deviceId is required when setting userProperties.' + ); + } + + if ( + ctx.input.groupType !== undefined || + ctx.input.groupValue !== undefined || + ctx.input.groupProperties !== undefined + ) { + if (!ctx.input.groupType || !ctx.input.groupValue || !ctx.input.groupProperties) { + throw amplitudeServiceError( + 'groupType, groupValue, and groupProperties are all required for group identification.' + ); + } + } + + if (ctx.input.mapToGlobalUserId && !ctx.input.userId) { + throw amplitudeServiceError('userId is required when mapToGlobalUserId is provided.'); + } + if (ctx.input.userProperties && (ctx.input.userId || ctx.input.deviceId)) { await client.identify({ userId: ctx.input.userId, @@ -90,6 +113,12 @@ export let identifyUserTool = SlateTool.create(spec, { actions.push(`user mapped to global ID "${ctx.input.mapToGlobalUserId}"`); } + if (actions.length === 0) { + throw amplitudeServiceError( + 'Provide userProperties, group identification fields, or mapToGlobalUserId.' + ); + } + return { output: { success: true }, message: `Identification complete: ${actions.join(', ')}.` diff --git a/integrations/amplitude/src/tools/index.ts b/integrations/amplitude/src/tools/index.ts index 657dd10050..05faede21a 100644 --- a/integrations/amplitude/src/tools/index.ts +++ b/integrations/amplitude/src/tools/index.ts @@ -1,4 +1,5 @@ export * from './delete-user-data'; +export * from './export-events'; export * from './get-chart-results'; export * from './get-user-profile'; export * from './identify-user'; diff --git a/integrations/amplitude/src/tools/manage-annotations.ts b/integrations/amplitude/src/tools/manage-annotations.ts index 60f23144d3..105fbcc318 100644 --- a/integrations/amplitude/src/tools/manage-annotations.ts +++ b/integrations/amplitude/src/tools/manage-annotations.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AmplitudeClient } from '../lib/client'; +import { amplitudeServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageAnnotationsTool = SlateTool.create(spec, { @@ -29,8 +30,20 @@ export let manageAnnotationsTool = SlateTool.create(spec, { .string() .optional() .describe( - 'Date for the annotation in MM-DD-YYYY format. Required for "create", optional for "update".' + 'Deprecated legacy date field. Prefer start. Used as start when start is omitted.' ), + start: z + .string() + .optional() + .describe( + 'Annotation start time in ISO 8601 format. Required for "create", optional for "update".' + ), + end: z.string().optional().describe('Optional annotation end time in ISO 8601 format.'), + category: z.string().optional().describe('Optional annotation category.'), + chartId: z + .string() + .optional() + .describe('Optional Amplitude chart ID to associate with the annotation.'), details: z .string() .optional() @@ -44,7 +57,10 @@ export let manageAnnotationsTool = SlateTool.create(spec, { z.object({ annotationId: z.string().optional(), label: z.string().optional(), - date: z.string().optional(), + start: z.string().optional(), + end: z.string().optional(), + category: z.string().optional(), + chartId: z.string().optional(), details: z.string().optional() }) ) @@ -68,14 +84,26 @@ export let manageAnnotationsTool = SlateTool.create(spec, { region: ctx.config.region }); + let normalizeAnnotation = (annotation: any) => ({ + annotationId: annotation.id !== undefined ? String(annotation.id) : undefined, + label: annotation.label, + start: annotation.start ?? annotation.date, + end: annotation.end, + category: annotation.category, + chartId: annotation.chart_id !== undefined ? String(annotation.chart_id) : undefined, + details: annotation.details + }); + if (ctx.input.action === 'list') { let result = await client.listAnnotations(); - let annotations = (result.data ?? result ?? []).map((a: any) => ({ - annotationId: String(a.id), - label: a.label, - date: a.date, - details: a.details - })); + let rawAnnotations = Array.isArray(result.data) + ? result.data + : Array.isArray(result.annotations) + ? result.annotations + : Array.isArray(result) + ? result + : []; + let annotations = rawAnnotations.map(normalizeAnnotation); return { output: { annotations }, message: `Found **${annotations.length}** annotation(s).` @@ -83,8 +111,9 @@ export let manageAnnotationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.annotationId) - throw new Error('annotationId is required for "get" action.'); + if (!ctx.input.annotationId) { + throw amplitudeServiceError('annotationId is required for "get" action.'); + } let result = await client.getAnnotation(ctx.input.annotationId); return { output: { annotation: result.data ?? result }, @@ -93,26 +122,35 @@ export let manageAnnotationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.label || !ctx.input.date) - throw new Error('label and date are required for "create" action.'); + let start = ctx.input.start ?? ctx.input.date; + if (!ctx.input.label || !start) { + throw amplitudeServiceError('label and start are required for "create" action.'); + } let result = await client.createAnnotation({ label: ctx.input.label, - date: ctx.input.date, - details: ctx.input.details + start, + details: ctx.input.details, + end: ctx.input.end, + category: ctx.input.category, + chartId: ctx.input.chartId }); return { output: { annotation: result.data ?? result }, - message: `Created annotation "${ctx.input.label}" on ${ctx.input.date}.` + message: `Created annotation "${ctx.input.label}" starting ${start}.` }; } if (ctx.input.action === 'update') { - if (!ctx.input.annotationId) - throw new Error('annotationId is required for "update" action.'); + if (!ctx.input.annotationId) { + throw amplitudeServiceError('annotationId is required for "update" action.'); + } let result = await client.updateAnnotation(ctx.input.annotationId, { label: ctx.input.label, - date: ctx.input.date, - details: ctx.input.details + start: ctx.input.start ?? ctx.input.date, + details: ctx.input.details, + end: ctx.input.end, + category: ctx.input.category, + chartId: ctx.input.chartId }); return { output: { annotation: result.data ?? result }, @@ -121,8 +159,9 @@ export let manageAnnotationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.annotationId) - throw new Error('annotationId is required for "delete" action.'); + if (!ctx.input.annotationId) { + throw amplitudeServiceError('annotationId is required for "delete" action.'); + } await client.deleteAnnotation(ctx.input.annotationId); return { output: { success: true }, @@ -130,6 +169,6 @@ export let manageAnnotationsTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw amplitudeServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/amplitude/src/tools/manage-cohorts.ts b/integrations/amplitude/src/tools/manage-cohorts.ts index 3712a61fde..eb73f65023 100644 --- a/integrations/amplitude/src/tools/manage-cohorts.ts +++ b/integrations/amplitude/src/tools/manage-cohorts.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AmplitudeClient } from '../lib/client'; +import { amplitudeServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCohortsTool = SlateTool.create(spec, { @@ -19,25 +20,69 @@ export let manageCohortsTool = SlateTool.create(spec, { .input( z.object({ action: z - .enum(['list', 'get', 'upload']) + .enum(['list', 'get', 'upload', 'get_usage', 'update_membership']) .describe( - 'Action to perform: "list" all cohorts, "get" a specific cohort, or "upload" a new/updated cohort.' + 'Action to perform: "list" cohorts, "get" one cohort, "get_usage" for download quota, "upload" a static cohort, or "update_membership" incrementally.' ), - cohortId: z.string().optional().describe('Cohort ID for "get" action.'), + cohortId: z + .string() + .optional() + .describe('Cohort ID for "get" and "update_membership" actions.'), upload: z .object({ name: z.string().describe('Name for the cohort.'), appId: z.number().describe('Amplitude project/app ID.'), idType: z.enum(['BY_AMP_ID', 'BY_USER_ID']).describe('Type of IDs being uploaded.'), - ids: z.array(z.string()).describe('Array of user IDs or Amplitude IDs.'), - owner: z.string().optional().describe('Email of the cohort owner.'), + ids: z.array(z.string()).min(1).describe('Array of user IDs or Amplitude IDs.'), + owner: z.string().optional().describe('Required email of the cohort owner.'), + published: z + .boolean() + .optional() + .describe('Required by Amplitude. Whether the cohort is discoverable.'), + skipSave: z + .boolean() + .optional() + .describe('Validate the upload without saving a cohort.'), + skipInvalidIds: z + .boolean() + .optional() + .describe('Skip invalid IDs and upload the remaining valid IDs.'), + countGroup: z + .string() + .optional() + .describe('Optional group name for group cohorts, sent as cg.'), existingCohortId: z .string() .optional() .describe('ID of existing cohort to update instead of creating new.') }) .optional() - .describe('Upload parameters. Required for "upload" action.') + .describe('Upload parameters. Required for "upload" action.'), + membership: z + .object({ + memberships: z + .array( + z.object({ + ids: z.array(z.string()).min(1).describe('IDs to add or remove.'), + idType: z + .enum(['BY_ID', 'BY_NAME']) + .describe('BY_ID is Amplitude ID/group ID; BY_NAME is user ID/group name.'), + operation: z.enum(['ADD', 'REMOVE']).describe('Membership operation.') + }) + ) + .min(1) + .describe('Membership operations to apply.'), + countGroup: z + .string() + .optional() + .describe('Count group for the given IDs. Defaults to User in Amplitude.'), + skipInvalidIds: z + .boolean() + .optional() + .describe('Skip invalid IDs instead of rejecting the full update.') + }) + .optional() + .describe('Membership update parameters. Required for "update_membership" action.') }) ) .output( @@ -56,7 +101,12 @@ export let manageCohortsTool = SlateTool.create(spec, { .optional() .describe('List of cohorts (for "list" action).'), cohort: z.any().optional().describe('Cohort details (for "get" action).'), - uploadResult: z.any().optional().describe('Upload result (for "upload" action).') + usage: z.any().optional().describe('Cohort download quota usage.'), + uploadResult: z.any().optional().describe('Upload result (for "upload" action).'), + membershipResult: z + .any() + .optional() + .describe('Membership update result (for "update_membership" action).') }) ) .handleInvocation(async ctx => { @@ -85,7 +135,9 @@ export let manageCohortsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.cohortId) throw new Error('cohortId is required for "get" action.'); + if (!ctx.input.cohortId) { + throw amplitudeServiceError('cohortId is required for "get" action.'); + } let result = await client.getCohort(ctx.input.cohortId); return { output: { cohort: result }, @@ -93,16 +145,52 @@ export let manageCohortsTool = SlateTool.create(spec, { }; } + if (ctx.input.action === 'get_usage') { + let result = await client.getCohortUsage(); + return { + output: { usage: result }, + message: 'Retrieved Behavioral Cohorts Download API usage.' + }; + } + if (ctx.input.action === 'upload') { - if (!ctx.input.upload) - throw new Error('upload parameters are required for "upload" action.'); + if (!ctx.input.upload) { + throw amplitudeServiceError('upload parameters are required for "upload" action.'); + } + if (!ctx.input.upload.owner) { + throw amplitudeServiceError('upload.owner is required by Amplitude.'); + } + if (ctx.input.upload.published === undefined) { + throw amplitudeServiceError('upload.published is required by Amplitude.'); + } let result = await client.uploadCohort(ctx.input.upload); return { output: { uploadResult: result }, - message: `${ctx.input.upload.existingCohortId ? 'Updated' : 'Created'} cohort "${ctx.input.upload.name}" with **${ctx.input.upload.ids.length}** users.` + message: `${ctx.input.upload.skipSave ? 'Validated' : ctx.input.upload.existingCohortId ? 'Updated' : 'Created'} cohort "${ctx.input.upload.name}" with **${ctx.input.upload.ids.length}** users.` + }; + } + + if (ctx.input.action === 'update_membership') { + if (!ctx.input.cohortId) { + throw amplitudeServiceError('cohortId is required for "update_membership" action.'); + } + if (!ctx.input.membership) { + throw amplitudeServiceError( + 'membership parameters are required for "update_membership" action.' + ); + } + let result = await client.updateCohortMembership({ + cohortId: ctx.input.cohortId, + memberships: ctx.input.membership.memberships, + countGroup: ctx.input.membership.countGroup, + skipInvalidIds: ctx.input.membership.skipInvalidIds + }); + return { + output: { membershipResult: result }, + message: `Updated membership for cohort **${ctx.input.cohortId}**.` }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw amplitudeServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/amplitude/src/tools/manage-taxonomy.ts b/integrations/amplitude/src/tools/manage-taxonomy.ts index 9ddf37b7a8..3601400b52 100644 --- a/integrations/amplitude/src/tools/manage-taxonomy.ts +++ b/integrations/amplitude/src/tools/manage-taxonomy.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AmplitudeClient } from '../lib/client'; +import { amplitudeServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageTaxonomyTool = SlateTool.create(spec, { @@ -111,7 +112,7 @@ export let manageTaxonomyTool = SlateTool.create(spec, { }; } if (action === 'get') { - if (!ctx.input.eventType) throw new Error('eventType is required.'); + if (!ctx.input.eventType) throw amplitudeServiceError('eventType is required.'); let result = await client.getEventType(ctx.input.eventType); return { output: { item: result.data ?? result }, @@ -119,7 +120,9 @@ export let manageTaxonomyTool = SlateTool.create(spec, { }; } if (action === 'create') { - if (!ctx.input.create) throw new Error('create parameters are required.'); + if (!ctx.input.create) { + throw amplitudeServiceError('create parameters are required.'); + } let result = await client.createEventType({ eventType: ctx.input.create.name, category: ctx.input.create.category, @@ -132,7 +135,7 @@ export let manageTaxonomyTool = SlateTool.create(spec, { } if (action === 'update') { if (!ctx.input.eventType || !ctx.input.update) - throw new Error('eventType and update parameters are required.'); + throw amplitudeServiceError('eventType and update parameters are required.'); let result = await client.updateEventType(ctx.input.eventType, { newEventType: ctx.input.update.newName, category: ctx.input.update.category, @@ -144,7 +147,7 @@ export let manageTaxonomyTool = SlateTool.create(spec, { }; } if (action === 'delete') { - if (!ctx.input.eventType) throw new Error('eventType is required.'); + if (!ctx.input.eventType) throw amplitudeServiceError('eventType is required.'); let result = await client.deleteEventType(ctx.input.eventType); return { output: { result: result.data ?? result }, @@ -157,7 +160,7 @@ export let manageTaxonomyTool = SlateTool.create(spec, { if (resourceType === 'event_property') { if (action === 'list') { if (!ctx.input.eventType) - throw new Error('eventType is required to list event properties.'); + throw amplitudeServiceError('eventType is required to list event properties.'); let result = await client.getEventProperties(ctx.input.eventType); return { output: { items: result.data ?? result }, @@ -166,7 +169,7 @@ export let manageTaxonomyTool = SlateTool.create(spec, { } if (action === 'create') { if (!ctx.input.create || !ctx.input.eventType) - throw new Error('create parameters and eventType are required.'); + throw amplitudeServiceError('create parameters and eventType are required.'); let result = await client.createEventProperty({ eventType: ctx.input.eventType, eventProperty: ctx.input.create.name, @@ -184,7 +187,9 @@ export let manageTaxonomyTool = SlateTool.create(spec, { } if (action === 'update') { if (!ctx.input.eventProperty || !ctx.input.eventType || !ctx.input.update) - throw new Error('eventProperty, eventType, and update parameters are required.'); + throw amplitudeServiceError( + 'eventProperty, eventType, and update parameters are required.' + ); let result = await client.updateEventProperty( ctx.input.eventProperty, ctx.input.eventType, @@ -205,7 +210,7 @@ export let manageTaxonomyTool = SlateTool.create(spec, { } if (action === 'delete') { if (!ctx.input.eventProperty || !ctx.input.eventType) - throw new Error('eventProperty and eventType are required.'); + throw amplitudeServiceError('eventProperty and eventType are required.'); let result = await client.deleteEventProperty( ctx.input.eventProperty, ctx.input.eventType @@ -227,7 +232,9 @@ export let manageTaxonomyTool = SlateTool.create(spec, { }; } if (action === 'create') { - if (!ctx.input.create) throw new Error('create parameters are required.'); + if (!ctx.input.create) { + throw amplitudeServiceError('create parameters are required.'); + } let result = await client.createUserProperty({ userProperty: ctx.input.create.name, description: ctx.input.create.description, @@ -243,7 +250,7 @@ export let manageTaxonomyTool = SlateTool.create(spec, { } if (action === 'update') { if (!ctx.input.userProperty || !ctx.input.update) - throw new Error('userProperty and update parameters are required.'); + throw amplitudeServiceError('userProperty and update parameters are required.'); let result = await client.updateUserProperty(ctx.input.userProperty, { newUserPropertyValue: ctx.input.update.newName, description: ctx.input.update.description, @@ -258,7 +265,9 @@ export let manageTaxonomyTool = SlateTool.create(spec, { }; } if (action === 'delete') { - if (!ctx.input.userProperty) throw new Error('userProperty is required.'); + if (!ctx.input.userProperty) { + throw amplitudeServiceError('userProperty is required.'); + } let result = await client.deleteUserProperty(ctx.input.userProperty); return { output: { result: result.data ?? result }, @@ -277,7 +286,9 @@ export let manageTaxonomyTool = SlateTool.create(spec, { }; } if (action === 'create') { - if (!ctx.input.create) throw new Error('create parameters are required.'); + if (!ctx.input.create) { + throw amplitudeServiceError('create parameters are required.'); + } let result = await client.createEventCategory({ name: ctx.input.create.name }); return { output: { result: result.data ?? result }, @@ -285,7 +296,7 @@ export let manageTaxonomyTool = SlateTool.create(spec, { }; } if (action === 'delete') { - if (!ctx.input.categoryId) throw new Error('categoryId is required.'); + if (!ctx.input.categoryId) throw amplitudeServiceError('categoryId is required.'); let result = await client.deleteEventCategory(ctx.input.categoryId); return { output: { result: result.data ?? result }, @@ -294,6 +305,8 @@ export let manageTaxonomyTool = SlateTool.create(spec, { } } - throw new Error(`Unsupported action "${action}" for resource type "${resourceType}".`); + throw amplitudeServiceError( + `Unsupported action "${action}" for resource type "${resourceType}".` + ); }) .build(); diff --git a/integrations/amplitude/src/tools/query-funnel.ts b/integrations/amplitude/src/tools/query-funnel.ts index 188ecd4417..00901aa93b 100644 --- a/integrations/amplitude/src/tools/query-funnel.ts +++ b/integrations/amplitude/src/tools/query-funnel.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AmplitudeClient } from '../lib/client'; +import { amplitudeServiceError } from '../lib/errors'; import { spec } from '../spec'; export let queryFunnelTool = SlateTool.create(spec, { @@ -58,6 +59,23 @@ export let queryFunnelTool = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + let parsedEvents: unknown; + try { + parsedEvents = JSON.parse(ctx.input.events); + } catch (error) { + let serviceError = amplitudeServiceError( + 'events must be a JSON-encoded array of event objects.' + ); + if (error instanceof Error) { + serviceError.setParent(error); + } + throw serviceError; + } + + if (!Array.isArray(parsedEvents)) { + throw amplitudeServiceError('events must be a JSON-encoded array of event objects.'); + } + let client = new AmplitudeClient({ apiKey: ctx.auth.apiKey, secretKey: ctx.auth.secretKey, @@ -83,7 +101,7 @@ export let queryFunnelTool = SlateTool.create(spec, { events: data.events, funnelData: data }, - message: `Funnel analysis completed for **${ctx.input.start}** to **${ctx.input.end}** with ${JSON.parse(ctx.input.events).length} steps.` + message: `Funnel analysis completed for **${ctx.input.start}** to **${ctx.input.end}** with ${parsedEvents.length} steps.` }; }) .build(); diff --git a/integrations/amplitude/src/tools/track-events.ts b/integrations/amplitude/src/tools/track-events.ts index a5944c244b..08d8f0cf98 100644 --- a/integrations/amplitude/src/tools/track-events.ts +++ b/integrations/amplitude/src/tools/track-events.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AmplitudeClient } from '../lib/client'; +import { amplitudeServiceError } from '../lib/errors'; import { spec } from '../spec'; let eventSchema = z.object({ @@ -110,6 +111,18 @@ export let trackEventsTool = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.events.length > 2000) { + throw amplitudeServiceError('Amplitude accepts at most 2000 events per request.'); + } + + for (let [index, event] of ctx.input.events.entries()) { + if (!event.userId && !event.deviceId) { + throw amplitudeServiceError( + `Event at index ${index} must include at least one of userId or deviceId.` + ); + } + } + let client = new AmplitudeClient({ apiKey: ctx.auth.apiKey, secretKey: ctx.auth.secretKey, diff --git a/integrations/amplitude/vitest.config.ts b/integrations/amplitude/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/amplitude/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/api2pdf/README.md b/integrations/api2pdf/README.md index e80dfe2f35..792fe48524 100644 --- a/integrations/api2pdf/README.md +++ b/integrations/api2pdf/README.md @@ -1,6 +1,6 @@ # API 2 Pdf -Generate PDFs from HTML, URLs, and Markdown. Convert Microsoft Office documents, images, and email files to PDF. Capture screenshots of websites and HTML content. Merge multiple PDFs, extract specific page ranges, and reorder pages. Password-protect PDFs for secure delivery. Generate barcodes and QR codes. Create thumbnail previews for PDFs, Office documents, and email files. Extract structured data from PDFs. Convert HTML to Word (DOCX) and Excel (XLSX). Compress files into ZIP archives. Delete generated files on demand. +Generate PDFs from HTML, URLs, and Markdown. Convert Microsoft Office documents, images, and email files to PDF. Capture screenshots of websites and HTML content. Merge multiple PDFs, extract specific page ranges, and reorder pages. Password-protect PDFs for secure delivery. Generate barcodes and QR codes. Create thumbnail previews for PDFs, Office documents, and email files. Extract structured data from PDFs. Convert HTML to Word (DOCX) and Excel (XLSX). Compress files into ZIP archives. Generated files are returned as Slate attachments with metadata, and generated API2PDF files can be deleted on demand. ## License diff --git a/integrations/api2pdf/docs/SPEC.md b/integrations/api2pdf/docs/SPEC.md index 738573bac9..56f9441a14 100644 --- a/integrations/api2pdf/docs/SPEC.md +++ b/integrations/api2pdf/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -API2PDF is a REST API for PDF generation, document conversion, and file transformation. It supports HTML to PDF, URL to PDF, HTML to image, URL to image, Microsoft Office document conversion, email and image file conversion, PDF page extraction, PDF password protection, file zipping, barcode and QR code generation, markdown conversion, structured PDF data extraction, and image previews or thumbnails for PDF, office, and email files. It is built on engines including wkhtmltopdf, Headless Chrome, PdfSharp, LibreOffice, and related tools. +API2PDF is a REST API for PDF generation, document conversion, and file transformation. It supports HTML to PDF, URL to PDF, HTML to image, URL to image, Microsoft Office document conversion, email and image file conversion, PDF page extraction, PDF password protection, file zipping, barcode and QR code generation, markdown conversion, structured PDF data extraction, and image previews or thumbnails for PDF, office, and email files. It is built on engines including Headless Chrome, PdfSharp, LibreOffice, wkhtmltopdf, and related tools. ## Authentication @@ -22,15 +22,15 @@ The base URL for the API is `https://v2.api2pdf.com`. An XL cluster is also avai ### PDF Generation from HTML and URLs -Convert raw HTML or a publicly accessible URL into a PDF document. Supports Markdown, HTML, and URLs to PDF using Headless Chrome. Also available via the wkhtmltopdf engine as an alternative renderer. Configurable options include landscape orientation, custom headers/footers, page size, margins, and extra HTTP headers for authenticated source URLs. +Convert raw HTML, Markdown, or a publicly accessible URL into a PDF document using Headless Chrome. Configurable options include landscape orientation, custom headers/footers, page size, margins, tagged PDFs, outlines, print CSS, and extra HTTP headers for authenticated source URLs. The official API still exposes wkhtmltopdf endpoints, but recommends Chrome for new usage, so this integration keeps Chrome as the practical PDF generation surface. ### Screenshot / Image Capture -Capture a website, URL, or raw HTML as a screenshot when PDF is not the deliverable. Uses Headless Chrome for rendering. +Capture a website, URL, raw HTML, or Markdown as a screenshot when PDF is not the deliverable. Uses Headless Chrome for rendering and supports viewport and Puppeteer wait options. ### Office Document Conversion -Convert Word, PowerPoint, Excel, and images into PDF with LibreOffice. Also supports converting HTML to Word (DOCX), HTML to Excel (XLSX), and PDF to HTML. Any file format that LibreOffice can open is supported as input. +Convert Word, PowerPoint, Excel, images, and other LibreOffice-supported files into PDF. Also supports converting HTML or a URL to Word (DOCX), and HTML or a URL to Excel (XLSX). ### PDF Merging @@ -64,9 +64,13 @@ Extract structured data from existing PDF documents for automated content proces Compress multiple files into a single ZIP archive. +### API Status and Balance + +Check the current API2PDF service status and the remaining account balance for the authenticated API key. + ### File Management -By default, API2PDF will delete your generated file 24 hours after it has been generated. For those with high security needs, you can delete your file on command using the responseId returned from the original request. +Generated files are returned as Slate attachments with metadata including response ID, source file URL, MIME type, byte length, API-reported file size, cost, and processing time. By default, API2PDF deletes generated files 24 hours after they have been generated. For high-security use cases, delete a generated file immediately using the responseId returned from the original request. ## Events diff --git a/integrations/api2pdf/package.json b/integrations/api2pdf/package.json index 5cbf9c0489..a4b3dbc87a 100644 --- a/integrations/api2pdf/package.json +++ b/integrations/api2pdf/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --dir . --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/api2pdf/src/index.ts b/integrations/api2pdf/src/index.ts index 0f745aa39d..3ff2e20d36 100644 --- a/integrations/api2pdf/src/index.ts +++ b/integrations/api2pdf/src/index.ts @@ -3,6 +3,7 @@ import { spec } from './spec'; import { captureScreenshot, checkBalance, + checkStatus, convertDocument, convertToMarkdown, createZip, @@ -35,7 +36,8 @@ export let provider = Slate.create({ createZip, convertToMarkdown, deleteFile, - checkBalance + checkBalance, + checkStatus ], triggers: [inboundWebhook] }); diff --git a/integrations/api2pdf/src/lib/client.ts b/integrations/api2pdf/src/lib/client.ts index 326d0017af..31c1e38adb 100644 --- a/integrations/api2pdf/src/lib/client.ts +++ b/integrations/api2pdf/src/lib/client.ts @@ -1,8 +1,13 @@ import { createAxios } from 'slates'; +import { api2PdfApiError, api2PdfServiceError } from './errors'; import type { AnyToPdfParams, + Api2PdfFileAttachment, Api2PdfResponse, BarcodeParams, + BaseRequestParams, + ChromeImageOptions, + ChromePdfOptions, DataLoaderParams, ExtractPagesParams, HtmlToDocxParams, @@ -23,6 +28,110 @@ import type { ZipParams } from './types'; +type Api2PdfRawResponse = Partial & { + ResponseId?: string; + Success?: boolean; + FileUrl?: string; + MbOut?: number; + Cost?: number; + Seconds?: number; + Error?: string; +}; + +let cleanObject = >(input: T) => + Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined) + ) as Partial; + +let mapStorage = (storage: BaseRequestParams['storage']) => { + if (!storage) return undefined; + + return cleanObject({ + Method: storage.method, + Url: storage.url, + ExtraHTTPHeaders: storage.extraHTTPHeaders + }); +}; + +let mapBaseRequest = (params: BaseRequestParams) => + cleanObject({ + FileName: params.fileName, + Inline: params.inline, + UseCustomStorage: params.useCustomStorage, + Storage: mapStorage(params.storage) + }); + +let mapChromePdfOptions = (options?: ChromePdfOptions) => { + if (!options) return undefined; + + return cleanObject({ + Delay: options.delay, + Scale: options.scale, + DisplayHeaderFooter: options.displayHeaderFooter, + HeaderTemplate: options.headerTemplate, + FooterTemplate: options.footerTemplate, + PrintBackground: options.printBackground, + Landscape: options.landscape, + PageRanges: options.pageRanges, + Width: options.width, + Height: options.height, + MarginTop: options.marginTop, + MarginBottom: options.marginBottom, + MarginLeft: options.marginLeft, + MarginRight: options.marginRight, + PreferCSSPageSize: options.preferCSSPageSize, + OmitBackground: options.omitBackground, + Tagged: options.tagged, + Outline: options.outline, + UsePrintCss: options.usePrintCss, + PuppeteerWaitForMethod: options.puppeteerWaitForMethod, + PuppeteerWaitForValue: options.puppeteerWaitForValue + }); +}; + +let mapChromeImageOptions = (options?: ChromeImageOptions) => { + if (!options) return undefined; + + return cleanObject({ + Delay: options.delay, + FullPage: options.fullPage, + ViewPortOptions: options.viewPortOptions + ? cleanObject({ + Width: options.viewPortOptions.width, + Height: options.viewPortOptions.height, + IsMobile: options.viewPortOptions.isMobile, + DeviceScaleFactor: options.viewPortOptions.deviceScaleFactor, + IsLandscape: options.viewPortOptions.isLandscape, + HasTouch: options.viewPortOptions.hasTouch + }) + : undefined, + PuppeteerWaitForMethod: options.puppeteerWaitForMethod, + PuppeteerWaitForValue: options.puppeteerWaitForValue + }); +}; + +let stringValue = (value: unknown) => + typeof value === 'string' + ? value + : value === undefined || value === null + ? '' + : String(value); + +let numberValue = (value: unknown) => { + let parsed = typeof value === 'number' ? value : Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +}; + +let normalizeApi2PdfResponse = (data: Api2PdfRawResponse): Api2PdfResponse => ({ + responseId: stringValue(data.responseId ?? data.ResponseId), + success: data.success ?? data.Success ?? false, + fileUrl: stringValue(data.fileUrl ?? data.FileUrl), + mbOut: numberValue(data.mbOut ?? data.MbOut), + cost: numberValue(data.cost ?? data.Cost), + seconds: numberValue(data.seconds ?? data.Seconds), + error: data.error ?? data.Error +}); + export class Api2PdfClient { private axios: ReturnType; @@ -38,158 +147,402 @@ export class Api2PdfClient { }); } + private async postFile( + path: string, + params: Record, + operation: string + ): Promise { + try { + let res = await this.axios.post(path, cleanObject(params)); + return normalizeApi2PdfResponse(res.data); + } catch (error) { + throw api2PdfApiError(error, operation); + } + } + + private async getFile( + path: string, + params: Record, + operation: string + ): Promise { + try { + let res = await this.axios.get(path, { + params: cleanObject(params) + }); + return normalizeApi2PdfResponse(res.data); + } catch (error) { + throw api2PdfApiError(error, operation); + } + } + // --- Chrome PDF Endpoints --- async chromeHtmlToPdf(params: HtmlToPdfParams): Promise { - let res = await this.axios.post('/chrome/pdf/html', params); - return res.data; + return await this.postFile( + '/chrome/pdf/html', + { + ...mapBaseRequest(params), + Html: params.html, + Options: mapChromePdfOptions(params.options) + }, + 'Chrome HTML to PDF' + ); } async chromeUrlToPdf(params: UrlToPdfParams): Promise { - let res = await this.axios.post('/chrome/pdf/url', params); - return res.data; + return await this.postFile( + '/chrome/pdf/url', + { + ...mapBaseRequest(params), + Url: params.url, + Options: mapChromePdfOptions(params.options), + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'Chrome URL to PDF' + ); } async chromeMarkdownToPdf(params: MarkdownToPdfParams): Promise { - let res = await this.axios.post('/chrome/pdf/markdown', params); - return res.data; + return await this.postFile( + '/chrome/pdf/markdown', + { + ...mapBaseRequest(params), + Markdown: params.markdown, + Options: mapChromePdfOptions(params.options) + }, + 'Chrome Markdown to PDF' + ); } // --- Chrome Image Endpoints --- async chromeHtmlToImage(params: HtmlToImageParams): Promise { - let res = await this.axios.post('/chrome/image/html', params); - return res.data; + return await this.postFile( + '/chrome/image/html', + { + ...mapBaseRequest(params), + Html: params.html, + Options: mapChromeImageOptions(params.options) + }, + 'Chrome HTML to image' + ); } async chromeUrlToImage(params: UrlToImageParams): Promise { - let res = await this.axios.post('/chrome/image/url', params); - return res.data; + return await this.postFile( + '/chrome/image/url', + { + ...mapBaseRequest(params), + Url: params.url, + Options: mapChromeImageOptions(params.options), + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'Chrome URL to image' + ); } async chromeMarkdownToImage(params: MarkdownToImageParams): Promise { - let res = await this.axios.post('/chrome/image/markdown', params); - return res.data; + return await this.postFile( + '/chrome/image/markdown', + { + ...mapBaseRequest(params), + Markdown: params.markdown, + Options: mapChromeImageOptions(params.options) + }, + 'Chrome Markdown to image' + ); } // --- LibreOffice Endpoints --- async libreOfficeAnyToPdf(params: AnyToPdfParams): Promise { - let res = await this.axios.post('/libreoffice/any-to-pdf', params); - return res.data; + return await this.postFile( + '/libreoffice/any-to-pdf', + { + ...mapBaseRequest(params), + Url: params.url, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'LibreOffice file to PDF' + ); } async libreOfficeThumbnail(params: ThumbnailParams): Promise { - let res = await this.axios.post('/libreoffice/thumbnail', params); - return res.data; + return await this.postFile( + '/libreoffice/thumbnail', + { + ...mapBaseRequest(params), + Url: params.url, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'LibreOffice thumbnail' + ); } async libreOfficeHtmlToDocx(params: HtmlToDocxParams): Promise { - let res = await this.axios.post('/libreoffice/html-to-docx', params); - return res.data; + return await this.postFile( + '/libreoffice/html-to-docx', + { + ...mapBaseRequest(params), + Html: params.html, + Url: params.url, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'LibreOffice HTML to DOCX' + ); } async libreOfficeHtmlToXlsx(params: HtmlToXlsxParams): Promise { - let res = await this.axios.post('/libreoffice/html-to-xlsx', params); - return res.data; + return await this.postFile( + '/libreoffice/html-to-xlsx', + { + ...mapBaseRequest(params), + Html: params.html, + Url: params.url, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'LibreOffice HTML to XLSX' + ); } // --- PdfSharp Endpoints --- async pdfSharpMerge(params: MergePdfsParams): Promise { - let res = await this.axios.post('/pdfsharp/merge', params); - return res.data; + return await this.postFile( + '/pdfsharp/merge', + { + ...mapBaseRequest(params), + Urls: params.urls, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'PdfSharp merge' + ); } async pdfSharpPassword(params: PasswordPdfParams): Promise { - let res = await this.axios.post('/pdfsharp/password', params); - return res.data; + return await this.postFile( + '/pdfsharp/password', + { + ...mapBaseRequest(params), + Url: params.url, + UserPassword: params.userpassword, + OwnerPassword: params.ownerpassword, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'PdfSharp password' + ); } async pdfSharpExtractPages(params: ExtractPagesParams): Promise { - let res = await this.axios.post('/pdfsharp/extract-pages', params); - return res.data; + return await this.postFile( + '/pdfsharp/extract-pages', + { + ...mapBaseRequest(params), + Url: params.url, + Start: params.start, + End: params.end, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'PdfSharp extract pages' + ); } async pdfSharpWatermark(params: WatermarkPdfParams): Promise { - let res = await this.axios.post('/pdfsharp/watermark', params); - return res.data; + return await this.postFile( + '/pdfsharp/watermark', + { + ...mapBaseRequest(params), + Url: params.url, + Text: params.text, + FontSize: params.fontSize, + Color: params.color, + Opacity: params.opacity, + Rotation: params.rotation, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'PdfSharp watermark' + ); } // --- Barcode Endpoint --- async generateBarcode(params: BarcodeParams): Promise { - let res = await this.axios.get('/zebra', { - params: { + return await this.getFile( + '/zebra', + { format: params.format, value: params.value, - ...(params.width !== undefined && { width: params.width }), - ...(params.height !== undefined && { height: params.height }), - ...(params.showlabel !== undefined && { showlabel: params.showlabel }), + width: params.width, + height: params.height, + showlabel: params.showlabel, outputBinary: false - } - }); - return res.data; + }, + 'barcode generation' + ); } // --- Zip Endpoint --- async createZip(params: ZipParams): Promise { - let res = await this.axios.post('/zip', params, { - params: { outputBinary: false } - }); - return res.data; + return await this.postFile( + '/zip', + { + ...mapBaseRequest(params), + Files: params.files.map(file => + cleanObject({ + Url: file.url, + FileName: file.fileName + }) + ), + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'ZIP archive creation' + ); } // --- Markitdown Endpoint --- async convertToMarkdown(params: MarkitdownParams): Promise { - let res = await this.axios.post('/markitdown', params); - return res.data; + return await this.postFile( + '/markitdown', + { + ...mapBaseRequest(params), + Url: params.url, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'MarkItDown conversion' + ); } // --- OpenDataLoader Endpoints --- async extractJsonFromPdf(params: DataLoaderParams): Promise { - let res = await this.axios.post('/opendataloader/json', params); - return res.data; + return await this.postFile( + '/opendataloader/json', + { + ...mapBaseRequest(params), + Url: params.url, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'OpenDataLoader JSON extraction' + ); } async extractMarkdownFromPdf(params: DataLoaderParams): Promise { - let res = await this.axios.post('/opendataloader/markdown', params); - return res.data; + return await this.postFile( + '/opendataloader/markdown', + { + ...mapBaseRequest(params), + Url: params.url, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'OpenDataLoader Markdown extraction' + ); } async extractHtmlFromPdf(params: DataLoaderParams): Promise { - let res = await this.axios.post('/opendataloader/html', params); - return res.data; + return await this.postFile( + '/opendataloader/html', + { + ...mapBaseRequest(params), + Url: params.url, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'OpenDataLoader HTML extraction' + ); } // --- Wkhtml Endpoints --- async wkhtmlHtmlToPdf(params: WkhtmlHtmlToPdfParams): Promise { - let res = await this.axios.post('/wkhtml/pdf/html', params); - return res.data; + return await this.postFile( + '/wkhtml/pdf/html', + { + ...mapBaseRequest(params), + Html: params.html, + Options: params.options, + EnableToc: params.enableToc, + TocOptions: params.tocOptions + }, + 'wkhtml HTML to PDF' + ); } async wkhtmlUrlToPdf(params: WkhtmlUrlToPdfParams): Promise { - let res = await this.axios.post('/wkhtml/pdf/url', params); - return res.data; + return await this.postFile( + '/wkhtml/pdf/url', + { + ...mapBaseRequest(params), + Url: params.url, + Options: params.options, + EnableToc: params.enableToc, + TocOptions: params.tocOptions, + ExtraHTTPHeaders: params.extraHTTPHeaders + }, + 'wkhtml URL to PDF' + ); } // --- Utility Endpoints --- async deleteFile(responseId: string): Promise { - await this.axios.delete(`/file/${responseId}`); + try { + await this.axios.delete(`/file/${responseId}`); + } catch (error) { + throw api2PdfApiError(error, 'file deletion'); + } } async getBalance(): Promise<{ balance: number }> { - let res = await this.axios.get<{ balance: number }>('/balance'); - return res.data; + try { + let res = await this.axios.get<{ balance?: number; Balance?: number }>('/balance'); + return { + balance: numberValue(res.data.balance ?? res.data.Balance) + }; + } catch (error) { + throw api2PdfApiError(error, 'balance check'); + } } async getStatus(): Promise<{ status: string }> { - let res = await this.axios.get<{ status: string }>('/status'); - return res.data; + try { + let res = await this.axios.get<{ status?: string; Status?: string }>('/status'); + return { + status: stringValue(res.data.status ?? res.data.Status) || 'unknown' + }; + } catch (error) { + throw api2PdfApiError(error, 'status check'); + } + } + + async downloadFile(fileUrl: string): Promise { + let response: globalThis.Response; + + try { + response = await fetch(fileUrl); + } catch (error) { + throw api2PdfApiError(error, 'generated file download'); + } + + if (!response.ok) { + throw api2PdfServiceError( + `API2PDF generated file download failed: HTTP ${response.status} ${response.statusText}` + ); + } + + let contentType = response.headers.get('content-type')?.split(';')[0]?.trim(); + let bytes: Buffer; + + try { + bytes = Buffer.from(await response.arrayBuffer()); + } catch (error) { + throw api2PdfApiError(error, 'generated file download'); + } + + return { + contentBase64: bytes.toString('base64'), + mimeType: contentType || 'application/octet-stream', + byteLength: bytes.byteLength + }; } } diff --git a/integrations/api2pdf/src/lib/errors.ts b/integrations/api2pdf/src/lib/errors.ts new file mode 100644 index 0000000000..56a3f81dee --- /dev/null +++ b/integrations/api2pdf/src/lib/errors.ts @@ -0,0 +1,80 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) details.push(detail); +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) collectDetails(item, details); + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.Message); + pushDetail(details, value.error); + pushDetail(details, value.Error); + pushDetail(details, value.detail); + pushDetail(details, value.title); + pushDetail(details, value.code); + collectDetails(value.errors, details); +}; + +let extractApi2PdfMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) return details.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) return undefined; + + let code = response.data.code ?? response.data.error ?? response.data.Error; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +}; + +export let api2PdfServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let api2PdfApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = api2PdfServiceError( + `API2PDF API ${operation} failed: ${statusLabelFor(response)}${extractApi2PdfMessage(error)}` + ); + serviceError.data.reason = 'api2pdf_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) serviceError.setParent(error); + + return serviceError; +}; diff --git a/integrations/api2pdf/src/lib/types.ts b/integrations/api2pdf/src/lib/types.ts index 93b536f555..e8b787e4e6 100644 --- a/integrations/api2pdf/src/lib/types.ts +++ b/integrations/api2pdf/src/lib/types.ts @@ -8,6 +8,12 @@ export interface Api2PdfResponse { error?: string; } +export interface Api2PdfFileAttachment { + contentBase64: string; + mimeType: string; + byteLength: number; +} + export interface ChromePdfOptions { delay?: number; scale?: number; @@ -43,6 +49,8 @@ export interface ChromeImageOptions { isLandscape?: boolean; hasTouch?: boolean; }; + puppeteerWaitForMethod?: string; + puppeteerWaitForValue?: string; } export interface StorageOptions { diff --git a/integrations/api2pdf/src/tools.schema.test.ts b/integrations/api2pdf/src/tools.schema.test.ts new file mode 100644 index 0000000000..7208ba765c --- /dev/null +++ b/integrations/api2pdf/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('API2PDF tool input schemas', provider.actions); diff --git a/integrations/api2pdf/src/tools/capture-screenshot.ts b/integrations/api2pdf/src/tools/capture-screenshot.ts index ab47f01bfb..94b3d68813 100644 --- a/integrations/api2pdf/src/tools/capture-screenshot.ts +++ b/integrations/api2pdf/src/tools/capture-screenshot.ts @@ -1,7 +1,14 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; +import { api2PdfServiceError } from '../lib/errors'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; let imageOptionsSchema = z .object({ @@ -23,7 +30,15 @@ let imageOptionsSchema = z hasTouch: z.boolean().optional().describe('Simulate touch-enabled device') }) .optional() - .describe('Viewport configuration for the screenshot') + .describe('Viewport configuration for the screenshot'), + puppeteerWaitForMethod: z + .string() + .optional() + .describe('Puppeteer wait method, e.g. "WaitForNavigation" or "WaitForExpression"'), + puppeteerWaitForValue: z + .string() + .optional() + .describe('Value to pass to the Puppeteer wait method') }) .optional() .describe('Screenshot capture options'); @@ -59,17 +74,7 @@ export let captureScreenshot = SlateTool.create(spec, { .describe('Extra HTTP headers when fetching the source URL') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the generated screenshot image'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -80,7 +85,7 @@ export let captureScreenshot = SlateTool.create(spec, { Boolean ).length; if (sourceCount !== 1) { - throw new Error('Provide exactly one of: html, url, or markdown'); + throw api2PdfServiceError('Provide exactly one of: html, url, or markdown'); } let result: any; @@ -109,21 +114,14 @@ export let captureScreenshot = SlateTool.create(spec, { }); } - if (!result.success) { - throw new Error(result.error || 'Screenshot capture failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'Screenshot capture failed'); let sourceType = ctx.input.html ? 'HTML' : ctx.input.url ? 'URL' : 'Markdown'; return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Captured screenshot from ${sourceType} (${result.mbOut} MB, ${result.seconds}s). [Download Image](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Captured screenshot from ${sourceType} (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/check-status.ts b/integrations/api2pdf/src/tools/check-status.ts new file mode 100644 index 0000000000..fbe6648b98 --- /dev/null +++ b/integrations/api2pdf/src/tools/check-status.ts @@ -0,0 +1,36 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Api2PdfClient } from '../lib/client'; +import { spec } from '../spec'; + +export let checkStatus = SlateTool.create(spec, { + name: 'Check Status', + key: 'check_status', + description: `Check the API2PDF service health status endpoint. Use this to distinguish account or request issues from a broader API availability problem.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + status: z.string().describe('Current API2PDF service status') + }) + ) + .handleInvocation(async ctx => { + let client = new Api2PdfClient({ + token: ctx.auth.token, + useXlCluster: ctx.config.useXlCluster + }); + + let result = await client.getStatus(); + + return { + output: { + status: result.status + }, + message: `API2PDF status: **${result.status}**` + }; + }) + .build(); diff --git a/integrations/api2pdf/src/tools/convert-document.ts b/integrations/api2pdf/src/tools/convert-document.ts index 827313b867..11e83dc049 100644 --- a/integrations/api2pdf/src/tools/convert-document.ts +++ b/integrations/api2pdf/src/tools/convert-document.ts @@ -1,7 +1,14 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; +import { api2PdfServiceError } from '../lib/errors'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let convertDocument = SlateTool.create(spec, { name: 'Convert Document', @@ -35,17 +42,7 @@ export let convertDocument = SlateTool.create(spec, { .describe('Extra HTTP headers when fetching the source URL') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the converted document'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -56,7 +53,7 @@ export let convertDocument = SlateTool.create(spec, { if (ctx.input.outputFormat === 'pdf') { if (!ctx.input.url) { - throw new Error('A url is required for PDF conversion'); + throw api2PdfServiceError('A url is required for PDF conversion'); } result = await client.libreOfficeAnyToPdf({ url: ctx.input.url, @@ -66,41 +63,36 @@ export let convertDocument = SlateTool.create(spec, { }); } else if (ctx.input.outputFormat === 'docx') { if (!ctx.input.html && !ctx.input.url) { - throw new Error('Either html or url is required for DOCX conversion'); + throw api2PdfServiceError('Either html or url is required for DOCX conversion'); } result = await client.libreOfficeHtmlToDocx({ html: ctx.input.html, url: ctx.input.url, fileName: ctx.input.fileName, + inline: ctx.input.inline, extraHTTPHeaders: ctx.input.extraHttpHeaders }); } else { if (!ctx.input.html && !ctx.input.url) { - throw new Error('Either html or url is required for XLSX conversion'); + throw api2PdfServiceError('Either html or url is required for XLSX conversion'); } result = await client.libreOfficeHtmlToXlsx({ html: ctx.input.html, url: ctx.input.url, fileName: ctx.input.fileName, + inline: ctx.input.inline, extraHTTPHeaders: ctx.input.extraHttpHeaders }); } - if (!result.success) { - throw new Error(result.error || 'Document conversion failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'Document conversion failed'); let formatLabel = ctx.input.outputFormat.toUpperCase(); return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Converted document to ${formatLabel} (${result.mbOut} MB, ${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Converted document to ${formatLabel} (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/convert-to-markdown.ts b/integrations/api2pdf/src/tools/convert-to-markdown.ts index 429225cb59..87371668af 100644 --- a/integrations/api2pdf/src/tools/convert-to-markdown.ts +++ b/integrations/api2pdf/src/tools/convert-to-markdown.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let convertToMarkdown = SlateTool.create(spec, { name: 'Convert to Markdown', @@ -21,23 +27,17 @@ export let convertToMarkdown = SlateTool.create(spec, { .string() .optional() .describe('Desired file name for the output Markdown file'), + inline: z + .boolean() + .optional() + .describe('If true, opens in browser; if false, triggers download'), extraHttpHeaders: z .record(z.string(), z.string()) .optional() .describe('Extra HTTP headers when fetching the source document') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the generated Markdown file'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -47,22 +47,16 @@ export let convertToMarkdown = SlateTool.create(spec, { let result = await client.convertToMarkdown({ url: ctx.input.url, fileName: ctx.input.fileName, + inline: ctx.input.inline, extraHTTPHeaders: ctx.input.extraHttpHeaders }); - if (!result.success) { - throw new Error(result.error || 'Markdown conversion failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'Markdown conversion failed'); return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Converted document to Markdown (${result.mbOut} MB, ${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Converted document to Markdown (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/create-zip.ts b/integrations/api2pdf/src/tools/create-zip.ts index 6dbb3d5e7b..47eea1f06a 100644 --- a/integrations/api2pdf/src/tools/create-zip.ts +++ b/integrations/api2pdf/src/tools/create-zip.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let createZip = SlateTool.create(spec, { name: 'Create ZIP Archive', @@ -34,17 +40,7 @@ export let createZip = SlateTool.create(spec, { .describe('Extra HTTP headers when fetching the source files') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the ZIP archive'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -58,19 +54,12 @@ export let createZip = SlateTool.create(spec, { extraHTTPHeaders: ctx.input.extraHttpHeaders }); - if (!result.success) { - throw new Error(result.error || 'ZIP archive creation failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'ZIP archive creation failed'); return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Created ZIP archive with **${ctx.input.files.length}** file(s) (${result.mbOut} MB, ${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Created ZIP archive with **${ctx.input.files.length}** file(s) (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/extract-pdf-data.ts b/integrations/api2pdf/src/tools/extract-pdf-data.ts index 3e94b2dde3..60403436d1 100644 --- a/integrations/api2pdf/src/tools/extract-pdf-data.ts +++ b/integrations/api2pdf/src/tools/extract-pdf-data.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let extractPdfData = SlateTool.create(spec, { name: 'Extract PDF Data', @@ -27,23 +33,17 @@ export let extractPdfData = SlateTool.create(spec, { .string() .optional() .describe('Desired file name for the extracted data file'), + inline: z + .boolean() + .optional() + .describe('If true, opens in browser; if false, triggers download'), extraHttpHeaders: z .record(z.string(), z.string()) .optional() .describe('Extra HTTP headers when fetching the source PDF') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the extracted data file'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -56,35 +56,31 @@ export let extractPdfData = SlateTool.create(spec, { result = await client.extractJsonFromPdf({ url: ctx.input.url, fileName: ctx.input.fileName, + inline: ctx.input.inline, extraHTTPHeaders: ctx.input.extraHttpHeaders }); } else if (ctx.input.outputFormat === 'markdown') { result = await client.extractMarkdownFromPdf({ url: ctx.input.url, fileName: ctx.input.fileName, + inline: ctx.input.inline, extraHTTPHeaders: ctx.input.extraHttpHeaders }); } else { result = await client.extractHtmlFromPdf({ url: ctx.input.url, fileName: ctx.input.fileName, + inline: ctx.input.inline, extraHTTPHeaders: ctx.input.extraHttpHeaders }); } - if (!result.success) { - throw new Error(result.error || 'PDF data extraction failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'PDF data extraction failed'); return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Extracted data from PDF as ${ctx.input.outputFormat.toUpperCase()} (${result.mbOut} MB, ${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Extracted data from PDF as ${ctx.input.outputFormat.toUpperCase()} (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/extract-pdf-pages.ts b/integrations/api2pdf/src/tools/extract-pdf-pages.ts index da964f8fa4..277ae9cd9a 100644 --- a/integrations/api2pdf/src/tools/extract-pdf-pages.ts +++ b/integrations/api2pdf/src/tools/extract-pdf-pages.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let extractPdfPages = SlateTool.create(spec, { name: 'Extract PDF Pages', @@ -40,17 +46,7 @@ export let extractPdfPages = SlateTool.create(spec, { .describe('Extra HTTP headers when fetching the source PDF') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the extracted PDF'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -66,9 +62,7 @@ export let extractPdfPages = SlateTool.create(spec, { extraHTTPHeaders: ctx.input.extraHttpHeaders }); - if (!result.success) { - throw new Error(result.error || 'Page extraction failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'Page extraction failed'); let rangeDesc = ''; if (ctx.input.start !== undefined && ctx.input.end !== undefined) { @@ -82,14 +76,9 @@ export let extractPdfPages = SlateTool.create(spec, { } return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Extracted ${rangeDesc} from PDF (${result.mbOut} MB, ${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Extracted ${rangeDesc} from PDF (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/generate-barcode.ts b/integrations/api2pdf/src/tools/generate-barcode.ts index e681892282..8aeba7738c 100644 --- a/integrations/api2pdf/src/tools/generate-barcode.ts +++ b/integrations/api2pdf/src/tools/generate-barcode.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let generateBarcode = SlateTool.create(spec, { name: 'Generate Barcode', @@ -31,17 +37,7 @@ export let generateBarcode = SlateTool.create(spec, { .describe('Whether to display the encoded value as a label below the barcode') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the generated barcode image'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -56,19 +52,12 @@ export let generateBarcode = SlateTool.create(spec, { showlabel: ctx.input.showLabel }); - if (!result.success) { - throw new Error(result.error || 'Barcode generation failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'Barcode generation failed'); return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Generated ${ctx.input.format} barcode (${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Generated ${ctx.input.format} barcode (${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/generate-pdf.ts b/integrations/api2pdf/src/tools/generate-pdf.ts index d0c98d92b8..666942f8fe 100644 --- a/integrations/api2pdf/src/tools/generate-pdf.ts +++ b/integrations/api2pdf/src/tools/generate-pdf.ts @@ -1,7 +1,14 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; +import { api2PdfServiceError } from '../lib/errors'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; let chromePdfOptionsSchema = z .object({ @@ -46,6 +53,9 @@ let chromePdfOptionsSchema = z .boolean() .optional() .describe('Omit default white background for transparent PDFs'), + tagged: z.boolean().optional().describe('Generate a tagged, accessible PDF'), + outline: z.boolean().optional().describe('Embed a document outline/bookmarks'), + usePrintCss: z.boolean().optional().describe('Use print media CSS styles while rendering'), puppeteerWaitForMethod: z .string() .optional() @@ -65,7 +75,7 @@ export let generatePdf = SlateTool.create(spec, { instructions: [ 'Provide exactly one of: html, url, or markdown as the source content.', 'For URLs requiring authentication, pass the necessary headers via extraHttpHeaders.', - 'Use the options object to customize page layout, margins, orientation, and header/footer templates.' + 'Use the options object to customize page layout, margins, orientation, header/footer templates, accessibility tagging, and print CSS.' ], tags: { destructive: false, @@ -89,17 +99,7 @@ export let generatePdf = SlateTool.create(spec, { .describe('Extra HTTP headers to send when fetching the source URL') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the generated PDF'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -110,7 +110,7 @@ export let generatePdf = SlateTool.create(spec, { Boolean ).length; if (sourceCount !== 1) { - throw new Error('Provide exactly one of: html, url, or markdown'); + throw api2PdfServiceError('Provide exactly one of: html, url, or markdown'); } let result: any; @@ -139,21 +139,14 @@ export let generatePdf = SlateTool.create(spec, { }); } - if (!result.success) { - throw new Error(result.error || 'PDF generation failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'PDF generation failed'); let sourceType = ctx.input.html ? 'HTML' : ctx.input.url ? 'URL' : 'Markdown'; return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Generated PDF from ${sourceType} (${result.mbOut} MB, ${result.seconds}s). [Download PDF](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Generated PDF from ${sourceType} (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/generate-thumbnail.ts b/integrations/api2pdf/src/tools/generate-thumbnail.ts index 6133bb7714..fe5341d729 100644 --- a/integrations/api2pdf/src/tools/generate-thumbnail.ts +++ b/integrations/api2pdf/src/tools/generate-thumbnail.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let generateThumbnail = SlateTool.create(spec, { name: 'Generate Thumbnail', @@ -30,17 +36,7 @@ export let generateThumbnail = SlateTool.create(spec, { .describe('Extra HTTP headers when fetching the source document') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the generated thumbnail image'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -54,19 +50,12 @@ export let generateThumbnail = SlateTool.create(spec, { extraHTTPHeaders: ctx.input.extraHttpHeaders }); - if (!result.success) { - throw new Error(result.error || 'Thumbnail generation failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'Thumbnail generation failed'); return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Generated thumbnail preview (${result.mbOut} MB, ${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Generated thumbnail preview (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/index.ts b/integrations/api2pdf/src/tools/index.ts index eaddb0779d..e92f5bd78b 100644 --- a/integrations/api2pdf/src/tools/index.ts +++ b/integrations/api2pdf/src/tools/index.ts @@ -1,5 +1,6 @@ export * from './capture-screenshot'; export * from './check-balance'; +export * from './check-status'; export * from './convert-document'; export * from './convert-to-markdown'; export * from './create-zip'; diff --git a/integrations/api2pdf/src/tools/merge-pdfs.ts b/integrations/api2pdf/src/tools/merge-pdfs.ts index ed78b2a38c..1b42ff29a6 100644 --- a/integrations/api2pdf/src/tools/merge-pdfs.ts +++ b/integrations/api2pdf/src/tools/merge-pdfs.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let mergePdfs = SlateTool.create(spec, { name: 'Merge PDFs', @@ -33,17 +39,7 @@ export let mergePdfs = SlateTool.create(spec, { .describe('Extra HTTP headers when fetching the source PDFs') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the merged PDF'), - mbOut: z.number().describe('Size of the merged file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -57,19 +53,12 @@ export let mergePdfs = SlateTool.create(spec, { extraHTTPHeaders: ctx.input.extraHttpHeaders }); - if (!result.success) { - throw new Error(result.error || 'PDF merge failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'PDF merge failed'); return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Merged **${ctx.input.urls.length}** PDFs into one document (${result.mbOut} MB, ${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Merged **${ctx.input.urls.length}** PDFs into one document (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/protect-pdf.ts b/integrations/api2pdf/src/tools/protect-pdf.ts index 31a565d9fd..cbd1ad2fdb 100644 --- a/integrations/api2pdf/src/tools/protect-pdf.ts +++ b/integrations/api2pdf/src/tools/protect-pdf.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let protectPdf = SlateTool.create(spec, { name: 'Protect PDF', @@ -33,17 +39,7 @@ export let protectPdf = SlateTool.create(spec, { .describe('Extra HTTP headers when fetching the source PDF') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the password-protected PDF'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -59,19 +55,12 @@ export let protectPdf = SlateTool.create(spec, { extraHTTPHeaders: ctx.input.extraHttpHeaders }); - if (!result.success) { - throw new Error(result.error || 'PDF password protection failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'PDF password protection failed'); return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Added password protection to PDF (${result.mbOut} MB, ${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Added password protection to PDF (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/src/tools/shared.ts b/integrations/api2pdf/src/tools/shared.ts new file mode 100644 index 0000000000..36175a83af --- /dev/null +++ b/integrations/api2pdf/src/tools/shared.ts @@ -0,0 +1,51 @@ +import { createBase64Attachment } from 'slates'; +import { z } from 'zod'; +import type { Api2PdfClient } from '../lib/client'; +import { api2PdfServiceError } from '../lib/errors'; +import type { Api2PdfFileAttachment, Api2PdfResponse } from '../lib/types'; + +export let api2PdfFileOutputSchema = z.object({ + responseId: z + .string() + .describe('Unique ID for this request, can be used to delete the file later'), + fileUrl: z.string().describe('API2PDF URL for the generated file'), + mbOut: z.number().describe('Size of the generated file in megabytes, reported by API2PDF'), + cost: z.number().describe('Cost of this API call in USD'), + seconds: z.number().describe('Processing time in seconds'), + mimeType: z.string().describe('MIME type of the returned Slate attachment'), + byteLength: z.number().describe('Decoded byte length of the returned attachment'), + attachmentCount: z.number().describe('Number of Slate attachments returned') +}); + +export let requireApi2PdfSuccess = (result: Api2PdfResponse, fallback: string) => { + if (!result.success) { + throw api2PdfServiceError(result.error || fallback); + } + + if (!result.fileUrl) { + throw api2PdfServiceError('API2PDF succeeded but did not return a generated file URL.'); + } +}; + +export let fetchApi2PdfAttachment = async ( + client: Api2PdfClient, + result: Api2PdfResponse, + fallback: string +) => { + requireApi2PdfSuccess(result, fallback); + return await client.downloadFile(result.fileUrl); +}; + +export let fileOutput = (result: Api2PdfResponse, file: Api2PdfFileAttachment) => ({ + responseId: result.responseId, + fileUrl: result.fileUrl, + mbOut: result.mbOut, + cost: result.cost, + seconds: result.seconds, + mimeType: file.mimeType, + byteLength: file.byteLength, + attachmentCount: 1 +}); + +export let fileAttachment = (file: Api2PdfFileAttachment) => + createBase64Attachment(file.contentBase64, file.mimeType); diff --git a/integrations/api2pdf/src/tools/watermark-pdf.ts b/integrations/api2pdf/src/tools/watermark-pdf.ts index 662f975d5b..e1a96feb2e 100644 --- a/integrations/api2pdf/src/tools/watermark-pdf.ts +++ b/integrations/api2pdf/src/tools/watermark-pdf.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Api2PdfClient } from '../lib/client'; import { spec } from '../spec'; +import { + api2PdfFileOutputSchema, + fetchApi2PdfAttachment, + fileAttachment, + fileOutput +} from './shared'; export let watermarkPdf = SlateTool.create(spec, { name: 'Watermark PDF', @@ -37,17 +43,7 @@ export let watermarkPdf = SlateTool.create(spec, { .describe('Extra HTTP headers when fetching the source PDF') }) ) - .output( - z.object({ - responseId: z - .string() - .describe('Unique ID for this request, can be used to delete the file later'), - fileUrl: z.string().describe('URL to download the watermarked PDF'), - mbOut: z.number().describe('Size of the generated file in megabytes'), - cost: z.number().describe('Cost of this API call in USD'), - seconds: z.number().describe('Processing time in seconds') - }) - ) + .output(api2PdfFileOutputSchema) .handleInvocation(async ctx => { let client = new Api2PdfClient({ token: ctx.auth.token, @@ -66,21 +62,14 @@ export let watermarkPdf = SlateTool.create(spec, { extraHTTPHeaders: ctx.input.extraHttpHeaders }); - if (!result.success) { - throw new Error(result.error || 'PDF watermarking failed'); - } + let file = await fetchApi2PdfAttachment(client, result, 'PDF watermarking failed'); let watermarkText = ctx.input.text || 'DRAFT'; return { - output: { - responseId: result.responseId, - fileUrl: result.fileUrl, - mbOut: result.mbOut, - cost: result.cost, - seconds: result.seconds - }, - message: `Added "${watermarkText}" watermark to PDF (${result.mbOut} MB, ${result.seconds}s). [Download](${result.fileUrl})` + output: fileOutput(result, file), + attachments: [fileAttachment(file)], + message: `Added "${watermarkText}" watermark to PDF (${result.mbOut} MB, ${result.seconds}s) and returned it as a Slate attachment.` }; }) .build(); diff --git a/integrations/api2pdf/vitest.config.ts b/integrations/api2pdf/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/api2pdf/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/asana/README.md b/integrations/asana/README.md index 4dc23f1a3c..c426091c43 100644 --- a/integrations/asana/README.md +++ b/integrations/asana/README.md @@ -16,6 +16,10 @@ Create a new task in a project or workspace. Supports setting name, notes, assig Permanently delete a project. This action cannot be undone. +### Delete Section + +Delete an empty project section. Asana does not allow deleting the last remaining section. + ### Delete Task Permanently delete a task. This action cannot be undone. @@ -48,6 +52,10 @@ List tasks filtered by project, section, or assignee. At least one filter must b List all teams in an organization workspace. +### List Time Tracking Entries + +List Asana time tracking entries for a task, or retrieve one entry by GID. + ### List Users List users in a workspace. Returns user GIDs and names for referencing in other tools. @@ -72,10 +80,30 @@ List all sections in a project. Sections are used to organize tasks within a pro List all tags in a workspace. +### Manage Attachments + +List, inspect, attach, upload, or delete Asana attachments on tasks, projects, and project briefs. File bytes are accepted only as input for uploads; downloaded file contents are not returned inline. + +### Manage Custom Fields + +List, inspect, create, update, and maintain Asana custom field metadata for a workspace. + +### Manage Project Templates + +List, inspect, and instantiate Asana project templates. + ### Search Tasks Search for tasks in a workspace using various filters like text, assignee, projects, tags, completion status, and date ranges. Supports full-text search across task names and descriptions. +### Typeahead Search + +Search Asana workspace objects with the low-latency typeahead endpoint. Use this to discover project, project template, portfolio, tag, task, user, or custom field GIDs for other tools. + +### Update Section + +Rename an existing project section. + ### Update Project Update an existing project's name, notes, dates, color, layout, archived status, or privacy setting. diff --git a/integrations/asana/docs/SPEC.md b/integrations/asana/docs/SPEC.md index b028bee704..70ace7352f 100644 --- a/integrations/asana/docs/SPEC.md +++ b/integrations/asana/docs/SPEC.md @@ -42,7 +42,7 @@ OAuth is a mechanism for applications to access the Asana API on behalf of a use - `teams:read` - `time_tracking_entries:read` - `timesheet_approval_statuses:read`, `timesheet_approval_statuses:write` -- `workspace.typeahead:read` +- `workspaces.typeahead:read` - `users:read` - `webhooks:read`, `webhooks:write`, `webhooks:delete` - `workspaces:read` diff --git a/integrations/asana/package.json b/integrations/asana/package.json index 22fdc1f26e..bf27d1d7e0 100644 --- a/integrations/asana/package.json +++ b/integrations/asana/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/asana/src/auth.ts b/integrations/asana/src/auth.ts index f95bbe6f45..feed683d2c 100644 --- a/integrations/asana/src/auth.ts +++ b/integrations/asana/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { asanaApiError, asanaServiceError } from './lib/errors'; let asanaApi = createAxios({ baseURL: 'https://app.asana.com/api/1.0' @@ -18,11 +19,16 @@ let outputSchema = z.object({ type AuthOutput = z.infer; let fetchProfile = async (token: string) => { - let response = await asanaApi.get('/users/me', { - headers: { - Authorization: `Bearer ${token}` - } - }); + let response: any; + try { + response = await asanaApi.get('/users/me', { + headers: { + Authorization: `Bearer ${token}` + } + }); + } catch (error) { + throw asanaApiError(error, 'fetch profile'); + } let user = response.data.data; @@ -176,7 +182,7 @@ export let auth = SlateAuth.create() { title: 'Typeahead Read', description: 'Use workspace typeahead search', - scope: 'workspace.typeahead:read' + scope: 'workspaces.typeahead:read' }, { title: 'OpenID Connect', @@ -202,23 +208,32 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let response = await asanaAuth.post( - '/-/oauth_token', - new URLSearchParams({ - grant_type: 'authorization_code', - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - redirect_uri: ctx.redirectUri, - code: ctx.code - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + let response: any; + try { + response = await asanaAuth.post( + '/-/oauth_token', + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + redirect_uri: ctx.redirectUri, + code: ctx.code + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } } - } - ); + ); + } catch (error) { + throw asanaApiError(error, 'exchange OAuth code'); + } let data = response.data; + if (!data.access_token) { + throw asanaServiceError('Asana OAuth token exchange did not return an access token.'); + } + let expiresAt = data.expires_in ? new Date(Date.now() + data.expires_in * 1000).toISOString() : undefined; @@ -234,25 +249,34 @@ export let auth = SlateAuth.create() handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - throw new Error('No refresh token available'); + throw asanaServiceError('No Asana refresh token is available.'); } - let response = await asanaAuth.post( - '/-/oauth_token', - new URLSearchParams({ - grant_type: 'refresh_token', - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - refresh_token: ctx.output.refreshToken - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + let response: any; + try { + response = await asanaAuth.post( + '/-/oauth_token', + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + refresh_token: ctx.output.refreshToken + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } } - } - ); + ); + } catch (error) { + throw asanaApiError(error, 'refresh OAuth token'); + } let data = response.data; + if (!data.access_token) { + throw asanaServiceError('Asana OAuth refresh did not return an access token.'); + } + let expiresAt = data.expires_in ? new Date(Date.now() + data.expires_in * 1000).toISOString() : undefined; diff --git a/integrations/asana/src/index.ts b/integrations/asana/src/index.ts index 935f5be46f..aa94c00cf9 100644 --- a/integrations/asana/src/index.ts +++ b/integrations/asana/src/index.ts @@ -8,6 +8,7 @@ import { createTag, createTask, deleteProject, + deleteSection, deleteTask, getGoal, getPortfolio, @@ -23,11 +24,17 @@ import { listTags, listTasks, listTeams, + listTimeTrackingEntries, listUsers, listWorkspaces, + manageAttachments, + manageCustomFields, + manageProjectTemplates, searchTasks, + typeaheadSearch, updatePortfolio, updateProject, + updateSection, updateTask } from './tools'; import { projectEvents, taskChangesWebhook, taskEvents } from './triggers'; @@ -50,19 +57,26 @@ export let provider = Slate.create({ listSubtasks, listSections, createSection, + updateSection, + deleteSection, listComments, addComment, listTags, createTag, + manageAttachments, + manageCustomFields, listPortfolios, getPortfolio, createPortfolio, updatePortfolio, + manageProjectTemplates, listGoals, getGoal, listUsers, getUser, - listTeams + listTeams, + listTimeTrackingEntries, + typeaheadSearch ], triggers: [projectEvents, taskEvents, taskChangesWebhook] }); diff --git a/integrations/asana/src/lib/client.ts b/integrations/asana/src/lib/client.ts index 6f9a00e229..4e6a25bd46 100644 --- a/integrations/asana/src/lib/client.ts +++ b/integrations/asana/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { asanaApiError } from './errors'; export class Client { private axios: ReturnType; @@ -11,6 +12,11 @@ export class Client { 'Content-Type': 'application/json' } }); + + this.axios.interceptors.response.use( + response => response, + error => Promise.reject(asanaApiError(error)) + ); } // ── Workspaces ── @@ -55,13 +61,14 @@ export class Client { workspaceId: string, params?: { limit?: number; offset?: string; archived?: boolean; team?: string } ) { - let response = await this.axios.get('/projects', { + let path = params?.team + ? `/teams/${params.team}/projects` + : `/workspaces/${workspaceId}/projects`; + let response = await this.axios.get(path, { params: { - workspace: workspaceId, limit: params?.limit ?? 100, offset: params?.offset, archived: params?.archived, - team: params?.team, opt_fields: 'name,gid,archived,color,created_at,current_status,due_on,start_on,modified_at,owner,team,workspace,notes,public,default_view' } @@ -80,9 +87,9 @@ export class Client { } async createProject(workspaceId: string, data: Record) { - let response = await this.axios.post('/projects', { - data: { ...data, workspace: workspaceId } - }); + let { team, ...projectData } = data; + let path = team ? `/teams/${team}/projects` : `/workspaces/${workspaceId}/projects`; + let response = await this.axios.post(path, { data: projectData }); return response.data.data; } @@ -212,6 +219,12 @@ export class Client { }); } + async removeDependentsFromTask(taskId: string, dependentIds: string[]) { + await this.axios.post(`/tasks/${taskId}/removeDependents`, { + data: { dependents: dependentIds } + }); + } + async addProjectToTask( taskId: string, projectId: string, @@ -483,6 +496,18 @@ export class Client { return response.data.data; } + async createEnumOption(customFieldId: string, data: Record) { + let response = await this.axios.post(`/custom_fields/${customFieldId}/enum_options`, { + data + }); + return response.data.data; + } + + async updateEnumOption(enumOptionId: string, data: Record) { + let response = await this.axios.put(`/enum_options/${enumOptionId}`, { data }); + return response.data.data; + } + // ── Teams ── async listTeamsInWorkspace( @@ -522,16 +547,19 @@ export class Client { async typeahead(workspaceId: string, resourceType: string, query: string, count?: number) { let response = await this.axios.get(`/workspaces/${workspaceId}/typeahead`, { - params: { resource_type: resourceType, query, count: count ?? 10 } + params: { resource_type: resourceType, query, count: count ?? 20 } }); return response.data; } // ── Attachments ── - async listAttachments(parentId: string, parentType: string = 'task') { - let response = await this.axios.get(`/${parentType}s/${parentId}/attachments`, { + async listAttachments(parentId: string, params?: { limit?: number; offset?: string }) { + let response = await this.axios.get('/attachments', { params: { + parent: parentId, + limit: params?.limit ?? 100, + offset: params?.offset, opt_fields: 'name,gid,resource_type,created_at,download_url,host,parent,view_url,resource_subtype,size' } @@ -540,14 +568,50 @@ export class Client { } async getAttachment(attachmentId: string) { - let response = await this.axios.get(`/attachments/${attachmentId}`); + let response = await this.axios.get(`/attachments/${attachmentId}`, { + params: { + opt_fields: + 'name,gid,resource_type,created_at,download_url,host,parent,view_url,resource_subtype,size' + } + }); + return response.data.data; + } + + async createExternalAttachment(params: { + parentId: string; + url: string; + name: string; + connectToApp?: boolean; + }) { + let form = new FormData(); + form.append('parent', params.parentId); + form.append('resource_subtype', 'external'); + form.append('url', params.url); + form.append('name', params.name); + if (params.connectToApp !== undefined) { + form.append('connect_to_app', String(params.connectToApp)); + } + + let response = await this.axios.post('/attachments', form); return response.data.data; } - async createAttachmentFromUrl(parentId: string, url: string, name: string) { - let response = await this.axios.post(`/tasks/${parentId}/attachments`, { - data: { resource_subtype: 'external', url, name, parent: parentId } + async uploadAttachment(params: { + parentId: string; + fileName: string; + contentBase64: string; + mimeType?: string; + }) { + let fileBytes = Buffer.from(params.contentBase64, 'base64'); + let form = new FormData(); + let blob = new Blob([fileBytes], { + type: params.mimeType ?? 'application/octet-stream' }); + + form.append('parent', params.parentId); + form.append('file', blob, params.fileName); + + let response = await this.axios.post('/attachments', form); return response.data.data; } @@ -571,7 +635,11 @@ export class Client { let payload: any = response.data; return { webhook: payload?.data, - hookSecret: payload?.['X-Hook-Secret'] ?? null + hookSecret: + response.headers?.['x-hook-secret'] ?? + response.headers?.['X-Hook-Secret'] ?? + payload?.['X-Hook-Secret'] ?? + null }; } @@ -595,21 +663,33 @@ export class Client { // ── Project Templates ── - async listProjectTemplates( - workspaceId: string, - params?: { limit?: number; offset?: string } - ) { - let response = await this.axios.get(`/project_templates`, { + async listProjectTemplates(params: { + workspaceId?: string; + teamId?: string; + limit?: number; + offset?: string; + }) { + let response = await this.axios.get('/project_templates', { params: { - workspace: workspaceId, - limit: params?.limit ?? 100, - offset: params?.offset, - opt_fields: 'name,gid,description,color,public,requested_dates,team' + workspace: params.workspaceId, + team: params.teamId, + limit: params.limit ?? 100, + offset: params.offset, + opt_fields: 'name,gid,description,color,public,requested_dates,team,workspace' } }); return response.data; } + async getProjectTemplate(templateId: string) { + let response = await this.axios.get(`/project_templates/${templateId}`, { + params: { + opt_fields: 'name,gid,description,color,public,requested_dates,team,workspace' + } + }); + return response.data.data; + } + async instantiateProjectTemplate(templateId: string, data: Record) { let response = await this.axios.post( `/project_templates/${templateId}/instantiateProject`, @@ -632,6 +712,16 @@ export class Client { return response.data; } + async getTimeTrackingEntry(timeTrackingEntryId: string) { + let response = await this.axios.get(`/time_tracking_entries/${timeTrackingEntryId}`, { + params: { + opt_fields: + 'gid,created_by,created_by.name,duration_minutes,entered_on,created_at,task' + } + }); + return response.data.data; + } + // ── User Task Lists ── async getUserTaskList(userId: string, workspaceId: string) { diff --git a/integrations/asana/src/lib/errors.ts b/integrations/asana/src/lib/errors.ts new file mode 100644 index 0000000000..2b52baaafa --- /dev/null +++ b/integrations/asana/src/lib/errors.ts @@ -0,0 +1,94 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractAsanaMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data ?? (isRecord(error) ? error.data : undefined); + let details: string[] = []; + + if (isRecord(data)) { + let errors = data.errors; + if (Array.isArray(errors)) { + for (let item of errors) { + if (!isRecord(item)) continue; + addDetail(details, item.message); + addDetail(details, item.phrase); + addDetail(details, item.help); + } + } + + for (let key of ['message', 'error', 'error_description']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getAsanaErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let asanaServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let asanaApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getAsanaErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = asanaServiceError( + `Asana API ${operation} failed: ${statusLabel}${extractAsanaMessage(error)}` + ); + serviceError.data.reason = 'asana_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/asana/src/tools.schema.test.ts b/integrations/asana/src/tools.schema.test.ts new file mode 100644 index 0000000000..116f2e59cc --- /dev/null +++ b/integrations/asana/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Asana tool input schemas', provider.actions); diff --git a/integrations/asana/src/tools/index.ts b/integrations/asana/src/tools/index.ts index 73900af545..a2d06d06b6 100644 --- a/integrations/asana/src/tools/index.ts +++ b/integrations/asana/src/tools/index.ts @@ -9,12 +9,17 @@ export * from './list-projects'; export * from './list-subtasks'; export * from './list-tasks'; export * from './list-teams'; +export * from './list-time-tracking-entries'; export * from './list-users'; export * from './list-workspaces'; +export * from './manage-attachments'; export * from './manage-comments'; +export * from './manage-custom-fields'; export * from './manage-portfolios'; +export * from './manage-project-templates'; export * from './manage-sections'; export * from './manage-tags'; export * from './search-tasks'; +export * from './typeahead-search'; export * from './update-project'; export * from './update-task'; diff --git a/integrations/asana/src/tools/list-time-tracking-entries.ts b/integrations/asana/src/tools/list-time-tracking-entries.ts new file mode 100644 index 0000000000..d9a7e2b355 --- /dev/null +++ b/integrations/asana/src/tools/list-time-tracking-entries.ts @@ -0,0 +1,83 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { asanaServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let formatTimeTrackingEntry = (entry: any) => ({ + timeTrackingEntryId: entry.gid, + durationMinutes: entry.duration_minutes, + enteredOn: entry.entered_on, + createdAt: entry.created_at, + createdBy: entry.created_by, + task: entry.task +}); + +export let listTimeTrackingEntries = SlateTool.create(spec, { + name: 'List Time Tracking Entries', + key: 'list_time_tracking_entries', + description: `List Asana time tracking entries for a task, or retrieve one entry by GID.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + taskId: z.string().optional().describe('Task GID whose time entries should be listed.'), + timeTrackingEntryId: z + .string() + .optional() + .describe('Specific time tracking entry GID to retrieve.'), + limit: z.number().optional().describe('Maximum entries to return when listing.') + }) + ) + .output( + z.object({ + timeTrackingEntries: z + .array( + z.object({ + timeTrackingEntryId: z.string(), + durationMinutes: z.number().optional(), + enteredOn: z.string().optional(), + createdAt: z.string().optional(), + createdBy: z.any().optional(), + task: z.any().optional() + }) + ) + .optional(), + timeTrackingEntry: z.any().optional(), + timeTrackingEntryCount: z.number() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + if (ctx.input.timeTrackingEntryId) { + let timeTrackingEntry = formatTimeTrackingEntry( + await client.getTimeTrackingEntry(ctx.input.timeTrackingEntryId) + ); + + return { + output: { timeTrackingEntry, timeTrackingEntryCount: 1 }, + message: `Retrieved time tracking entry ${timeTrackingEntry.timeTrackingEntryId}.` + }; + } + + if (!ctx.input.taskId) { + throw asanaServiceError('taskId is required when timeTrackingEntryId is not provided.'); + } + + let result = await client.listTimeTrackingEntries(ctx.input.taskId, { + limit: ctx.input.limit + }); + let timeTrackingEntries = (result.data || []).map(formatTimeTrackingEntry); + + return { + output: { + timeTrackingEntries, + timeTrackingEntryCount: timeTrackingEntries.length + }, + message: `Found **${timeTrackingEntries.length}** time tracking entr(y/ies).` + }; + }) + .build(); diff --git a/integrations/asana/src/tools/manage-attachments.ts b/integrations/asana/src/tools/manage-attachments.ts new file mode 100644 index 0000000000..083a23714e --- /dev/null +++ b/integrations/asana/src/tools/manage-attachments.ts @@ -0,0 +1,176 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { asanaServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let attachmentSchema = z.object({ + attachmentId: z.string(), + name: z.string().optional(), + resourceType: z.string().optional(), + resourceSubtype: z.string().optional(), + host: z.string().optional(), + createdAt: z.string().optional(), + downloadUrl: z.string().nullable().optional(), + viewUrl: z.string().nullable().optional(), + size: z.number().nullable().optional(), + parent: z.any().optional() +}); + +let formatAttachment = (attachment: any) => ({ + attachmentId: attachment.gid, + name: attachment.name, + resourceType: attachment.resource_type, + resourceSubtype: attachment.resource_subtype, + host: attachment.host, + createdAt: attachment.created_at, + downloadUrl: attachment.download_url, + viewUrl: attachment.view_url, + size: attachment.size, + parent: attachment.parent +}); + +let requireField = (value: T | undefined | null, label: string, action: string): T => { + if (value === undefined || value === null || value === '') { + throw asanaServiceError(`${label} is required for "${action}".`); + } + + return value; +}; + +let validateBase64 = (value: string) => { + let normalized = value.trim(); + let bytes = Buffer.from(normalized, 'base64'); + let roundTrip = bytes.toString('base64').replace(/=+$/, ''); + let input = normalized.replace(/=+$/, ''); + + if (bytes.length === 0 || roundTrip !== input) { + throw asanaServiceError('contentBase64 must be valid non-empty base64 content.'); + } +}; + +export let manageAttachments = SlateTool.create(spec, { + name: 'Manage Attachments', + key: 'manage_attachments', + description: `List, inspect, attach, upload, or delete Asana attachments on tasks, projects, and project briefs. File bytes are accepted only as input for uploads; downloaded file contents are not returned inline.`, + instructions: [ + 'Use action "list" with parentId to list attachment metadata for a task, project, or project brief.', + 'Use action "attach_external" with parentId, url, and name to attach an external URL.', + 'Use action "upload" with parentId, fileName, and contentBase64 to upload file bytes to Asana.', + 'Use action "get" or "delete" with attachmentId for a specific attachment.' + ] +}) + .input( + z.object({ + action: z + .enum(['list', 'get', 'attach_external', 'upload', 'delete']) + .describe('Attachment operation to perform.'), + parentId: z + .string() + .optional() + .describe('Parent task, project, or project brief GID for list/create actions.'), + attachmentId: z.string().optional().describe('Attachment GID for get/delete actions.'), + name: z.string().optional().describe('Display name for an external attachment.'), + url: z.string().optional().describe('Public URL for an external attachment.'), + connectToApp: z + .boolean() + .optional() + .describe('For OAuth external task attachments, connect the current app widget.'), + fileName: z.string().optional().describe('File name for upload action.'), + contentBase64: z + .string() + .optional() + .describe('Base64-encoded file content for upload action.'), + mimeType: z.string().optional().describe('MIME type for uploaded file content.'), + limit: z.number().optional().describe('Maximum attachments to return for list action.') + }) + ) + .output( + z.object({ + attachments: z.array(attachmentSchema).optional(), + attachment: attachmentSchema.optional(), + deleted: z.boolean().optional(), + attachmentCount: z.number().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + if (ctx.input.action === 'list') { + let parentId = requireField(ctx.input.parentId, 'parentId', ctx.input.action); + let result = await client.listAttachments(parentId, { limit: ctx.input.limit }); + let attachments = (result.data || []).map(formatAttachment); + + return { + output: { attachments, attachmentCount: attachments.length }, + message: `Found **${attachments.length}** attachment(s).` + }; + } + + if (ctx.input.action === 'get') { + let attachmentId = requireField( + ctx.input.attachmentId, + 'attachmentId', + ctx.input.action + ); + let attachment = formatAttachment(await client.getAttachment(attachmentId)); + + return { + output: { attachment, attachmentCount: 1 }, + message: `Retrieved attachment **${attachment.name ?? attachment.attachmentId}**.` + }; + } + + if (ctx.input.action === 'attach_external') { + let parentId = requireField(ctx.input.parentId, 'parentId', ctx.input.action); + let name = requireField(ctx.input.name, 'name', ctx.input.action); + let url = requireField(ctx.input.url, 'url', ctx.input.action); + let attachment = formatAttachment( + await client.createExternalAttachment({ + parentId, + name, + url, + connectToApp: ctx.input.connectToApp + }) + ); + + return { + output: { attachment, attachmentCount: 1 }, + message: `Attached external resource **${attachment.name ?? name}**.` + }; + } + + if (ctx.input.action === 'upload') { + let parentId = requireField(ctx.input.parentId, 'parentId', ctx.input.action); + let fileName = requireField(ctx.input.fileName, 'fileName', ctx.input.action); + let contentBase64 = requireField( + ctx.input.contentBase64, + 'contentBase64', + ctx.input.action + ); + validateBase64(contentBase64); + + let attachment = formatAttachment( + await client.uploadAttachment({ + parentId, + fileName, + contentBase64, + mimeType: ctx.input.mimeType + }) + ); + + return { + output: { attachment, attachmentCount: 1 }, + message: `Uploaded attachment **${attachment.name ?? fileName}**.` + }; + } + + let attachmentId = requireField(ctx.input.attachmentId, 'attachmentId', ctx.input.action); + await client.deleteAttachment(attachmentId); + + return { + output: { deleted: true, attachmentCount: 0 }, + message: `Deleted attachment ${attachmentId}.` + }; + }) + .build(); diff --git a/integrations/asana/src/tools/manage-custom-fields.ts b/integrations/asana/src/tools/manage-custom-fields.ts new file mode 100644 index 0000000000..47c5fa1d13 --- /dev/null +++ b/integrations/asana/src/tools/manage-custom-fields.ts @@ -0,0 +1,242 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { asanaServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let enumOptionInputSchema = z.object({ + name: z.string(), + color: z.string().optional(), + enabled: z.boolean().optional() +}); + +let customFieldSchema = z.object({ + customFieldId: z.string(), + name: z.string().optional(), + type: z.string().optional(), + resourceSubtype: z.string().optional(), + description: z.string().nullable().optional(), + enumOptions: z.array(z.any()).optional(), + precision: z.number().nullable().optional(), + format: z.string().nullable().optional(), + currencyCode: z.string().nullable().optional(), + hasNotificationsEnabled: z.boolean().optional() +}); + +let formatCustomField = (field: any) => ({ + customFieldId: field.gid, + name: field.name, + type: field.type, + resourceSubtype: field.resource_subtype, + description: field.description, + enumOptions: field.enum_options, + precision: field.precision, + format: field.format, + currencyCode: field.currency_code, + hasNotificationsEnabled: field.has_notifications_enabled +}); + +let requireField = (value: T | undefined | null, label: string, action: string): T => { + if (value === undefined || value === null || value === '') { + throw asanaServiceError(`${label} is required for "${action}".`); + } + + return value; +}; + +let buildCustomFieldData = (input: { + name?: string; + fieldType?: string; + description?: string | null; + enumOptions?: z.infer[]; + precision?: number; + format?: string; + currencyCode?: string; + hasNotificationsEnabled?: boolean; +}) => { + let data: Record = {}; + if (input.name !== undefined) data.name = input.name; + if (input.fieldType !== undefined) data.resource_subtype = input.fieldType; + if (input.description !== undefined) data.description = input.description; + if (input.enumOptions !== undefined) data.enum_options = input.enumOptions; + if (input.precision !== undefined) data.precision = input.precision; + if (input.format !== undefined) data.format = input.format; + if (input.currencyCode !== undefined) data.currency_code = input.currencyCode; + if (input.hasNotificationsEnabled !== undefined) { + data.has_notifications_enabled = input.hasNotificationsEnabled; + } + return data; +}; + +export let manageCustomFields = SlateTool.create(spec, { + name: 'Manage Custom Fields', + key: 'manage_custom_fields', + description: `List, inspect, create, update, and maintain Asana custom field metadata for a workspace.`, + instructions: [ + 'Use action "list" with workspaceId to discover custom field GIDs.', + 'Use action "get" with customFieldId before setting task customFields values.', + 'Use action "create" with workspaceId, name, and fieldType.', + 'Use action "create_enum_option" with customFieldId and enumOptionName for enum or multi_enum fields.' + ] +}) + .input( + z.object({ + action: z + .enum(['list', 'get', 'create', 'update', 'create_enum_option', 'update_enum_option']) + .describe('Custom field metadata operation to perform.'), + workspaceId: z.string().optional().describe('Workspace GID for list/create actions.'), + customFieldId: z + .string() + .optional() + .describe('Custom field GID for get/update/enum option actions.'), + enumOptionId: z.string().optional().describe('Enum option GID for update_enum_option.'), + name: z.string().optional().describe('Custom field name for create/update.'), + fieldType: z + .enum(['text', 'enum', 'multi_enum', 'number', 'date', 'people']) + .optional() + .describe('Custom field resource subtype for create.'), + description: z + .string() + .nullable() + .optional() + .describe('Custom field description, or null to clear when supported.'), + enumOptions: z + .array(enumOptionInputSchema) + .optional() + .describe('Enum options for enum or multi_enum field creation.'), + enumOptionName: z + .string() + .optional() + .describe('Enum option name for create/update enum option actions.'), + enumOptionColor: z + .string() + .optional() + .describe('Enum option color for create/update enum option actions.'), + enumOptionEnabled: z + .boolean() + .optional() + .describe('Enable or disable an enum option on update.'), + precision: z.number().optional().describe('Decimal precision for number fields.'), + format: z.string().optional().describe('Number field format.'), + currencyCode: z.string().optional().describe('Currency code for currency fields.'), + hasNotificationsEnabled: z + .boolean() + .optional() + .describe('Whether notifications are enabled for custom field changes.'), + limit: z.number().optional().describe('Maximum custom fields to return for list action.') + }) + ) + .output( + z.object({ + customFields: z.array(customFieldSchema).optional(), + customField: customFieldSchema.optional(), + enumOption: z.any().optional(), + customFieldCount: z.number().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + if (ctx.input.action === 'list') { + let workspaceId = requireField(ctx.input.workspaceId, 'workspaceId', ctx.input.action); + let result = await client.listCustomFieldsInWorkspace(workspaceId, { + limit: ctx.input.limit + }); + let customFields = (result.data || []).map(formatCustomField); + + return { + output: { customFields, customFieldCount: customFields.length }, + message: `Found **${customFields.length}** custom field(s).` + }; + } + + if (ctx.input.action === 'get') { + let customFieldId = requireField( + ctx.input.customFieldId, + 'customFieldId', + ctx.input.action + ); + let customField = formatCustomField(await client.getCustomField(customFieldId)); + + return { + output: { customField, customFieldCount: 1 }, + message: `Retrieved custom field **${customField.name ?? customField.customFieldId}**.` + }; + } + + if (ctx.input.action === 'create') { + let workspaceId = requireField(ctx.input.workspaceId, 'workspaceId', ctx.input.action); + requireField(ctx.input.name, 'name', ctx.input.action); + requireField(ctx.input.fieldType, 'fieldType', ctx.input.action); + + let customField = formatCustomField( + await client.createCustomField(workspaceId, buildCustomFieldData(ctx.input)) + ); + + return { + output: { customField, customFieldCount: 1 }, + message: `Created custom field **${customField.name ?? customField.customFieldId}**.` + }; + } + + if (ctx.input.action === 'update') { + let customFieldId = requireField( + ctx.input.customFieldId, + 'customFieldId', + ctx.input.action + ); + let { + resource_subtype: _resourceSubtype, + enum_options: _enumOptions, + ...data + } = buildCustomFieldData(ctx.input); + + if (Object.keys(data).length === 0) { + throw asanaServiceError('Provide at least one custom field property to update.'); + } + + let customField = formatCustomField(await client.updateCustomField(customFieldId, data)); + + return { + output: { customField, customFieldCount: 1 }, + message: `Updated custom field **${customField.name ?? customField.customFieldId}**.` + }; + } + + if (ctx.input.action === 'create_enum_option') { + let customFieldId = requireField( + ctx.input.customFieldId, + 'customFieldId', + ctx.input.action + ); + let name = requireField(ctx.input.enumOptionName, 'enumOptionName', ctx.input.action); + let enumOption = await client.createEnumOption(customFieldId, { + name, + color: ctx.input.enumOptionColor + }); + + return { + output: { enumOption }, + message: `Created enum option **${enumOption.name ?? name}**.` + }; + } + + let enumOptionId = requireField(ctx.input.enumOptionId, 'enumOptionId', ctx.input.action); + let enumData: Record = {}; + if (ctx.input.enumOptionName !== undefined) enumData.name = ctx.input.enumOptionName; + if (ctx.input.enumOptionColor !== undefined) enumData.color = ctx.input.enumOptionColor; + if (ctx.input.enumOptionEnabled !== undefined) { + enumData.enabled = ctx.input.enumOptionEnabled; + } + if (Object.keys(enumData).length === 0) { + throw asanaServiceError('Provide at least one enum option property to update.'); + } + + let enumOption = await client.updateEnumOption(enumOptionId, enumData); + + return { + output: { enumOption }, + message: `Updated enum option **${enumOption.name ?? enumOptionId}**.` + }; + }) + .build(); diff --git a/integrations/asana/src/tools/manage-project-templates.ts b/integrations/asana/src/tools/manage-project-templates.ts new file mode 100644 index 0000000000..608e1dd216 --- /dev/null +++ b/integrations/asana/src/tools/manage-project-templates.ts @@ -0,0 +1,139 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { asanaServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let projectTemplateSchema = z.object({ + projectTemplateId: z.string(), + name: z.string().optional(), + description: z.string().nullable().optional(), + color: z.string().nullable().optional(), + isPublic: z.boolean().optional(), + requestedDates: z.array(z.any()).optional(), + team: z.any().optional(), + workspace: z.any().optional() +}); + +let formatProjectTemplate = (template: any) => ({ + projectTemplateId: template.gid, + name: template.name, + description: template.description, + color: template.color, + isPublic: template.public, + requestedDates: template.requested_dates, + team: template.team, + workspace: template.workspace +}); + +let requireField = (value: T | undefined | null, label: string, action: string): T => { + if (value === undefined || value === null || value === '') { + throw asanaServiceError(`${label} is required for "${action}".`); + } + + return value; +}; + +export let manageProjectTemplates = SlateTool.create(spec, { + name: 'Manage Project Templates', + key: 'manage_project_templates', + description: `List, inspect, and instantiate Asana project templates.`, + instructions: [ + 'Use action "list" with workspaceId or teamId to discover templates.', + 'Use action "get" before instantiating to inspect requestedDates.', + 'Use action "instantiate" with projectTemplateId and name; Asana returns an async job.' + ] +}) + .input( + z.object({ + action: z + .enum(['list', 'get', 'instantiate']) + .describe('Project template operation to perform.'), + workspaceId: z.string().optional().describe('Workspace GID for list action.'), + teamId: z.string().optional().describe('Team GID for list or instantiate action.'), + projectTemplateId: z + .string() + .optional() + .describe('Project template GID for get/instantiate actions.'), + name: z.string().optional().describe('Name for the instantiated project.'), + requestedDates: z + .array( + z.object({ + gid: z.string().describe('Requested date variable GID from the template.'), + value: z.string().describe('Date value in YYYY-MM-DD format.') + }) + ) + .optional() + .describe('Requested date values required by the template.'), + public: z + .boolean() + .optional() + .describe('Whether the instantiated project is public when supported.'), + limit: z.number().optional().describe('Maximum project templates to return.') + }) + ) + .output( + z.object({ + projectTemplates: z.array(projectTemplateSchema).optional(), + projectTemplate: projectTemplateSchema.optional(), + job: z.any().optional(), + jobId: z.string().optional(), + projectTemplateCount: z.number().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + if (ctx.input.action === 'list') { + if (!ctx.input.workspaceId && !ctx.input.teamId) { + throw asanaServiceError('workspaceId or teamId is required for "list".'); + } + + let result = await client.listProjectTemplates({ + workspaceId: ctx.input.workspaceId, + teamId: ctx.input.teamId, + limit: ctx.input.limit + }); + let projectTemplates = (result.data || []).map(formatProjectTemplate); + + return { + output: { projectTemplates, projectTemplateCount: projectTemplates.length }, + message: `Found **${projectTemplates.length}** project template(s).` + }; + } + + if (ctx.input.action === 'get') { + let projectTemplateId = requireField( + ctx.input.projectTemplateId, + 'projectTemplateId', + ctx.input.action + ); + let projectTemplate = formatProjectTemplate( + await client.getProjectTemplate(projectTemplateId) + ); + + return { + output: { projectTemplate, projectTemplateCount: 1 }, + message: `Retrieved project template **${projectTemplate.name ?? projectTemplateId}**.` + }; + } + + let projectTemplateId = requireField( + ctx.input.projectTemplateId, + 'projectTemplateId', + ctx.input.action + ); + let name = requireField(ctx.input.name, 'name', ctx.input.action); + let data: Record = { name }; + if (ctx.input.teamId) data.team = ctx.input.teamId; + if (ctx.input.requestedDates) data.requested_dates = ctx.input.requestedDates; + if (ctx.input.public !== undefined) data.public = ctx.input.public; + + let job = await client.instantiateProjectTemplate(projectTemplateId, data); + + return { + output: { job, jobId: job.gid }, + message: `Started project template instantiation job ${job.gid ?? ''}.` + }; + }) + .build(); diff --git a/integrations/asana/src/tools/manage-sections.ts b/integrations/asana/src/tools/manage-sections.ts index e6d5b940e3..e013b22fb3 100644 --- a/integrations/asana/src/tools/manage-sections.ts +++ b/integrations/asana/src/tools/manage-sections.ts @@ -80,3 +80,63 @@ export let createSection = SlateTool.create(spec, { }; }) .build(); + +export let updateSection = SlateTool.create(spec, { + name: 'Update Section', + key: 'update_section', + description: `Rename an existing project section.` +}) + .input( + z.object({ + sectionId: z.string().describe('Section GID to update'), + name: z.string().describe('New section name') + }) + ) + .output( + z.object({ + sectionId: z.string(), + name: z.string() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let section = await client.updateSection(ctx.input.sectionId, ctx.input.name); + + return { + output: { + sectionId: section.gid, + name: section.name + }, + message: `Updated section **${section.name}** (${section.gid}).` + }; + }) + .build(); + +export let deleteSection = SlateTool.create(spec, { + name: 'Delete Section', + key: 'delete_section', + description: `Delete an empty project section. Asana does not allow deleting the last remaining section.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + sectionId: z.string().describe('Section GID to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + await client.deleteSection(ctx.input.sectionId); + + return { + output: { deleted: true }, + message: `Deleted section ${ctx.input.sectionId}.` + }; + }) + .build(); diff --git a/integrations/asana/src/tools/typeahead-search.ts b/integrations/asana/src/tools/typeahead-search.ts new file mode 100644 index 0000000000..40df832e05 --- /dev/null +++ b/integrations/asana/src/tools/typeahead-search.ts @@ -0,0 +1,67 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let typeaheadSearch = SlateTool.create(spec, { + name: 'Typeahead Search', + key: 'typeahead_search', + description: `Search Asana workspace objects with the low-latency typeahead endpoint. Use this to discover project, project template, portfolio, tag, task, user, or custom field GIDs for other tools.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + workspaceId: z.string().describe('Workspace GID to search in.'), + resourceType: z + .enum([ + 'custom_field', + 'project', + 'project_template', + 'portfolio', + 'tag', + 'task', + 'user' + ]) + .describe('Type of Asana resource to search for.'), + query: z + .string() + .optional() + .describe('Search text. Omit or pass an empty string for relevant defaults.'), + count: z.number().optional().describe('Maximum number of results to return.') + }) + ) + .output( + z.object({ + results: z.array( + z.object({ + id: z.string(), + name: z.string().optional(), + resourceType: z.string().optional() + }) + ), + resultCount: z.number() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.typeahead( + ctx.input.workspaceId, + ctx.input.resourceType, + ctx.input.query ?? '', + ctx.input.count + ); + + let results = (result.data || []).map((item: any) => ({ + id: item.gid, + name: item.name, + resourceType: item.resource_type + })); + + return { + output: { results, resultCount: results.length }, + message: `Found **${results.length}** typeahead result(s).` + }; + }) + .build(); diff --git a/integrations/asana/src/tools/update-task.ts b/integrations/asana/src/tools/update-task.ts index 86a17cbc53..5b09e7690c 100644 --- a/integrations/asana/src/tools/update-task.ts +++ b/integrations/asana/src/tools/update-task.ts @@ -76,6 +76,10 @@ export let updateTask = SlateTool.create(spec, { .array(z.string()) .optional() .describe('Task GIDs that depend on this task'), + removeDependentIds: z + .array(z.string()) + .optional() + .describe('Dependent task GIDs to remove'), parentId: z .string() .nullable() @@ -149,6 +153,9 @@ export let updateTask = SlateTool.create(spec, { if (ctx.input.addDependentIds?.length) { operations.push(client.addDependentsToTask(taskId, ctx.input.addDependentIds)); } + if (ctx.input.removeDependentIds?.length) { + operations.push(client.removeDependentsFromTask(taskId, ctx.input.removeDependentIds)); + } if (ctx.input.parentId !== undefined) { operations.push(client.setParentForTask(taskId, ctx.input.parentId)); } diff --git a/integrations/asana/src/triggers/task-changes-webhook.ts b/integrations/asana/src/triggers/task-changes-webhook.ts index 4667fe3790..1f9a8910ca 100644 --- a/integrations/asana/src/triggers/task-changes-webhook.ts +++ b/integrations/asana/src/triggers/task-changes-webhook.ts @@ -2,6 +2,7 @@ import { createHmac, timingSafeEqual } from 'crypto'; import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { asanaServiceError } from '../lib/errors'; import { spec } from '../spec'; function verifyAsanaSignature( @@ -49,7 +50,7 @@ export let taskChangesWebhook = SlateTrigger.create(spec, { .webhook({ autoRegisterWebhook: async ctx => { if (!ctx.config.webhookProjectId) { - throw new Error( + throw asanaServiceError( 'config.webhookProjectId is required to auto-register Asana webhooks (project GID that will receive task events).' ); } diff --git a/integrations/asana/vitest.config.ts b/integrations/asana/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/asana/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/assembly-ai/README.md b/integrations/assembly-ai/README.md index 90bf04bd3c..1335a8fdaa 100644 --- a/integrations/assembly-ai/README.md +++ b/integrations/assembly-ai/README.md @@ -1,9 +1,17 @@ # Assembly Ai -Transcribe pre-recorded and live audio/video to text with support for 99+ languages, speaker diarization, and multichannel audio. Apply audio intelligence models to extract summaries, sentiment analysis, entity detection, topic detection, key phrases, and content moderation from transcripts. Redact personally identifiable information from text and audio. Generate SRT/VTT subtitles and segment transcripts into paragraphs, sentences, or auto-chapters. Stream real-time speech-to-text via WebSocket connections. Upload audio/video files for processing. Manage and delete transcripts. Access an LLM gateway to apply large language models (Claude, GPT, Gemini) to transcribed speech data for summarization, Q&A, and custom analysis. Translate transcripts across 99+ languages. Receive webhook notifications when transcriptions complete or fail. +Transcribe pre-recorded and live audio/video to text with support for 99+ languages, speaker diarization, code switching, and multichannel audio. Apply audio intelligence models to extract sentiment analysis, entity detection, topic detection, key phrases, and content moderation from transcripts. Redact personally identifiable information from text and audio. Generate SRT/VTT subtitles and segment transcripts into paragraphs or sentences. Upload audio/video files for processing. Manage and delete transcripts. Use AssemblyAI's LLM Gateway for transcript-aware summarization, Q&A, and custom analysis, and Speech Understanding for transcript translation, speaker identification, and custom formatting. Stream real-time speech-to-text via WebSocket connections with temporary tokens. Receive webhook notifications when transcriptions complete or fail. ## Tools +### Create Chat Completion + +Create a completion with AssemblyAI's LLM Gateway. Use prompt for a simple request, or messages for a conversation. Provide transcriptId to inject an AssemblyAI transcript into the first {{ transcript }} tag in the prompt. + +### Create Speech Understanding + +Apply AssemblyAI Speech Understanding to a completed transcript. Supports translation, advanced speaker identification, and custom formatting in one request. + ### Create Streaming Token Generate a temporary authentication token for use with AssemblyAI's real-time streaming speech-to-text WebSocket API. Use this to securely authenticate client-side streaming without exposing your main API key. Each token is single-use and valid for one streaming session. @@ -14,7 +22,7 @@ Delete a transcript by removing its data and marking it as deleted. The transcri ### Get Redacted Audio -Retrieve the URL for a PII-redacted audio file. The original transcription must have been submitted with PII audio redaction enabled (\ +Retrieve the URL for a PII-redacted audio file. The original transcription must have been submitted with PII audio redaction enabled. ### Get Subtitles @@ -28,10 +36,6 @@ Retrieve a completed transcript's text segmented into sentences or paragraphs. T Retrieve a transcript by its ID. Returns the full transcript object including text, words with timestamps, speaker labels, and any enabled audio intelligence results (summary, sentiment, entities, topics, chapters, content safety, key phrases). Use this to poll for completion after submitting a transcription, or to retrieve results of a completed transcript. -### LeMUR Task - -Apply a large language model to one or more transcripts using AssemblyAI's LeMUR framework. Submit a custom prompt along with transcript IDs or raw text input, and receive an LLM-generated response. Use this for summarizing transcripts, extracting insights, answering questions about audio content, generating action items, or any custom analysis task. Supports multiple LLM providers including Claude, GPT, and Gemini models. - ### List Transcripts List transcripts with pagination and optional filters. Returns transcript summaries sorted from newest to oldest. Supports filtering by status and creation date, and cursor-based pagination using before/after IDs. @@ -42,7 +46,11 @@ Search through a completed transcript for specific keywords. You can search for ### Submit Transcription -Submit an audio or video file for asynchronous transcription. Provide a publicly accessible URL to the media file. Optionally enable audio intelligence features like summarization, sentiment analysis, entity detection, topic detection, content moderation, key phrases, auto chapters, and PII redaction. Returns the transcript object with a status of "queued" — poll using the **Get Transcript** tool to check for completion. +Submit an audio or video file for asynchronous transcription. Provide a publicly accessible media URL or an AssemblyAI upload URL from Upload Media File. Optionally enable current transcription features like code switching, speech model priority routing, keyterms prompting, sentiment analysis, entity detection, topic detection, content moderation, and PII redaction. Returns the transcript object with a status of "queued" — poll using the **Get Transcript** tool to check for completion. + +### Upload Media File + +Upload local audio or video bytes to AssemblyAI and receive an AssemblyAI-only upload URL. Use the returned uploadUrl as audioUrl in Submit Transcription when the media is not already publicly accessible. ## License diff --git a/integrations/assembly-ai/docs/SPEC.md b/integrations/assembly-ai/docs/SPEC.md index 164317c170..86eb8d1038 100644 --- a/integrations/assembly-ai/docs/SPEC.md +++ b/integrations/assembly-ai/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -AssemblyAI is a Speech AI platform that provides APIs for speech-to-text transcription (both async and real-time streaming), audio intelligence models (summarization, sentiment analysis, entity detection, etc.), and an LLM Gateway for applying large language models to transcribed speech data. It offers models for converting audio files, video files, and live speech into text, an LLM Gateway framework for applying LLMs to spoken data, and models for interpreting audio for business and personal workflows. +AssemblyAI is a Speech AI platform that provides APIs for speech-to-text transcription (both async and real-time streaming), audio intelligence models (sentiment analysis, entity detection, topic detection, key phrases, content moderation, PII redaction, etc.), Speech Understanding, and an LLM Gateway for applying large language models to transcribed speech data. It offers models for converting audio files, video files, and live speech into text, current LLM Gateway endpoints for transcript-aware reasoning, and models for interpreting audio for business and personal workflows. ## Authentication @@ -36,8 +36,8 @@ Transcribe pre-recorded audio and video files by submitting a URL or uploading t - **Speaker diarization**: Identifies and labels different speakers within audio. - **Advanced Speaker Identification**: Maps speaker clusters to real names or roles via the Speech Understanding API, using audio context and optional known values you provide. - **Multichannel transcription**: Transcribe each audio channel separately. -- **Custom vocabulary / word boost**: Provide a list of specific words or terms to improve recognition accuracy. -- **Speech model selection**: Choose between different models (e.g., Universal, Slam-1) depending on accuracy and language needs. +- **Keyterms prompting**: Provide a list of domain-specific words or phrases to improve recognition accuracy. +- **Speech model selection**: Provide a priority list of speech models (for example Universal-3 Pro and Universal-2) depending on accuracy and language needs. - **Subtitle/caption generation**: Get transcripts formatted as SRT or VTT subtitles. - **Paragraph and sentence segmentation**: Retrieve transcripts split into semantically meaningful paragraphs or sentences. @@ -52,14 +52,12 @@ Transcribe live audio streams in real-time via WebSocket connections. Provides p ### Audio Intelligence -A suite of models that run alongside transcription to extract insights, including: summarization, content moderation, sentiment analysis, entity detection, topic detection, auto chapters, key phrases, and PII redaction. +A suite of models that run alongside transcription to extract insights, including: content moderation, sentiment analysis, entity detection, topic detection, key phrases, and PII redaction. Flexible summaries and chapters should be generated through LLM Gateway with transcript injection rather than deprecated transcript-time summary parameters. -- **Summarization**: Choose summary types (bullets, bullets_verbose, gist, headline, paragraph) and models (informative, conversational, catchy). - **Sentiment Analysis**: Detects positive, negative, and neutral sentiments in speech segments. - **Entity Detection**: Supports 44 entity types to automatically identify and categorize key information in transcripts with timestamps. - **Topic Detection**: Predicts topics spoken in audio using the standardized IAB Taxonomy. - **Content Moderation**: Detects sensitive content with confidence and severity scores. -- **Auto Chapters**: Automatically segments transcripts into chapters with summaries. - **Key Phrases**: Extracts key phrases from the transcript. - **PII Redaction**: Automatically identifies and removes personally identifiable information from transcripts. Supports text redaction (hash or entity name substitution) and audio redaction (beeping out sensitive data). Configurable PII policies (names, credit cards, SSNs, etc.). Available in multiple languages. @@ -67,14 +65,14 @@ A suite of models that run alongside transcription to extract insights, includin A unified interface that allows you to connect with multiple LLM providers including Claude, GPT, and Gemini to build sophisticated AI applications through a single API. -- Provides access to 15+ models with support for basic chat completions, multi-turn conversations, tool/function calling, and agentic workflows. -- Can be used standalone or combined with transcripts to apply LLMs to spoken data (summarization, Q&A, custom analysis). +- Provides access to multiple models with support for chat completions, multi-turn conversations, tool/function calling, and agentic workflows. +- Can be used standalone or combined with transcripts by injecting transcript text into prompts for summarization, Q&A, custom analysis, and chapter generation. - Unified billing and usage tracking across all providers. -- LLM Gateway is not currently supported in the EU. +- Uses the `https://llm-gateway.assemblyai.com` base URL in the default region and `https://llm-gateway.eu.assemblyai.com` for EU data residency. ### Speech Understanding -Pre-built, LLM-powered features that transform raw transcripts into structured, actionable data. Includes translation (99+ languages), advanced speaker identification, text normalization (dates, phone numbers, emails), and customizable summarization. +Pre-built, LLM-powered features that transform completed transcripts into structured, actionable data. Includes translation, advanced speaker identification, and custom formatting for dates, phone numbers, and emails. ### File Upload diff --git a/integrations/assembly-ai/package.json b/integrations/assembly-ai/package.json index 65cd1b96e4..d8c7b3dd27 100644 --- a/integrations/assembly-ai/package.json +++ b/integrations/assembly-ai/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/assembly-ai/slate.json b/integrations/assembly-ai/slate.json index d905b9b3ed..1480438b0c 100644 --- a/integrations/assembly-ai/slate.json +++ b/integrations/assembly-ai/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/assemblyai", - "description": "Transcribe pre-recorded and live audio/video to text with support for 99+ languages, speaker diarization, and multichannel audio. Apply audio intelligence models to extract summaries, sentiment analysis, entity detection, topic detection, key phrases, and content moderation from transcripts. Redact personally identifiable information from text and audio. Generate SRT/VTT subtitles and segment transcripts into paragraphs, sentences, or auto-chapters. Stream real-time speech-to-text via WebSocket connections. Upload audio/video files for processing. Manage and delete transcripts. Access an LLM gateway to apply large language models (Claude, GPT, Gemini) to transcribed speech data for summarization, Q&A, and custom analysis. Translate transcripts across 99+ languages. Receive webhook notifications when transcriptions complete or fail.", + "description": "Transcribe pre-recorded and live audio/video to text with support for 99+ languages, code switching, speaker diarization, and multichannel audio. Apply audio intelligence models to extract sentiment analysis, entity detection, topic detection, key phrases, and content moderation from transcripts. Redact personally identifiable information from text and audio. Generate SRT/VTT subtitles and segment transcripts into paragraphs or sentences. Upload audio/video files for processing. Manage and delete transcripts. Use AssemblyAI's LLM Gateway for transcript-aware summarization, Q&A, and custom analysis, and Speech Understanding for transcript translation, speaker identification, and custom formatting. Stream real-time speech-to-text via WebSocket connections with temporary tokens. Receive webhook notifications when transcriptions complete or fail.", "categories": [ "apis-and-http-requests", "language-translation", diff --git a/integrations/assembly-ai/src/index.ts b/integrations/assembly-ai/src/index.ts index 14268a5d05..9cdb8ad2a7 100644 --- a/integrations/assembly-ai/src/index.ts +++ b/integrations/assembly-ai/src/index.ts @@ -1,22 +1,25 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + createChatCompletion, + createSpeechUnderstanding, createStreamingToken, deleteTranscript, getRedactedAudio, getSubtitles, getTranscript, getTranscriptText, - lemurTask, listTranscripts, searchTranscript, - submitTranscription + submitTranscription, + uploadMediaFile } from './tools'; import { inboundWebhook, transcriptionCompleted } from './triggers'; export let provider = Slate.create({ spec, tools: [ + uploadMediaFile, submitTranscription, getTranscript, listTranscripts, @@ -25,7 +28,8 @@ export let provider = Slate.create({ getSubtitles, searchTranscript, getRedactedAudio, - lemurTask, + createChatCompletion, + createSpeechUnderstanding, createStreamingToken ], triggers: [inboundWebhook, transcriptionCompleted] diff --git a/integrations/assembly-ai/src/lib/client.ts b/integrations/assembly-ai/src/lib/client.ts index b2f77ab1fc..6ff32640da 100644 --- a/integrations/assembly-ai/src/lib/client.ts +++ b/integrations/assembly-ai/src/lib/client.ts @@ -1,4 +1,6 @@ +import { Buffer } from 'node:buffer'; import { createAxios } from 'slates'; +import { assemblyAiApiError } from './errors'; let getBaseUrl = (region: string) => { if (region === 'eu') { @@ -14,6 +16,13 @@ let getStreamingBaseUrl = (region: string) => { return 'https://streaming.assemblyai.com'; }; +let getLlmGatewayBaseUrl = (region: string) => { + if (region === 'eu') { + return 'https://llm-gateway.eu.assemblyai.com'; + } + return 'https://llm-gateway.assemblyai.com'; +}; + export interface TranscribeParams { audioUrl: string; languageCode?: string; @@ -21,6 +30,7 @@ export interface TranscribeParams { languageDetection?: boolean; languageConfidenceThreshold?: number; speechModel?: string; + speechModels?: string[]; punctuate?: boolean; formatText?: boolean; disfluencies?: boolean; @@ -38,6 +48,7 @@ export interface TranscribeParams { contentSafety?: boolean; contentSafetyConfidence?: number; iabCategories?: boolean; + keytermsPrompt?: string[]; summarization?: boolean; summaryModel?: string; summaryType?: string; @@ -46,11 +57,16 @@ export interface TranscribeParams { redactPiiAudioQuality?: string; redactPiiPolicies?: string[]; redactPiiSub?: string; + redactPiiReturnUnredacted?: boolean; + redactStaticEntities?: Record; customSpelling?: Array<{ from: string[]; to: string }>; audioStartFrom?: number; audioEndAt?: number; speechThreshold?: number; prompt?: string; + temperature?: number; + domain?: string; + removeAudioTags?: string; } export interface ListTranscriptsParams { @@ -62,15 +78,38 @@ export interface ListTranscriptsParams { throttledOnly?: boolean; } -export interface LemurTaskParams { - transcriptIds?: string[]; - inputText?: string; - prompt: string; - finalModel?: string; - maxOutputSize?: number; +export interface ChatCompletionParams { + model: string; + messages?: Array<{ role: string; content: string }>; + prompt?: string; + transcriptId?: string; + modelRegion?: 'global'; + maxTokens?: number; temperature?: number; } +export interface SpeechUnderstandingSpeaker { + name?: string; + role?: string; + description?: string; + company?: string; + title?: string; +} + +export interface SpeechUnderstandingParams { + transcriptId: string; + targetLanguages?: string[]; + formal?: boolean; + matchOriginalUtterance?: boolean; + speakerType?: string; + speakers?: SpeechUnderstandingSpeaker[]; + customFormatting?: { + date?: string; + phoneNumber?: string; + email?: string; + }; +} + export class Client { private token: string; private region: string; @@ -100,6 +139,35 @@ export class Client { }); } + private get llmGatewayAxios() { + return createAxios({ + baseURL: getLlmGatewayBaseUrl(this.region), + headers: { + Authorization: this.token, + 'Content-Type': 'application/json' + } + }); + } + + private async request(operation: string, run: () => Promise<{ data: T }>): Promise { + try { + let response = await run(); + return response.data; + } catch (error) { + throw assemblyAiApiError(error, operation); + } + } + + async uploadMediaFile(contentBase64: string): Promise { + return this.request('upload media file', () => + this.axios.post('/v2/upload', Buffer.from(contentBase64, 'base64'), { + headers: { + 'Content-Type': 'application/octet-stream' + } + }) + ); + } + async submitTranscription(params: TranscribeParams): Promise { let body: Record = { audio_url: params.audioUrl @@ -112,6 +180,7 @@ export class Client { if (params.languageConfidenceThreshold !== undefined) body.language_confidence_threshold = params.languageConfidenceThreshold; if (params.speechModel !== undefined) body.speech_model = params.speechModel; + if (params.speechModels !== undefined) body.speech_models = params.speechModels; if (params.punctuate !== undefined) body.punctuate = params.punctuate; if (params.formatText !== undefined) body.format_text = params.formatText; if (params.disfluencies !== undefined) body.disfluencies = params.disfluencies; @@ -134,6 +203,7 @@ export class Client { if (params.contentSafetyConfidence !== undefined) body.content_safety_confidence = params.contentSafetyConfidence; if (params.iabCategories !== undefined) body.iab_categories = params.iabCategories; + if (params.keytermsPrompt !== undefined) body.keyterms_prompt = params.keytermsPrompt; if (params.summarization !== undefined) body.summarization = params.summarization; if (params.summaryModel !== undefined) body.summary_model = params.summaryModel; if (params.summaryType !== undefined) body.summary_type = params.summaryType; @@ -144,6 +214,10 @@ export class Client { if (params.redactPiiPolicies !== undefined) body.redact_pii_policies = params.redactPiiPolicies; if (params.redactPiiSub !== undefined) body.redact_pii_sub = params.redactPiiSub; + if (params.redactPiiReturnUnredacted !== undefined) + body.redact_pii_return_unredacted = params.redactPiiReturnUnredacted; + if (params.redactStaticEntities !== undefined) + body.redact_static_entities = params.redactStaticEntities; if (params.customSpelling !== undefined) { body.custom_spelling = params.customSpelling.map(s => ({ from: s.from, @@ -154,14 +228,17 @@ export class Client { if (params.audioEndAt !== undefined) body.audio_end_at = params.audioEndAt; if (params.speechThreshold !== undefined) body.speech_threshold = params.speechThreshold; if (params.prompt !== undefined) body.prompt = params.prompt; + if (params.temperature !== undefined) body.temperature = params.temperature; + if (params.domain !== undefined) body.domain = params.domain; + if (params.removeAudioTags !== undefined) body.remove_audio_tags = params.removeAudioTags; - let response = await this.axios.post('/v2/transcript', body); - return response.data; + return this.request('submit transcription', () => this.axios.post('/v2/transcript', body)); } async getTranscript(transcriptId: string): Promise { - let response = await this.axios.get(`/v2/transcript/${transcriptId}`); - return response.data; + return this.request('get transcript', () => + this.axios.get(`/v2/transcript/${transcriptId}`) + ); } async listTranscripts(params?: ListTranscriptsParams): Promise { @@ -173,23 +250,27 @@ export class Client { if (params?.afterId !== undefined) queryParams.after_id = params.afterId; if (params?.throttledOnly !== undefined) queryParams.throttled_only = params.throttledOnly; - let response = await this.axios.get('/v2/transcript', { params: queryParams }); - return response.data; + return this.request('list transcripts', () => + this.axios.get('/v2/transcript', { params: queryParams }) + ); } async deleteTranscript(transcriptId: string): Promise { - let response = await this.axios.delete(`/v2/transcript/${transcriptId}`); - return response.data; + return this.request('delete transcript', () => + this.axios.delete(`/v2/transcript/${transcriptId}`) + ); } async getSentences(transcriptId: string): Promise { - let response = await this.axios.get(`/v2/transcript/${transcriptId}/sentences`); - return response.data; + return this.request('get transcript sentences', () => + this.axios.get(`/v2/transcript/${transcriptId}/sentences`) + ); } async getParagraphs(transcriptId: string): Promise { - let response = await this.axios.get(`/v2/transcript/${transcriptId}/paragraphs`); - return response.data; + return this.request('get transcript paragraphs', () => + this.axios.get(`/v2/transcript/${transcriptId}/paragraphs`) + ); } async getSubtitles( @@ -199,41 +280,25 @@ export class Client { ): Promise { let params: Record = {}; if (charsPerCaption !== undefined) params.chars_per_caption = charsPerCaption; - let response = await this.axios.get(`/v2/transcript/${transcriptId}/${format}`, { - params - }); - return response.data; + return this.request('get subtitles', () => + this.axios.get(`/v2/transcript/${transcriptId}/${format}`, { + params + }) + ); } async wordSearch(transcriptId: string, words: string[]): Promise { - let response = await this.axios.get(`/v2/transcript/${transcriptId}/word-search`, { - params: { words: words.join(',') } - }); - return response.data; + return this.request('word search', () => + this.axios.get(`/v2/transcript/${transcriptId}/word-search`, { + params: { words: words.join(',') } + }) + ); } async getRedactedAudio(transcriptId: string): Promise { - let response = await this.axios.get(`/v2/transcript/${transcriptId}/redacted-audio`); - return response.data; - } - - async lemurTask(params: LemurTaskParams): Promise { - let body: Record = { - prompt: params.prompt - }; - if (params.transcriptIds !== undefined) body.transcript_ids = params.transcriptIds; - if (params.inputText !== undefined) body.input_text = params.inputText; - if (params.finalModel !== undefined) body.final_model = params.finalModel; - if (params.maxOutputSize !== undefined) body.max_output_size = params.maxOutputSize; - if (params.temperature !== undefined) body.temperature = params.temperature; - - let response = await this.axios.post('/lemur/v3/generate/task', body); - return response.data; - } - - async lemurPurge(requestId: string): Promise { - let response = await this.axios.delete(`/lemur/v3/${requestId}`); - return response.data; + return this.request('get redacted audio', () => + this.axios.get(`/v2/transcript/${transcriptId}/redacted-audio`) + ); } async createStreamingToken( @@ -246,7 +311,71 @@ export class Client { if (maxSessionDurationSeconds !== undefined) { params.max_session_duration_seconds = maxSessionDurationSeconds; } - let response = await this.streamingAxios.get('/v3/token', { params }); - return response.data; + return this.request('create streaming token', () => + this.streamingAxios.get('/v3/token', { params }) + ); + } + + async createChatCompletion(params: ChatCompletionParams): Promise { + let body: Record = { + model: params.model + }; + + if (params.messages !== undefined) body.messages = params.messages; + if (params.prompt !== undefined) body.prompt = params.prompt; + if (params.transcriptId !== undefined) body.transcript_id = params.transcriptId; + if (params.modelRegion !== undefined) body.model_region = params.modelRegion; + if (params.maxTokens !== undefined) body.max_tokens = params.maxTokens; + if (params.temperature !== undefined) body.temperature = params.temperature; + + return this.request('create chat completion', () => + this.llmGatewayAxios.post('/v1/chat/completions', body) + ); + } + + async createSpeechUnderstanding(params: SpeechUnderstandingParams): Promise { + let request: Record = {}; + + if (params.targetLanguages !== undefined) { + request.translation = { + target_languages: params.targetLanguages + }; + if (params.formal !== undefined) request.translation.formal = params.formal; + if (params.matchOriginalUtterance !== undefined) { + request.translation.match_original_utterance = params.matchOriginalUtterance; + } + } + + if (params.speakerType !== undefined || params.speakers !== undefined) { + request.speaker_identification = {}; + if (params.speakerType !== undefined) { + request.speaker_identification.speaker_type = params.speakerType; + } + if (params.speakers !== undefined) { + request.speaker_identification.speakers = params.speakers; + } + } + + if (params.customFormatting !== undefined) { + request.custom_formatting = {}; + if (params.customFormatting.date !== undefined) { + request.custom_formatting.date = params.customFormatting.date; + } + if (params.customFormatting.phoneNumber !== undefined) { + request.custom_formatting.phone_number = params.customFormatting.phoneNumber; + } + if (params.customFormatting.email !== undefined) { + request.custom_formatting.email = params.customFormatting.email; + } + } + + return this.request('create speech understanding', () => + this.llmGatewayAxios.post('/v1/understanding', { + transcript_id: params.transcriptId, + speech_understanding: { + request + } + }) + ); } } diff --git a/integrations/assembly-ai/src/lib/errors.ts b/integrations/assembly-ai/src/lib/errors.ts new file mode 100644 index 0000000000..006de1077a --- /dev/null +++ b/integrations/assembly-ai/src/lib/errors.ts @@ -0,0 +1,75 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let extractAssemblyAiMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let messages: string[] = []; + + if (isRecord(data)) { + addMessage(messages, data.error); + addMessage(messages, data.message); + addMessage(messages, data.detail); + + if (isRecord(data.error)) { + addMessage(messages, data.error.message); + addMessage(messages, data.error.type); + } + } else { + addMessage(messages, data); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let assemblyAiServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let assemblyAiApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = assemblyAiServiceError( + `AssemblyAI API ${operation} failed: ${statusLabel}${extractAssemblyAiMessage(error)}` + ); + serviceError.data.reason = 'assembly_ai_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/assembly-ai/src/tools.schema.test.ts b/integrations/assembly-ai/src/tools.schema.test.ts new file mode 100644 index 0000000000..b52199d76f --- /dev/null +++ b/integrations/assembly-ai/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('AssemblyAI tool input schemas', provider.actions); diff --git a/integrations/assembly-ai/src/tools/create-chat-completion.ts b/integrations/assembly-ai/src/tools/create-chat-completion.ts new file mode 100644 index 0000000000..4d8403453c --- /dev/null +++ b/integrations/assembly-ai/src/tools/create-chat-completion.ts @@ -0,0 +1,130 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { assemblyAiServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let messageSchema = z.object({ + role: z + .enum(['system', 'user', 'assistant', 'tool']) + .describe('Message role for the LLM Gateway request.'), + content: z.string().describe('Message content.') +}); + +export let createChatCompletion = SlateTool.create(spec, { + name: 'Create Chat Completion', + key: 'create_chat_completion', + description: `Create a completion with AssemblyAI's LLM Gateway. Use prompt for a simple request, or messages for a conversation. Provide transcriptId to inject an AssemblyAI transcript into the first {{ transcript }} tag in the prompt.`, + instructions: [ + 'Provide either prompt or messages.', + 'To analyze a transcript, set transcriptId and include {{ transcript }} in the prompt.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + model: z + .string() + .describe('LLM Gateway model ID, such as "claude-sonnet-4-5-20250929".'), + prompt: z + .string() + .optional() + .describe('Simple prompt. Use {{ transcript }} to inject transcriptId text.'), + messages: z + .array(messageSchema) + .optional() + .describe('Conversation messages. Alternative to prompt.'), + transcriptId: z + .string() + .optional() + .describe( + 'AssemblyAI transcript ID whose text replaces the first {{ transcript }} tag in prompt.' + ), + modelRegion: z + .enum(['global']) + .optional() + .describe('Route supported models to the provider global endpoint.'), + maxTokens: z + .number() + .int() + .min(1) + .optional() + .describe('Maximum output tokens. Defaults to AssemblyAI LLM Gateway default.'), + temperature: z + .number() + .min(0) + .max(2) + .optional() + .describe('Controls randomness. Lower values are more deterministic.') + }) + ) + .output( + z.object({ + requestId: z.string().describe('AssemblyAI LLM Gateway request ID.'), + content: z.string().optional().nullable().describe('First response message content.'), + choices: z + .array( + z.object({ + role: z.string().optional().nullable(), + content: z.string().optional().nullable(), + finishReason: z.string().optional().nullable() + }) + ) + .describe('Returned completion choices.'), + usage: z + .object({ + inputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalTokens: z.number().optional() + }) + .optional() + .describe('Token usage when returned by the LLM Gateway.') + }) + ) + .handleInvocation(async ctx => { + if (!ctx.input.prompt && !ctx.input.messages?.length) { + throw assemblyAiServiceError('Provide either prompt or messages.'); + } + + if (ctx.input.prompt && ctx.input.messages?.length) { + throw assemblyAiServiceError('Provide either prompt or messages, not both.'); + } + + if (ctx.input.transcriptId && !ctx.input.prompt?.includes('{{ transcript }}')) { + throw assemblyAiServiceError( + 'prompt must include {{ transcript }} when transcriptId is provided.' + ); + } + + let client = new Client({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await client.createChatCompletion(ctx.input); + let choices = (result.choices || []).map((choice: any) => ({ + role: choice.message?.role ?? null, + content: choice.message?.content ?? null, + finishReason: choice.finish_reason ?? null + })); + + return { + output: { + requestId: result.request_id, + content: choices[0]?.content ?? null, + choices, + usage: result.usage + ? { + inputTokens: result.usage.input_tokens, + outputTokens: result.usage.output_tokens, + totalTokens: result.usage.total_tokens + } + : undefined + }, + message: `Created AssemblyAI LLM Gateway completion **${result.request_id}**.` + }; + }) + .build(); diff --git a/integrations/assembly-ai/src/tools/create-speech-understanding.ts b/integrations/assembly-ai/src/tools/create-speech-understanding.ts new file mode 100644 index 0000000000..73869d8cbf --- /dev/null +++ b/integrations/assembly-ai/src/tools/create-speech-understanding.ts @@ -0,0 +1,128 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { assemblyAiServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let speakerSchema = z.object({ + name: z.string().optional().describe('Known speaker name.'), + role: z.string().optional().describe('Known speaker role.'), + description: z.string().optional().describe('Context describing the speaker.'), + company: z.string().optional().describe('Speaker company or organization.'), + title: z.string().optional().describe('Speaker title.') +}); + +let customFormattingSchema = z.object({ + date: z.string().optional().describe('Desired date format, such as "mm/dd/yyyy".'), + phoneNumber: z + .string() + .optional() + .describe('Desired phone number format, such as "(xxx)xxx-xxxx".'), + email: z.string().optional().describe('Desired email format.') +}); + +export let createSpeechUnderstanding = SlateTool.create(spec, { + name: 'Create Speech Understanding', + key: 'create_speech_understanding', + description: `Apply AssemblyAI Speech Understanding to a completed transcript. Supports translation, advanced speaker identification, and custom formatting in one request.`, + instructions: [ + 'The transcript must already be completed.', + 'Provide at least one capability: targetLanguages, speakerType/speakers, or customFormatting.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + transcriptId: z.string().describe('Completed AssemblyAI transcript ID to process.'), + targetLanguages: z + .array(z.string()) + .optional() + .describe('Language codes to translate the transcript into, such as ["es", "de"].'), + formal: z + .boolean() + .optional() + .describe('Use formal translation tone when targetLanguages is provided.'), + matchOriginalUtterance: z + .boolean() + .optional() + .describe( + 'Return translated utterances aligned to original utterances when targetLanguages is provided.' + ), + speakerType: z + .enum(['name', 'role']) + .optional() + .describe('Speaker identification type to map diarized labels to names or roles.'), + speakers: z + .array(speakerSchema) + .optional() + .describe('Known speakers to help speaker identification.'), + customFormatting: customFormattingSchema + .optional() + .describe('Formatting preferences for dates, phone numbers, and emails.') + }) + ) + .output( + z.object({ + transcriptId: z.string().describe('Transcript ID that was processed.'), + speechUnderstanding: z + .any() + .optional() + .describe('Speech Understanding request and response status details.'), + translatedTexts: z + .record(z.string(), z.string()) + .optional() + .describe('Translated text keyed by target language.'), + utterances: z + .array(z.any()) + .optional() + .describe('Utterances returned by Speech Understanding.'), + words: z.array(z.any()).optional().describe('Words returned by Speech Understanding.') + }) + ) + .handleInvocation(async ctx => { + let hasTranslation = Boolean(ctx.input.targetLanguages?.length); + let hasSpeakerIdentification = Boolean( + ctx.input.speakerType || ctx.input.speakers?.length + ); + let hasCustomFormatting = + ctx.input.customFormatting !== undefined && + Object.values(ctx.input.customFormatting).some(value => value !== undefined); + + if (!hasTranslation && !hasSpeakerIdentification && !hasCustomFormatting) { + throw assemblyAiServiceError( + 'Provide targetLanguages, speakerType/speakers, or customFormatting.' + ); + } + + if ((ctx.input.formal || ctx.input.matchOriginalUtterance) && !hasTranslation) { + throw assemblyAiServiceError( + 'formal and matchOriginalUtterance require targetLanguages.' + ); + } + + if (ctx.input.speakers?.length && !ctx.input.speakerType) { + throw assemblyAiServiceError('speakerType is required when speakers are provided.'); + } + + let client = new Client({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await client.createSpeechUnderstanding(ctx.input); + + return { + output: { + transcriptId: ctx.input.transcriptId, + speechUnderstanding: result.speech_understanding, + translatedTexts: result.translated_texts, + utterances: result.utterances, + words: result.words + }, + message: `Created Speech Understanding results for transcript **${ctx.input.transcriptId}**.` + }; + }) + .build(); diff --git a/integrations/assembly-ai/src/tools/get-subtitles.ts b/integrations/assembly-ai/src/tools/get-subtitles.ts index ec7202b010..41fe87e5df 100644 --- a/integrations/assembly-ai/src/tools/get-subtitles.ts +++ b/integrations/assembly-ai/src/tools/get-subtitles.ts @@ -1,4 +1,5 @@ -import { SlateTool } from 'slates'; +import { Buffer } from 'node:buffer'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; @@ -25,8 +26,10 @@ Optionally limit the number of characters per caption line.`, ) .output( z.object({ - subtitleContent: z.string().describe('The subtitle content in the requested format.'), - format: z.string().describe('The subtitle format (srt or vtt).') + format: z.string().describe('The subtitle format (srt or vtt).'), + contentType: z.string().describe('MIME type of the returned subtitle attachment.'), + byteLength: z.number().describe('UTF-8 byte length of the subtitle attachment.'), + attachmentCount: z.number().describe('Number of subtitle attachments returned.') }) ) .handleInvocation(async ctx => { @@ -42,12 +45,16 @@ Optionally limit the number of characters per caption line.`, ); let content = typeof result === 'string' ? result : JSON.stringify(result); + let contentType = ctx.input.subtitleFormat === 'srt' ? 'application/x-subrip' : 'text/vtt'; return { output: { - subtitleContent: content, - format: ctx.input.subtitleFormat + format: ctx.input.subtitleFormat, + contentType, + byteLength: Buffer.byteLength(content, 'utf8'), + attachmentCount: 1 }, + attachments: [createTextAttachment(content, contentType)], message: `Generated **${ctx.input.subtitleFormat.toUpperCase()}** subtitles for transcript **${ctx.input.transcriptId}**.` }; }) diff --git a/integrations/assembly-ai/src/tools/get-transcript-text.ts b/integrations/assembly-ai/src/tools/get-transcript-text.ts index 36cfa39881..07b805d301 100644 --- a/integrations/assembly-ai/src/tools/get-transcript-text.ts +++ b/integrations/assembly-ai/src/tools/get-transcript-text.ts @@ -8,6 +8,7 @@ let segmentSchema = z.object({ start: z.number().describe('Start time in milliseconds.'), end: z.number().describe('End time in milliseconds.'), confidence: z.number().describe('Confidence score.'), + speaker: z.string().optional().nullable().describe('Speaker label if available.'), words: z .array( z.object({ @@ -69,6 +70,7 @@ Choose "sentences" or "paragraphs" segmentation depending on how granular you ne start: s.start, end: s.end, confidence: s.confidence, + speaker: s.speaker ?? null, words: s.words })); diff --git a/integrations/assembly-ai/src/tools/index.ts b/integrations/assembly-ai/src/tools/index.ts index f344967af9..f528facf47 100644 --- a/integrations/assembly-ai/src/tools/index.ts +++ b/integrations/assembly-ai/src/tools/index.ts @@ -1,10 +1,12 @@ +export * from './create-chat-completion'; +export * from './create-speech-understanding'; export * from './create-streaming-token'; export * from './delete-transcript'; export * from './get-redacted-audio'; export * from './get-subtitles'; export * from './get-transcript'; export * from './get-transcript-text'; -export * from './lemur-task'; export * from './list-transcripts'; export * from './search-transcript'; export * from './submit-transcription'; +export * from './upload-media-file'; diff --git a/integrations/assembly-ai/src/tools/lemur-task.ts b/integrations/assembly-ai/src/tools/lemur-task.ts deleted file mode 100644 index 4dd9cb84e8..0000000000 --- a/integrations/assembly-ai/src/tools/lemur-task.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { SlateTool } from 'slates'; -import { z } from 'zod'; -import { Client } from '../lib/client'; -import { spec } from '../spec'; - -export let lemurTask = SlateTool.create(spec, { - name: 'LeMUR Task', - key: 'lemur_task', - description: `Apply a large language model to one or more transcripts using AssemblyAI's LeMUR framework. Submit a custom prompt along with transcript IDs or raw text input, and receive an LLM-generated response. -Use this for summarizing transcripts, extracting insights, answering questions about audio content, generating action items, or any custom analysis task. -Supports multiple LLM providers including Claude, GPT, and Gemini models.`, - instructions: [ - 'Provide either transcriptIds (for transcripts already on AssemblyAI) or inputText (for custom text input), not both.', - 'The prompt should clearly describe what you want the LLM to do with the transcript data.' - ], - constraints: [ - 'Up to 100 transcript IDs or 100 hours of audio can be processed per request.', - 'LeMUR is not supported on the EU endpoint.' - ], - tags: { - destructive: false, - readOnly: true - } -}) - .input( - z.object({ - prompt: z - .string() - .describe('The LLM prompt describing what to do with the transcript data.'), - transcriptIds: z - .array(z.string()) - .optional() - .describe('List of completed transcript IDs to process.'), - inputText: z - .string() - .optional() - .describe('Custom text input to process (alternative to transcriptIds).'), - finalModel: z - .string() - .optional() - .describe( - 'LLM model to use (e.g., "anthropic/claude-3-5-sonnet", "openai/gpt-4o"). Defaults to the platform default.' - ), - maxOutputSize: z.number().optional().describe('Maximum output size in tokens.'), - temperature: z - .number() - .optional() - .describe('Model temperature (higher = more creative, lower = more conservative).') - }) - ) - .output( - z.object({ - requestId: z - .string() - .describe('The LeMUR request ID. Can be used to purge the data later.'), - response: z.string().describe('The LLM-generated response text.'), - usage: z - .object({ - inputTokens: z.number().optional().describe('Number of input tokens used.'), - outputTokens: z.number().optional().describe('Number of output tokens generated.') - }) - .optional() - .describe('Token usage information.') - }) - ) - .handleInvocation(async ctx => { - let client = new Client({ - token: ctx.auth.token, - region: ctx.config.region - }); - - let result = await client.lemurTask({ - prompt: ctx.input.prompt, - transcriptIds: ctx.input.transcriptIds, - inputText: ctx.input.inputText, - finalModel: ctx.input.finalModel, - maxOutputSize: ctx.input.maxOutputSize, - temperature: ctx.input.temperature - }); - - return { - output: { - requestId: result.request_id, - response: result.response, - usage: result.usage - ? { - inputTokens: result.usage.input_tokens, - outputTokens: result.usage.output_tokens - } - : undefined - }, - message: `LeMUR task completed. Request ID: **${result.request_id}**.` - }; - }) - .build(); diff --git a/integrations/assembly-ai/src/tools/search-transcript.ts b/integrations/assembly-ai/src/tools/search-transcript.ts index 2fccbc808e..f3aa087ff1 100644 --- a/integrations/assembly-ai/src/tools/search-transcript.ts +++ b/integrations/assembly-ai/src/tools/search-transcript.ts @@ -3,6 +3,20 @@ import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; +let mapTimestamp = (timestamp: any) => { + if (Array.isArray(timestamp)) { + return { + start: timestamp[0], + end: timestamp[1] + }; + } + + return { + start: timestamp.start, + end: timestamp.end + }; +}; + export let searchTranscript = SlateTool.create(spec, { name: 'Search Transcript', key: 'search_transcript', @@ -18,6 +32,7 @@ Returns match counts and timestamps for each keyword found.`, transcriptId: z.string().describe('The unique transcript ID.'), words: z .array(z.string()) + .min(1) .describe('Keywords or phrases to search for (each up to 5 words).') }) ) @@ -55,10 +70,7 @@ Returns match counts and timestamps for each keyword found.`, let matches = (result.matches || []).map((m: any) => ({ text: m.text, count: m.count, - timestamps: (m.timestamps || []).map((t: any) => ({ - start: t.start, - end: t.end - })), + timestamps: (m.timestamps || []).map(mapTimestamp), indexes: m.indexes || [] })); diff --git a/integrations/assembly-ai/src/tools/submit-transcription.ts b/integrations/assembly-ai/src/tools/submit-transcription.ts index 51cf6f163c..afa114fbb0 100644 --- a/integrations/assembly-ai/src/tools/submit-transcription.ts +++ b/integrations/assembly-ai/src/tools/submit-transcription.ts @@ -1,17 +1,19 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { assemblyAiServiceError } from '../lib/errors'; import { spec } from '../spec'; export let submitTranscription = SlateTool.create(spec, { name: 'Submit Transcription', key: 'submit_transcription', description: `Submit an audio or video file for asynchronous transcription. Provide a publicly accessible URL to the media file. -Optionally enable audio intelligence features like summarization, sentiment analysis, entity detection, topic detection, content moderation, key phrases, auto chapters, and PII redaction. +Optionally enable audio intelligence features like sentiment analysis, entity detection, topic detection, content moderation, key phrases, and PII redaction. Returns the transcript object with a status of "queued" — poll using the **Get Transcript** tool to check for completion.`, instructions: [ 'The audio URL must be a direct link to a media file (not a webpage).', - 'To enable summarization, set both summarization=true and provide summaryType and optionally summaryModel.', + 'Use speechModels for current model selection. speechModel is deprecated by AssemblyAI and only remains for backwards compatibility.', + 'For flexible summaries and chapters, prefer the Create Chat Completion tool with transcriptId over deprecated transcript-time summarization and auto chapters.', 'PII redaction requires redactPii=true and at least one policy in redactPiiPolicies.' ], constraints: [ @@ -30,6 +32,10 @@ Returns the transcript object with a status of "queued" — poll using the **Get .string() .optional() .describe('Language code (e.g., "en_us", "es", "fr"). Defaults to "en_us".'), + languageCodes: z + .array(z.string()) + .optional() + .describe('Language codes for code-switching audio that contains multiple languages.'), languageDetection: z .boolean() .optional() @@ -41,7 +47,13 @@ Returns the transcript object with a status of "queued" — poll using the **Get speechModel: z .string() .optional() - .describe('Speech model to use (e.g., "universal", "slam-1").'), + .describe('Deprecated AssemblyAI speech_model parameter. Prefer speechModels.'), + speechModels: z + .array(z.string()) + .optional() + .describe( + 'Current speech model priority list, such as ["universal-3-pro", "universal-2"].' + ), punctuate: z .boolean() .optional() @@ -80,7 +92,12 @@ Returns the transcript object with a status of "queued" — poll using the **Get .optional() .describe('Custom header value for webhook authentication.'), autoHighlights: z.boolean().optional().describe('Enable key phrase extraction.'), - autoChapters: z.boolean().optional().describe('Enable automatic chapter generation.'), + autoChapters: z + .boolean() + .optional() + .describe( + 'Deprecated AssemblyAI auto_chapters parameter. Prefer Create Chat Completion with transcriptId for flexible chapter summaries.' + ), entityDetection: z .boolean() .optional() @@ -101,15 +118,24 @@ Returns the transcript object with a status of "queued" — poll using the **Get .boolean() .optional() .describe('Enable topic detection using IAB taxonomy.'), - summarization: z.boolean().optional().describe('Enable transcript summarization.'), + keytermsPrompt: z + .array(z.string()) + .optional() + .describe('Domain-specific words or phrases to improve recognition accuracy.'), + summarization: z + .boolean() + .optional() + .describe( + 'Deprecated AssemblyAI summarization parameter. Prefer Create Chat Completion with transcriptId.' + ), summaryModel: z .enum(['informative', 'conversational', 'catchy']) .optional() - .describe('Summarization model to use.'), + .describe('Deprecated summarization model to use.'), summaryType: z .enum(['bullets', 'bullets_verbose', 'gist', 'headline', 'paragraph']) .optional() - .describe('Summary output format.'), + .describe('Deprecated summary output format.'), redactPii: z .boolean() .optional() @@ -132,6 +158,18 @@ Returns the transcript object with a status of "queued" — poll using the **Get .enum(['entity_name', 'hash']) .optional() .describe('How to replace PII in text: "entity_name" or "hash".'), + redactPiiReturnUnredacted: z + .boolean() + .optional() + .describe( + 'Return unredacted text, words, and utterances alongside redacted fields. Requires redactPii=true.' + ), + redactStaticEntities: z + .record(z.string(), z.array(z.string())) + .optional() + .describe( + 'User-defined exact terms to redact, keyed by redaction label. Requires redactPii=true.' + ), audioStartFrom: z .number() .optional() @@ -149,6 +187,22 @@ Returns the transcript object with a status of "queued" — poll using the **Get .optional() .describe( 'Natural language context (up to 1500 words) to guide transcription. Only supported for Universal-3-Pro.' + ), + temperature: z + .number() + .min(0) + .max(1) + .optional() + .describe('Universal-3 Pro transcription randomness.'), + domain: z + .enum(['medical-v1']) + .optional() + .describe('Domain-specific transcription model, currently "medical-v1".'), + removeAudioTags: z + .enum(['all', 'speaker']) + .optional() + .describe( + 'Universal-3 Pro option to remove inline annotations: all annotations or speaker cues only.' ) }) ) @@ -171,6 +225,52 @@ Returns the transcript object with a status of "queued" — poll using the **Get region: ctx.config.region }); + if (ctx.input.languageCode && ctx.input.languageCodes?.length) { + throw assemblyAiServiceError('Provide either languageCode or languageCodes, not both.'); + } + + if (ctx.input.speechModel && ctx.input.speechModels?.length) { + throw assemblyAiServiceError( + 'Provide either deprecated speechModel or current speechModels, not both.' + ); + } + + if ( + (ctx.input.webhookAuthHeaderName && !ctx.input.webhookAuthHeaderValue) || + (!ctx.input.webhookAuthHeaderName && ctx.input.webhookAuthHeaderValue) + ) { + throw assemblyAiServiceError( + 'webhookAuthHeaderName and webhookAuthHeaderValue must be provided together.' + ); + } + + if (ctx.input.summarization && !ctx.input.summaryType) { + throw assemblyAiServiceError( + 'summaryType is required when using deprecated summarization=true.' + ); + } + + if ( + (ctx.input.redactPiiAudio || + ctx.input.redactPiiReturnUnredacted || + ctx.input.redactStaticEntities) && + !ctx.input.redactPii + ) { + throw assemblyAiServiceError( + 'redactPii must be true when using audio redaction, unredacted returns, or static entity redaction.' + ); + } + + if ( + ctx.input.redactPii && + !ctx.input.redactPiiPolicies?.length && + !ctx.input.redactStaticEntities + ) { + throw assemblyAiServiceError( + 'redactPiiPolicies or redactStaticEntities is required when redactPii is true.' + ); + } + let result = await client.submitTranscription(ctx.input); return { diff --git a/integrations/assembly-ai/src/tools/upload-media-file.ts b/integrations/assembly-ai/src/tools/upload-media-file.ts new file mode 100644 index 0000000000..29b37dc9d3 --- /dev/null +++ b/integrations/assembly-ai/src/tools/upload-media-file.ts @@ -0,0 +1,82 @@ +import { Buffer } from 'node:buffer'; +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { assemblyAiServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let decodeBase64 = (value: string) => { + let normalized = value.trim().replace(/\s/g, ''); + let buffer = Buffer.from(normalized, 'base64'); + + if (buffer.length === 0) { + throw assemblyAiServiceError('contentBase64 must contain at least one byte.'); + } + + let canonical = buffer.toString('base64').replace(/=+$/, ''); + let provided = normalized.replace(/=+$/, ''); + if (canonical !== provided) { + throw assemblyAiServiceError('contentBase64 must be valid base64-encoded media.'); + } + + return buffer; +}; + +export let uploadMediaFile = SlateTool.create(spec, { + name: 'Upload Media File', + key: 'upload_media_file', + description: `Upload local audio or video bytes to AssemblyAI and receive an AssemblyAI-only upload URL. Use the returned uploadUrl as audioUrl in Submit Transcription when the media is not already publicly accessible.`, + instructions: [ + 'The upload URL is only accessible to AssemblyAI servers.', + 'Submit the returned uploadUrl with Submit Transcription to process the media.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + contentBase64: z + .string() + .describe('Base64-encoded audio or video file content to upload.'), + filename: z + .string() + .optional() + .describe('Original filename for user-facing metadata only.'), + mimeType: z + .string() + .optional() + .describe('Original MIME type for user-facing metadata only.') + }) + ) + .output( + z.object({ + uploadUrl: z + .string() + .describe('AssemblyAI upload URL to pass as audioUrl to Submit Transcription.'), + filename: z.string().optional().describe('Original filename if provided.'), + mimeType: z.string().optional().describe('Original MIME type if provided.'), + byteLength: z.number().describe('Decoded upload size in bytes.') + }) + ) + .handleInvocation(async ctx => { + let content = decodeBase64(ctx.input.contentBase64); + let client = new Client({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await client.uploadMediaFile(ctx.input.contentBase64); + + return { + output: { + uploadUrl: result.upload_url, + filename: ctx.input.filename, + mimeType: ctx.input.mimeType, + byteLength: content.byteLength + }, + message: `Uploaded media file${ctx.input.filename ? ` **${ctx.input.filename}**` : ''} to AssemblyAI (${content.byteLength} bytes). Use the upload URL with Submit Transcription.` + }; + }) + .build(); diff --git a/integrations/assembly-ai/vitest.config.ts b/integrations/assembly-ai/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/assembly-ai/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/auth0/README.md b/integrations/auth0/README.md index 20a73d2bb1..f31847e468 100644 --- a/integrations/auth0/README.md +++ b/integrations/auth0/README.md @@ -36,6 +36,14 @@ Create, update, delete, or list client grants. Client grants authorize applicati Create, update, delete, or list identity provider connections. Connections define how users authenticate — database, social (Google, Facebook), enterprise (SAML, OIDC), or passwordless (SMS, email). +### Manage Log Streams + +Create, update, delete, get, or list Auth0 log streams for delivering tenant logs to HTTP webhooks and supported event destinations. + +### Manage Organization Member Roles + +List, assign, or remove roles for a user inside a specific Auth0 Organization membership. + ### Manage Organization Members List, add, or remove members from an organization. Members are Auth0 users associated with an organization for multi-tenant B2B scenarios. @@ -44,6 +52,10 @@ List, add, or remove members from an organization. Members are Auth0 users assoc Create, update, delete, or list organizations for multi-tenant B2B scenarios. Organizations group users and can have their own connections, branding, and member roles. +### Manage Role Permissions + +List, assign, or remove permissions granted to an Auth0 role. Permissions reference API resource-server identifiers and scope names. + ### Manage Resource Servers Create, update, delete, or list API resource servers. Resource servers represent APIs protected by Auth0, with defined scopes/permissions and token settings. @@ -52,6 +64,10 @@ Create, update, delete, or list API resource servers. Resource servers represent Create, update, delete, or list roles. Roles define sets of permissions that can be assigned to users for role-based access control (RBAC). +### Manage User Permissions + +List, assign, or remove direct Auth0 permissions for a user. Permissions reference API resource-server identifiers and scope names. + ### Manage User Roles List, assign, or remove roles for a user. Use the action parameter to specify the operation: "list" to get current roles, "assign" to add roles, or "remove" to remove roles. diff --git a/integrations/auth0/docs/SPEC.md b/integrations/auth0/docs/SPEC.md index 360d06d762..15f4f5b851 100644 --- a/integrations/auth0/docs/SPEC.md +++ b/integrations/auth0/docs/SPEC.md @@ -38,11 +38,11 @@ Register and manage applications that use Auth0 for authentication. Configure ap ### Roles and Permissions (RBAC) -Define roles with associated permissions and assign them to users. Permissions are scoped to specific APIs (Resource Servers). Supports role-based access control for fine-grained authorization. +Define roles with associated permissions and assign them to users. Permissions are scoped to specific APIs (Resource Servers). Supports role-based access control for fine-grained authorization. The integration supports role CRUD, user-role assignment, role-permission assignment, and direct user-permission assignment. ### Organizations -Manage multi-tenant B2B scenarios by grouping users into organizations. Configure organization-specific connections, branding, and member roles. Invite users to organizations. +Manage multi-tenant B2B scenarios by grouping users into organizations. Configure organization-specific connections, branding, and member roles. Invite users to organizations. The integration supports organization CRUD, organization member add/remove/list, and organization-scoped member roles. ### Multi-Factor Authentication (MFA) @@ -76,6 +76,10 @@ Customize the look and feel of the Universal Login page, including colors, logos Manage which applications are authorized to request access tokens for specific APIs, along with the permitted scopes. +### Log Streams + +Create, update, delete, retrieve, and list log streams for delivering tenant log events to HTTP webhooks and supported event destinations. + ### Jobs (Bulk Operations) Import or export users in bulk using background jobs. Useful for migration scenarios or generating user data exports. diff --git a/integrations/auth0/package.json b/integrations/auth0/package.json index cc53d51867..677085a348 100644 --- a/integrations/auth0/package.json +++ b/integrations/auth0/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", + "vitest": "^3.1.2", "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/auth0/src/auth.ts b/integrations/auth0/src/auth.ts index 4d4dfc74c2..ae998be4ce 100644 --- a/integrations/auth0/src/auth.ts +++ b/integrations/auth0/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { auth0ApiError, auth0ServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -20,18 +21,30 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { - let domain = ctx.input.domain; + let domain = ctx.input.domain + .trim() + .replace(/^https?:\/\//, '') + .replace(/\/+$/, ''); + + if (!domain) { + throw auth0ServiceError('Auth0 tenant domain is required.'); + } let http = createAxios({ baseURL: `https://${domain}` }); - let response = await http.post('/oauth/token', { - grant_type: 'client_credentials', - client_id: ctx.input.clientId, - client_secret: ctx.input.clientSecret, - audience: `https://${domain}/api/v2/` - }); + let response: any; + try { + response = await http.post('/oauth/token', { + grant_type: 'client_credentials', + client_id: ctx.input.clientId, + client_secret: ctx.input.clientSecret, + audience: `https://${domain}/api/v2/` + }); + } catch (error) { + throw auth0ApiError(error, 'exchange client credentials token'); + } return { output: { diff --git a/integrations/auth0/src/index.ts b/integrations/auth0/src/index.ts index 21dd1c93ef..1af3f6256d 100644 --- a/integrations/auth0/src/index.ts +++ b/integrations/auth0/src/index.ts @@ -9,10 +9,14 @@ import { manageApplicationsTool, manageClientGrantsTool, manageConnectionsTool, + manageLogStreamsTool, + manageOrganizationMemberRolesTool, manageOrganizationMembersTool, manageOrganizationsTool, manageResourceServersTool, + manageRolePermissionsTool, manageRolesTool, + manageUserPermissionsTool, manageUserRolesTool, searchUsersTool, updateUserTool @@ -29,12 +33,16 @@ export let provider = Slate.create({ deleteUserTool, manageUserRolesTool, manageRolesTool, + manageRolePermissionsTool, manageConnectionsTool, manageApplicationsTool, manageOrganizationsTool, manageOrganizationMembersTool, + manageOrganizationMemberRolesTool, getLogsTool, + manageLogStreamsTool, manageResourceServersTool, + manageUserPermissionsTool, manageActionsTool, manageClientGrantsTool ], diff --git a/integrations/auth0/src/lib/client.ts b/integrations/auth0/src/lib/client.ts index 57494bb547..da88154de2 100644 --- a/integrations/auth0/src/lib/client.ts +++ b/integrations/auth0/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { auth0ApiError } from './errors'; export class Auth0Client { private http: ReturnType; @@ -13,6 +14,23 @@ export class Auth0Client { }); } + private async request(operation: string, run: () => Promise<{ data: any }>): Promise { + try { + let response = await run(); + return response.data; + } catch (error) { + throw auth0ApiError(error, operation); + } + } + + private async send(operation: string, run: () => Promise) { + try { + await run(); + } catch (error) { + throw auth0ApiError(error, operation); + } + } + // ─── Users ─── async listUsers(params?: { @@ -26,25 +44,27 @@ export class Auth0Client { includeFields?: boolean; searchEngine?: string; }) { - let response = await this.http.get('/users', { - params: { - q: params?.q, - page: params?.page, - per_page: params?.perPage, - include_totals: params?.includeTotals, - sort: params?.sort, - connection: params?.connection, - fields: params?.fields, - include_fields: params?.includeFields, - search_engine: params?.searchEngine ?? 'v3' - } - }); - return response.data; + return await this.request('list users', () => + this.http.get('/users', { + params: { + q: params?.q, + page: params?.page, + per_page: params?.perPage, + include_totals: params?.includeTotals, + sort: params?.sort, + connection: params?.connection, + fields: params?.fields, + include_fields: params?.includeFields, + search_engine: params?.searchEngine ?? 'v3' + } + }) + ); } async getUser(userId: string) { - let response = await this.http.get(`/users/${encodeURIComponent(userId)}`); - return response.data; + return await this.request('get user', () => + this.http.get(`/users/${encodeURIComponent(userId)}`) + ); } async createUser(data: { @@ -60,20 +80,21 @@ export class Auth0Client { verifyEmail?: boolean; phoneVerified?: boolean; }) { - let response = await this.http.post('/users', { - connection: data.connection, - email: data.email, - password: data.password, - username: data.username, - phone_number: data.phoneNumber, - user_metadata: data.userMetadata, - app_metadata: data.appMetadata, - blocked: data.blocked, - email_verified: data.emailVerified, - verify_email: data.verifyEmail, - phone_verified: data.phoneVerified - }); - return response.data; + return await this.request('create user', () => + this.http.post('/users', { + connection: data.connection, + email: data.email, + password: data.password, + username: data.username, + phone_number: data.phoneNumber, + user_metadata: data.userMetadata, + app_metadata: data.appMetadata, + blocked: data.blocked, + email_verified: data.emailVerified, + verify_email: data.verifyEmail, + phone_verified: data.phoneVerified + }) + ); } async updateUser( @@ -97,81 +118,94 @@ export class Auth0Client { familyName?: string; } ) { - let response = await this.http.patch(`/users/${encodeURIComponent(userId)}`, { - email: data.email, - password: data.password, - username: data.username, - phone_number: data.phoneNumber, - user_metadata: data.userMetadata, - app_metadata: data.appMetadata, - blocked: data.blocked, - email_verified: data.emailVerified, - phone_verified: data.phoneVerified, - connection: data.connection, - client_id: data.clientId, - name: data.name, - nickname: data.nickname, - picture: data.picture, - given_name: data.givenName, - family_name: data.familyName - }); - return response.data; + return await this.request('update user', () => + this.http.patch(`/users/${encodeURIComponent(userId)}`, { + email: data.email, + password: data.password, + username: data.username, + phone_number: data.phoneNumber, + user_metadata: data.userMetadata, + app_metadata: data.appMetadata, + blocked: data.blocked, + email_verified: data.emailVerified, + phone_verified: data.phoneVerified, + connection: data.connection, + client_id: data.clientId, + name: data.name, + nickname: data.nickname, + picture: data.picture, + given_name: data.givenName, + family_name: data.familyName + }) + ); } async deleteUser(userId: string) { - await this.http.delete(`/users/${encodeURIComponent(userId)}`); + await this.send('delete user', () => + this.http.delete(`/users/${encodeURIComponent(userId)}`) + ); } async getUserRoles(userId: string, params?: { page?: number; perPage?: number }) { - let response = await this.http.get(`/users/${encodeURIComponent(userId)}/roles`, { - params: { page: params?.page, per_page: params?.perPage } - }); - return response.data; + return await this.request('get user roles', () => + this.http.get(`/users/${encodeURIComponent(userId)}/roles`, { + params: { page: params?.page, per_page: params?.perPage } + }) + ); } async assignUserRoles(userId: string, roleIds: string[]) { - await this.http.post(`/users/${encodeURIComponent(userId)}/roles`, { - roles: roleIds - }); + await this.send('assign user roles', () => + this.http.post(`/users/${encodeURIComponent(userId)}/roles`, { + roles: roleIds + }) + ); } async removeUserRoles(userId: string, roleIds: string[]) { - await this.http.delete(`/users/${encodeURIComponent(userId)}/roles`, { - data: { roles: roleIds } - }); + await this.send('remove user roles', () => + this.http.delete(`/users/${encodeURIComponent(userId)}/roles`, { + data: { roles: roleIds } + }) + ); } async getUserPermissions(userId: string, params?: { page?: number; perPage?: number }) { - let response = await this.http.get(`/users/${encodeURIComponent(userId)}/permissions`, { - params: { page: params?.page, per_page: params?.perPage } - }); - return response.data; + return await this.request('get user permissions', () => + this.http.get(`/users/${encodeURIComponent(userId)}/permissions`, { + params: { page: params?.page, per_page: params?.perPage } + }) + ); } async assignUserPermissions( userId: string, permissions: Array<{ resourceServerIdentifier: string; permissionName: string }> ) { - await this.http.post(`/users/${encodeURIComponent(userId)}/permissions`, { - permissions: permissions.map(p => ({ - resource_server_identifier: p.resourceServerIdentifier, - permission_name: p.permissionName - })) - }); + await this.send('assign user permissions', () => + this.http.post(`/users/${encodeURIComponent(userId)}/permissions`, { + permissions: permissions.map(p => ({ + resource_server_identifier: p.resourceServerIdentifier, + permission_name: p.permissionName + })) + }) + ); } async removeUserPermissions( userId: string, permissions: Array<{ resourceServerIdentifier: string; permissionName: string }> ) { - await this.http.delete(`/users/${encodeURIComponent(userId)}/permissions`, { - data: { - permissions: permissions.map(p => ({ - resource_server_identifier: p.resourceServerIdentifier, - permission_name: p.permissionName - })) - } - }); + await this.send('remove user permissions', () => + this.http.delete(`/users/${encodeURIComponent(userId)}/permissions`, { + data: { + permissions: permissions.map(p => ({ + resource_server_identifier: p.resourceServerIdentifier, + permission_name: p.permissionName + })) + } + }) + ); } // ─── Roles ─── @@ -182,67 +216,76 @@ export class Auth0Client { includeTotals?: boolean; nameFilter?: string; }) { - let response = await this.http.get('/roles', { - params: { - page: params?.page, - per_page: params?.perPage, - include_totals: params?.includeTotals, - name_filter: params?.nameFilter - } - }); - return response.data; + return await this.request('list roles', () => + this.http.get('/roles', { + params: { + page: params?.page, + per_page: params?.perPage, + include_totals: params?.includeTotals, + name_filter: params?.nameFilter + } + }) + ); } async getRole(roleId: string) { - let response = await this.http.get(`/roles/${encodeURIComponent(roleId)}`); - return response.data; + return await this.request('get role', () => + this.http.get(`/roles/${encodeURIComponent(roleId)}`) + ); } async createRole(data: { name: string; description?: string }) { - let response = await this.http.post('/roles', data); - return response.data; + return await this.request('create role', () => this.http.post('/roles', data)); } async updateRole(roleId: string, data: { name?: string; description?: string }) { - let response = await this.http.patch(`/roles/${encodeURIComponent(roleId)}`, data); - return response.data; + return await this.request('update role', () => + this.http.patch(`/roles/${encodeURIComponent(roleId)}`, data) + ); } async deleteRole(roleId: string) { - await this.http.delete(`/roles/${encodeURIComponent(roleId)}`); + await this.send('delete role', () => + this.http.delete(`/roles/${encodeURIComponent(roleId)}`) + ); } async getRolePermissions(roleId: string, params?: { page?: number; perPage?: number }) { - let response = await this.http.get(`/roles/${encodeURIComponent(roleId)}/permissions`, { - params: { page: params?.page, per_page: params?.perPage } - }); - return response.data; + return await this.request('get role permissions', () => + this.http.get(`/roles/${encodeURIComponent(roleId)}/permissions`, { + params: { page: params?.page, per_page: params?.perPage } + }) + ); } async addRolePermissions( roleId: string, permissions: Array<{ resourceServerIdentifier: string; permissionName: string }> ) { - await this.http.post(`/roles/${encodeURIComponent(roleId)}/permissions`, { - permissions: permissions.map(p => ({ - resource_server_identifier: p.resourceServerIdentifier, - permission_name: p.permissionName - })) - }); + await this.send('add role permissions', () => + this.http.post(`/roles/${encodeURIComponent(roleId)}/permissions`, { + permissions: permissions.map(p => ({ + resource_server_identifier: p.resourceServerIdentifier, + permission_name: p.permissionName + })) + }) + ); } async removeRolePermissions( roleId: string, permissions: Array<{ resourceServerIdentifier: string; permissionName: string }> ) { - await this.http.delete(`/roles/${encodeURIComponent(roleId)}/permissions`, { - data: { - permissions: permissions.map(p => ({ - resource_server_identifier: p.resourceServerIdentifier, - permission_name: p.permissionName - })) - } - }); + await this.send('remove role permissions', () => + this.http.delete(`/roles/${encodeURIComponent(roleId)}/permissions`, { + data: { + permissions: permissions.map(p => ({ + resource_server_identifier: p.resourceServerIdentifier, + permission_name: p.permissionName + })) + } + }) + ); } // ─── Connections ─── @@ -255,22 +298,24 @@ export class Auth0Client { name?: string; fields?: string; }) { - let response = await this.http.get('/connections', { - params: { - page: params?.page, - per_page: params?.perPage, - include_totals: params?.includeTotals, - strategy: params?.strategy, - name: params?.name, - fields: params?.fields - } - }); - return response.data; + return await this.request('list connections', () => + this.http.get('/connections', { + params: { + page: params?.page, + per_page: params?.perPage, + include_totals: params?.includeTotals, + strategy: params?.strategy, + name: params?.name, + fields: params?.fields + } + }) + ); } async getConnection(connectionId: string) { - let response = await this.http.get(`/connections/${encodeURIComponent(connectionId)}`); - return response.data; + return await this.request('get connection', () => + this.http.get(`/connections/${encodeURIComponent(connectionId)}`) + ); } async createConnection(data: { @@ -280,14 +325,15 @@ export class Auth0Client { enabledClients?: string[]; metadata?: Record; }) { - let response = await this.http.post('/connections', { - name: data.name, - strategy: data.strategy, - options: data.options, - enabled_clients: data.enabledClients, - metadata: data.metadata - }); - return response.data; + return await this.request('create connection', () => + this.http.post('/connections', { + name: data.name, + strategy: data.strategy, + options: data.options, + enabled_clients: data.enabledClients, + metadata: data.metadata + }) + ); } async updateConnection( @@ -298,16 +344,19 @@ export class Auth0Client { metadata?: Record; } ) { - let response = await this.http.patch(`/connections/${encodeURIComponent(connectionId)}`, { - options: data.options, - enabled_clients: data.enabledClients, - metadata: data.metadata - }); - return response.data; + return await this.request('update connection', () => + this.http.patch(`/connections/${encodeURIComponent(connectionId)}`, { + options: data.options, + enabled_clients: data.enabledClients, + metadata: data.metadata + }) + ); } async deleteConnection(connectionId: string) { - await this.http.delete(`/connections/${encodeURIComponent(connectionId)}`); + await this.send('delete connection', () => + this.http.delete(`/connections/${encodeURIComponent(connectionId)}`) + ); } // ─── Applications (Clients) ─── @@ -319,21 +368,23 @@ export class Auth0Client { fields?: string; appType?: string; }) { - let response = await this.http.get('/clients', { - params: { - page: params?.page, - per_page: params?.perPage, - include_totals: params?.includeTotals, - fields: params?.fields, - app_type: params?.appType - } - }); - return response.data; + return await this.request('list applications', () => + this.http.get('/clients', { + params: { + page: params?.page, + per_page: params?.perPage, + include_totals: params?.includeTotals, + fields: params?.fields, + app_type: params?.appType + } + }) + ); } async getClient(clientId: string) { - let response = await this.http.get(`/clients/${encodeURIComponent(clientId)}`); - return response.data; + return await this.request('get application', () => + this.http.get(`/clients/${encodeURIComponent(clientId)}`) + ); } async createClient(data: { @@ -346,17 +397,18 @@ export class Auth0Client { allowedLogoutUrls?: string[]; logoUri?: string; }) { - let response = await this.http.post('/clients', { - name: data.name, - app_type: data.appType, - description: data.description, - callbacks: data.callbacks, - allowed_origins: data.allowedOrigins, - web_origins: data.webOrigins, - allowed_logout_urls: data.allowedLogoutUrls, - logo_uri: data.logoUri - }); - return response.data; + return await this.request('create application', () => + this.http.post('/clients', { + name: data.name, + app_type: data.appType, + description: data.description, + callbacks: data.callbacks, + allowed_origins: data.allowedOrigins, + web_origins: data.webOrigins, + allowed_logout_urls: data.allowedLogoutUrls, + logo_uri: data.logoUri + }) + ); } async updateClient( @@ -372,21 +424,24 @@ export class Auth0Client { logoUri?: string; } ) { - let response = await this.http.patch(`/clients/${encodeURIComponent(clientId)}`, { - name: data.name, - app_type: data.appType, - description: data.description, - callbacks: data.callbacks, - allowed_origins: data.allowedOrigins, - web_origins: data.webOrigins, - allowed_logout_urls: data.allowedLogoutUrls, - logo_uri: data.logoUri - }); - return response.data; + return await this.request('update application', () => + this.http.patch(`/clients/${encodeURIComponent(clientId)}`, { + name: data.name, + app_type: data.appType, + description: data.description, + callbacks: data.callbacks, + allowed_origins: data.allowedOrigins, + web_origins: data.webOrigins, + allowed_logout_urls: data.allowedLogoutUrls, + logo_uri: data.logoUri + }) + ); } async deleteClient(clientId: string) { - await this.http.delete(`/clients/${encodeURIComponent(clientId)}`); + await this.send('delete application', () => + this.http.delete(`/clients/${encodeURIComponent(clientId)}`) + ); } // ─── Organizations ─── @@ -396,24 +451,27 @@ export class Auth0Client { perPage?: number; includeTotals?: boolean; }) { - let response = await this.http.get('/organizations', { - params: { - page: params?.page, - per_page: params?.perPage, - include_totals: params?.includeTotals - } - }); - return response.data; + return await this.request('list organizations', () => + this.http.get('/organizations', { + params: { + page: params?.page, + per_page: params?.perPage, + include_totals: params?.includeTotals + } + }) + ); } async getOrganization(organizationId: string) { - let response = await this.http.get(`/organizations/${encodeURIComponent(organizationId)}`); - return response.data; + return await this.request('get organization', () => + this.http.get(`/organizations/${encodeURIComponent(organizationId)}`) + ); } async getOrganizationByName(name: string) { - let response = await this.http.get(`/organizations/name/${encodeURIComponent(name)}`); - return response.data; + return await this.request('get organization by name', () => + this.http.get(`/organizations/name/${encodeURIComponent(name)}`) + ); } async createOrganization(data: { @@ -422,18 +480,19 @@ export class Auth0Client { branding?: { logoUrl?: string; colors?: Record }; metadata?: Record; }) { - let response = await this.http.post('/organizations', { - name: data.name, - display_name: data.displayName, - branding: data.branding - ? { - logo_url: data.branding.logoUrl, - colors: data.branding.colors - } - : undefined, - metadata: data.metadata - }); - return response.data; + return await this.request('create organization', () => + this.http.post('/organizations', { + name: data.name, + display_name: data.displayName, + branding: data.branding + ? { + logo_url: data.branding.logoUrl, + colors: data.branding.colors + } + : undefined, + metadata: data.metadata + }) + ); } async updateOrganization( @@ -445,9 +504,8 @@ export class Auth0Client { metadata?: Record; } ) { - let response = await this.http.patch( - `/organizations/${encodeURIComponent(organizationId)}`, - { + return await this.request('update organization', () => + this.http.patch(`/organizations/${encodeURIComponent(organizationId)}`, { name: data.name, display_name: data.displayName, branding: data.branding @@ -457,38 +515,86 @@ export class Auth0Client { } : undefined, metadata: data.metadata - } + }) ); - return response.data; } async deleteOrganization(organizationId: string) { - await this.http.delete(`/organizations/${encodeURIComponent(organizationId)}`); + await this.send('delete organization', () => + this.http.delete(`/organizations/${encodeURIComponent(organizationId)}`) + ); } async listOrganizationMembers( organizationId: string, params?: { page?: number; perPage?: number } ) { - let response = await this.http.get( - `/organizations/${encodeURIComponent(organizationId)}/members`, - { + return await this.request('list organization members', () => + this.http.get(`/organizations/${encodeURIComponent(organizationId)}/members`, { params: { page: params?.page, per_page: params?.perPage } - } + }) ); - return response.data; } async addOrganizationMembers(organizationId: string, memberIds: string[]) { - await this.http.post(`/organizations/${encodeURIComponent(organizationId)}/members`, { - members: memberIds - }); + await this.send('add organization members', () => + this.http.post(`/organizations/${encodeURIComponent(organizationId)}/members`, { + members: memberIds + }) + ); } async removeOrganizationMembers(organizationId: string, memberIds: string[]) { - await this.http.delete(`/organizations/${encodeURIComponent(organizationId)}/members`, { - data: { members: memberIds } - }); + await this.send('remove organization members', () => + this.http.delete(`/organizations/${encodeURIComponent(organizationId)}/members`, { + data: { members: memberIds } + }) + ); + } + + async getOrganizationMemberRoles( + organizationId: string, + userId: string, + params?: { page?: number; perPage?: number } + ) { + return await this.request('get organization member roles', () => + this.http.get( + `/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(userId)}/roles`, + { + params: { page: params?.page, per_page: params?.perPage } + } + ) + ); + } + + async assignOrganizationMemberRoles( + organizationId: string, + userId: string, + roleIds: string[] + ) { + await this.send('assign organization member roles', () => + this.http.post( + `/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(userId)}/roles`, + { + roles: roleIds + } + ) + ); + } + + async removeOrganizationMemberRoles( + organizationId: string, + userId: string, + roleIds: string[] + ) { + await this.send('remove organization member roles', () => + this.http.delete( + `/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(userId)}/roles`, + { + data: { roles: roleIds } + } + ) + ); } // ─── Logs ─── @@ -504,25 +610,27 @@ export class Auth0Client { from?: string; take?: number; }) { - let response = await this.http.get('/logs', { - params: { - q: params?.q, - page: params?.page, - per_page: params?.perPage, - sort: params?.sort, - fields: params?.fields, - include_fields: params?.includeFields, - include_totals: params?.includeTotals, - from: params?.from, - take: params?.take - } - }); - return response.data; + return await this.request('get logs', () => + this.http.get('/logs', { + params: { + q: params?.q, + page: params?.page, + per_page: params?.perPage, + sort: params?.sort, + fields: params?.fields, + include_fields: params?.includeFields, + include_totals: params?.includeTotals, + from: params?.from, + take: params?.take + } + }) + ); } async getLogEvent(logEventId: string) { - let response = await this.http.get(`/logs/${encodeURIComponent(logEventId)}`); - return response.data; + return await this.request('get log event', () => + this.http.get(`/logs/${encodeURIComponent(logEventId)}`) + ); } // ─── Resource Servers (APIs) ─── @@ -532,21 +640,21 @@ export class Auth0Client { perPage?: number; includeTotals?: boolean; }) { - let response = await this.http.get('/resource-servers', { - params: { - page: params?.page, - per_page: params?.perPage, - include_totals: params?.includeTotals - } - }); - return response.data; + return await this.request('list resource servers', () => + this.http.get('/resource-servers', { + params: { + page: params?.page, + per_page: params?.perPage, + include_totals: params?.includeTotals + } + }) + ); } async getResourceServer(resourceServerId: string) { - let response = await this.http.get( - `/resource-servers/${encodeURIComponent(resourceServerId)}` + return await this.request('get resource server', () => + this.http.get(`/resource-servers/${encodeURIComponent(resourceServerId)}`) ); - return response.data; } async createResourceServer(data: { @@ -558,17 +666,18 @@ export class Auth0Client { tokenDialect?: string; skipConsentForVerifiableFirstPartyClients?: boolean; }) { - let response = await this.http.post('/resource-servers', { - name: data.name, - identifier: data.identifier, - scopes: data.scopes, - signing_alg: data.signingAlg, - token_lifetime: data.tokenLifetime, - token_dialect: data.tokenDialect, - skip_consent_for_verifiable_first_party_clients: - data.skipConsentForVerifiableFirstPartyClients - }); - return response.data; + return await this.request('create resource server', () => + this.http.post('/resource-servers', { + name: data.name, + identifier: data.identifier, + scopes: data.scopes, + signing_alg: data.signingAlg, + token_lifetime: data.tokenLifetime, + token_dialect: data.tokenDialect, + skip_consent_for_verifiable_first_party_clients: + data.skipConsentForVerifiableFirstPartyClients + }) + ); } async updateResourceServer( @@ -582,9 +691,8 @@ export class Auth0Client { skipConsentForVerifiableFirstPartyClients?: boolean; } ) { - let response = await this.http.patch( - `/resource-servers/${encodeURIComponent(resourceServerId)}`, - { + return await this.request('update resource server', () => + this.http.patch(`/resource-servers/${encodeURIComponent(resourceServerId)}`, { name: data.name, scopes: data.scopes, signing_alg: data.signingAlg, @@ -592,13 +700,14 @@ export class Auth0Client { token_dialect: data.tokenDialect, skip_consent_for_verifiable_first_party_clients: data.skipConsentForVerifiableFirstPartyClients - } + }) ); - return response.data; } async deleteResourceServer(resourceServerId: string) { - await this.http.delete(`/resource-servers/${encodeURIComponent(resourceServerId)}`); + await this.send('delete resource server', () => + this.http.delete(`/resource-servers/${encodeURIComponent(resourceServerId)}`) + ); } // ─── Client Grants ─── @@ -609,47 +718,52 @@ export class Auth0Client { audience?: string; clientId?: string; }) { - let response = await this.http.get('/client-grants', { - params: { - page: params?.page, - per_page: params?.perPage, - audience: params?.audience, - client_id: params?.clientId - } - }); - return response.data; + return await this.request('list client grants', () => + this.http.get('/client-grants', { + params: { + page: params?.page, + per_page: params?.perPage, + audience: params?.audience, + client_id: params?.clientId + } + }) + ); } async createClientGrant(data: { clientId: string; audience: string; scope: string[] }) { - let response = await this.http.post('/client-grants', { - client_id: data.clientId, - audience: data.audience, - scope: data.scope - }); - return response.data; + return await this.request('create client grant', () => + this.http.post('/client-grants', { + client_id: data.clientId, + audience: data.audience, + scope: data.scope + }) + ); } async updateClientGrant(grantId: string, data: { scope: string[] }) { - let response = await this.http.patch(`/client-grants/${encodeURIComponent(grantId)}`, { - scope: data.scope - }); - return response.data; + return await this.request('update client grant', () => + this.http.patch(`/client-grants/${encodeURIComponent(grantId)}`, { + scope: data.scope + }) + ); } async deleteClientGrant(grantId: string) { - await this.http.delete(`/client-grants/${encodeURIComponent(grantId)}`); + await this.send('delete client grant', () => + this.http.delete(`/client-grants/${encodeURIComponent(grantId)}`) + ); } // ─── Log Streams ─── async listLogStreams() { - let response = await this.http.get('/log-streams'); - return response.data; + return await this.request('list log streams', () => this.http.get('/log-streams')); } async getLogStream(logStreamId: string) { - let response = await this.http.get(`/log-streams/${encodeURIComponent(logStreamId)}`); - return response.data; + return await this.request('get log stream', () => + this.http.get(`/log-streams/${encodeURIComponent(logStreamId)}`) + ); } async createLogStream(data: { @@ -658,35 +772,57 @@ export class Auth0Client { sink: Record; filters?: Array<{ type: string; name: string }>; }) { - let response = await this.http.post('/log-streams', { - name: data.name, - type: data.type, - sink: data.sink, - filters: data.filters - }); - return response.data; + return await this.request('create log stream', () => + this.http.post('/log-streams', { + name: data.name, + type: data.type, + sink: data.sink, + filters: data.filters + }) + ); + } + + async updateLogStream( + logStreamId: string, + data: { + name?: string; + status?: string; + filters?: Array<{ type: string; name: string }>; + } + ) { + return await this.request('update log stream', () => + this.http.patch(`/log-streams/${encodeURIComponent(logStreamId)}`, { + name: data.name, + status: data.status, + filters: data.filters + }) + ); } async deleteLogStream(logStreamId: string) { - await this.http.delete(`/log-streams/${encodeURIComponent(logStreamId)}`); + await this.send('delete log stream', () => + this.http.delete(`/log-streams/${encodeURIComponent(logStreamId)}`) + ); } // ─── Actions ─── async listActions(params?: { triggerId?: string; deployed?: boolean; installed?: boolean }) { - let response = await this.http.get('/actions/actions', { - params: { - triggerId: params?.triggerId, - deployed: params?.deployed, - installed: params?.installed - } - }); - return response.data; + return await this.request('list actions', () => + this.http.get('/actions/actions', { + params: { + triggerId: params?.triggerId, + deployed: params?.deployed, + installed: params?.installed + } + }) + ); } async getAction(actionId: string) { - let response = await this.http.get(`/actions/actions/${encodeURIComponent(actionId)}`); - return response.data; + return await this.request('get action', () => + this.http.get(`/actions/actions/${encodeURIComponent(actionId)}`) + ); } async createAction(data: { @@ -696,14 +832,15 @@ export class Auth0Client { dependencies?: Array<{ name: string; version: string }>; secrets?: Array<{ name: string; value: string }>; }) { - let response = await this.http.post('/actions/actions', { - name: data.name, - supported_triggers: data.supportedTriggers, - code: data.code, - dependencies: data.dependencies, - secrets: data.secrets - }); - return response.data; + return await this.request('create action', () => + this.http.post('/actions/actions', { + name: data.name, + supported_triggers: data.supportedTriggers, + code: data.code, + dependencies: data.dependencies, + secrets: data.secrets + }) + ); } async updateAction( @@ -715,23 +852,25 @@ export class Auth0Client { secrets?: Array<{ name: string; value: string }>; } ) { - let response = await this.http.patch(`/actions/actions/${encodeURIComponent(actionId)}`, { - name: data.name, - code: data.code, - dependencies: data.dependencies, - secrets: data.secrets - }); - return response.data; + return await this.request('update action', () => + this.http.patch(`/actions/actions/${encodeURIComponent(actionId)}`, { + name: data.name, + code: data.code, + dependencies: data.dependencies, + secrets: data.secrets + }) + ); } async deleteAction(actionId: string) { - await this.http.delete(`/actions/actions/${encodeURIComponent(actionId)}`); + await this.send('delete action', () => + this.http.delete(`/actions/actions/${encodeURIComponent(actionId)}`) + ); } async deployAction(actionId: string) { - let response = await this.http.post( - `/actions/actions/${encodeURIComponent(actionId)}/deploy` + return await this.request('deploy action', () => + this.http.post(`/actions/actions/${encodeURIComponent(actionId)}/deploy`) ); - return response.data; } } diff --git a/integrations/auth0/src/lib/errors.ts b/integrations/auth0/src/lib/errors.ts new file mode 100644 index 0000000000..ca0498005f --- /dev/null +++ b/integrations/auth0/src/lib/errors.ts @@ -0,0 +1,104 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractAuth0Message = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + for (let key of ['message', 'error_description', 'error', 'errorCode', 'code']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getAuth0ErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let auth0ServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let auth0ApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getAuth0ErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = auth0ServiceError( + `Auth0 API ${operation} failed: ${statusLabel}${extractAuth0Message(error)}` + ); + serviceError.data.reason = 'auth0_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let requireField = (value: T | undefined | null, label: string, action: string) => { + if (value === undefined || value === null || value === '') { + throw auth0ServiceError(`${label} is required for ${action} action.`); + } + + return value; +}; + +export let requireNonEmptyArray = ( + value: T[] | undefined | null, + label: string, + action: string +) => { + if (!Array.isArray(value) || value.length === 0) { + throw auth0ServiceError(`${label} must contain at least one item for ${action} action.`); + } + + return value; +}; diff --git a/integrations/auth0/src/tools.schema.test.ts b/integrations/auth0/src/tools.schema.test.ts new file mode 100644 index 0000000000..e494e329e7 --- /dev/null +++ b/integrations/auth0/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Auth0 tool input schemas', provider.actions); diff --git a/integrations/auth0/src/tools/index.ts b/integrations/auth0/src/tools/index.ts index 5914af76f5..959131ae11 100644 --- a/integrations/auth0/src/tools/index.ts +++ b/integrations/auth0/src/tools/index.ts @@ -6,10 +6,14 @@ export { manageActionsTool } from './manage-actions'; export { manageApplicationsTool } from './manage-applications'; export { manageClientGrantsTool } from './manage-client-grants'; export { manageConnectionsTool } from './manage-connections'; +export { manageLogStreamsTool } from './manage-log-streams'; +export { manageOrganizationMemberRolesTool } from './manage-organization-member-roles'; export { manageOrganizationMembersTool } from './manage-organization-members'; export { manageOrganizationsTool } from './manage-organizations'; export { manageResourceServersTool } from './manage-resource-servers'; +export { manageRolePermissionsTool } from './manage-role-permissions'; export { manageRolesTool } from './manage-roles'; +export { manageUserPermissionsTool } from './manage-user-permissions'; export { manageUserRolesTool } from './manage-user-roles'; export { searchUsersTool } from './search-users'; export { updateUserTool } from './update-user'; diff --git a/integrations/auth0/src/tools/manage-actions.ts b/integrations/auth0/src/tools/manage-actions.ts index d815de71fd..0c86f05318 100644 --- a/integrations/auth0/src/tools/manage-actions.ts +++ b/integrations/auth0/src/tools/manage-actions.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Auth0Client } from '../lib/client'; +import { auth0ServiceError, requireField } from '../lib/errors'; import { spec } from '../spec'; export let manageActionsTool = SlateTool.create(spec, { @@ -109,8 +110,8 @@ export let manageActionsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.actionId) throw new Error('actionId is required for get action'); - let a = await client.getAction(ctx.input.actionId); + let actionId = requireField(ctx.input.actionId, 'actionId', 'get'); + let a = await client.getAction(actionId); return { output: { actionDetails: mapAction(a) }, message: `Retrieved action **${a.name}** (${a.status}).` @@ -118,15 +119,13 @@ export let manageActionsTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); - if (!ctx.input.triggerId) throw new Error('triggerId is required for create action'); - if (!ctx.input.code) throw new Error('code is required for create action'); + let name = requireField(ctx.input.name, 'name', 'create'); + let triggerId = requireField(ctx.input.triggerId, 'triggerId', 'create'); + let code = requireField(ctx.input.code, 'code', 'create'); let a = await client.createAction({ - name: ctx.input.name, - supportedTriggers: [ - { id: ctx.input.triggerId, version: ctx.input.triggerVersion || 'v3' } - ], - code: ctx.input.code, + name, + supportedTriggers: [{ id: triggerId, version: ctx.input.triggerVersion || 'v3' }], + code, dependencies: ctx.input.dependencies, secrets: ctx.input.secrets }); @@ -137,8 +136,8 @@ export let manageActionsTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.actionId) throw new Error('actionId is required for update action'); - let a = await client.updateAction(ctx.input.actionId, { + let actionId = requireField(ctx.input.actionId, 'actionId', 'update'); + let a = await client.updateAction(actionId, { name: ctx.input.name, code: ctx.input.code, dependencies: ctx.input.dependencies, @@ -151,8 +150,8 @@ export let manageActionsTool = SlateTool.create(spec, { } if (ctx.input.action === 'deploy') { - if (!ctx.input.actionId) throw new Error('actionId is required for deploy action'); - let _a = await client.deployAction(ctx.input.actionId); + let actionId = requireField(ctx.input.actionId, 'actionId', 'deploy'); + let _a = await client.deployAction(actionId); return { output: { deployed: true }, message: `Deployed action successfully.` @@ -160,14 +159,14 @@ export let manageActionsTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.actionId) throw new Error('actionId is required for delete action'); - await client.deleteAction(ctx.input.actionId); + let actionId = requireField(ctx.input.actionId, 'actionId', 'delete'); + await client.deleteAction(actionId); return { output: { deleted: true }, - message: `Deleted action **${ctx.input.actionId}**.` + message: `Deleted action **${actionId}**.` }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw auth0ServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/auth0/src/tools/manage-applications.ts b/integrations/auth0/src/tools/manage-applications.ts index ce9c6a731d..182da6e5be 100644 --- a/integrations/auth0/src/tools/manage-applications.ts +++ b/integrations/auth0/src/tools/manage-applications.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Auth0Client } from '../lib/client'; +import { auth0ServiceError, requireField } from '../lib/errors'; import { spec } from '../spec'; export let manageApplicationsTool = SlateTool.create(spec, { @@ -98,8 +99,8 @@ export let manageApplicationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.clientId) throw new Error('clientId is required for get action'); - let app = await client.getClient(ctx.input.clientId); + let clientId = requireField(ctx.input.clientId, 'clientId', 'get'); + let app = await client.getClient(clientId); return { output: { application: mapApp(app) }, message: `Retrieved application **${app.name}**.` @@ -107,9 +108,9 @@ export let manageApplicationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); + let name = requireField(ctx.input.name, 'name', 'create'); let app = await client.createClient({ - name: ctx.input.name, + name, appType: ctx.input.appType, description: ctx.input.description, callbacks: ctx.input.callbacks, @@ -125,8 +126,8 @@ export let manageApplicationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.clientId) throw new Error('clientId is required for update action'); - let app = await client.updateClient(ctx.input.clientId, { + let clientId = requireField(ctx.input.clientId, 'clientId', 'update'); + let app = await client.updateClient(clientId, { name: ctx.input.name, appType: ctx.input.appType, description: ctx.input.description, @@ -143,14 +144,14 @@ export let manageApplicationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.clientId) throw new Error('clientId is required for delete action'); - await client.deleteClient(ctx.input.clientId); + let clientId = requireField(ctx.input.clientId, 'clientId', 'delete'); + await client.deleteClient(clientId); return { output: { deleted: true }, - message: `Deleted application **${ctx.input.clientId}**.` + message: `Deleted application **${clientId}**.` }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw auth0ServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/auth0/src/tools/manage-client-grants.ts b/integrations/auth0/src/tools/manage-client-grants.ts index 66168d6a62..dc1fe1e063 100644 --- a/integrations/auth0/src/tools/manage-client-grants.ts +++ b/integrations/auth0/src/tools/manage-client-grants.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Auth0Client } from '../lib/client'; +import { auth0ServiceError, requireField, requireNonEmptyArray } from '../lib/errors'; import { spec } from '../spec'; export let manageClientGrantsTool = SlateTool.create(spec, { @@ -86,13 +87,13 @@ export let manageClientGrantsTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.clientId) throw new Error('clientId is required for create action'); - if (!ctx.input.audience) throw new Error('audience is required for create action'); - if (!ctx.input.scope) throw new Error('scope is required for create action'); + let clientId = requireField(ctx.input.clientId, 'clientId', 'create'); + let audience = requireField(ctx.input.audience, 'audience', 'create'); + let scope = requireNonEmptyArray(ctx.input.scope, 'scope', 'create'); let grant = await client.createClientGrant({ - clientId: ctx.input.clientId, - audience: ctx.input.audience, - scope: ctx.input.scope + clientId, + audience, + scope }); return { output: { grant: mapGrant(grant) }, @@ -101,10 +102,10 @@ export let manageClientGrantsTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.grantId) throw new Error('grantId is required for update action'); - if (!ctx.input.scope) throw new Error('scope is required for update action'); - let grant = await client.updateClientGrant(ctx.input.grantId, { - scope: ctx.input.scope + let grantId = requireField(ctx.input.grantId, 'grantId', 'update'); + let scope = requireNonEmptyArray(ctx.input.scope, 'scope', 'update'); + let grant = await client.updateClientGrant(grantId, { + scope }); return { output: { grant: mapGrant(grant) }, @@ -113,14 +114,14 @@ export let manageClientGrantsTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.grantId) throw new Error('grantId is required for delete action'); - await client.deleteClientGrant(ctx.input.grantId); + let grantId = requireField(ctx.input.grantId, 'grantId', 'delete'); + await client.deleteClientGrant(grantId); return { output: { deleted: true }, - message: `Deleted client grant **${ctx.input.grantId}**.` + message: `Deleted client grant **${grantId}**.` }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw auth0ServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/auth0/src/tools/manage-connections.ts b/integrations/auth0/src/tools/manage-connections.ts index cc47561e25..fd7bb394df 100644 --- a/integrations/auth0/src/tools/manage-connections.ts +++ b/integrations/auth0/src/tools/manage-connections.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Auth0Client } from '../lib/client'; +import { auth0ServiceError, requireField } from '../lib/errors'; import { spec } from '../spec'; export let manageConnectionsTool = SlateTool.create(spec, { @@ -95,8 +96,8 @@ export let manageConnectionsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.connectionId) throw new Error('connectionId is required for get action'); - let conn = await client.getConnection(ctx.input.connectionId); + let connectionId = requireField(ctx.input.connectionId, 'connectionId', 'get'); + let conn = await client.getConnection(connectionId); return { output: { connection: mapConn(conn) }, message: `Retrieved connection **${conn.name}** (${conn.strategy}).` @@ -104,11 +105,11 @@ export let manageConnectionsTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); - if (!ctx.input.strategy) throw new Error('strategy is required for create action'); + let name = requireField(ctx.input.name, 'name', 'create'); + let strategy = requireField(ctx.input.strategy, 'strategy', 'create'); let conn = await client.createConnection({ - name: ctx.input.name, - strategy: ctx.input.strategy, + name, + strategy, options: ctx.input.options, enabledClients: ctx.input.enabledClients, metadata: ctx.input.metadata @@ -120,9 +121,8 @@ export let manageConnectionsTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.connectionId) - throw new Error('connectionId is required for update action'); - let conn = await client.updateConnection(ctx.input.connectionId, { + let connectionId = requireField(ctx.input.connectionId, 'connectionId', 'update'); + let conn = await client.updateConnection(connectionId, { options: ctx.input.options, enabledClients: ctx.input.enabledClients, metadata: ctx.input.metadata @@ -134,15 +134,14 @@ export let manageConnectionsTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.connectionId) - throw new Error('connectionId is required for delete action'); - await client.deleteConnection(ctx.input.connectionId); + let connectionId = requireField(ctx.input.connectionId, 'connectionId', 'delete'); + await client.deleteConnection(connectionId); return { output: { deleted: true }, - message: `Deleted connection **${ctx.input.connectionId}**.` + message: `Deleted connection **${connectionId}**.` }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw auth0ServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/auth0/src/tools/manage-log-streams.ts b/integrations/auth0/src/tools/manage-log-streams.ts new file mode 100644 index 0000000000..e0b5a8c5b7 --- /dev/null +++ b/integrations/auth0/src/tools/manage-log-streams.ts @@ -0,0 +1,159 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Auth0Client } from '../lib/client'; +import { auth0ServiceError, requireField } from '../lib/errors'; +import { spec } from '../spec'; + +let filterSchema = z.object({ + type: z.string().describe('Filter type, such as category'), + name: z.string().describe('Filter value/name') +}); + +let mapLogStream = (stream: any) => ({ + logStreamId: stream.id, + name: stream.name, + type: stream.type, + status: stream.status, + sink: stream.sink, + filters: stream.filters +}); + +export let manageLogStreamsTool = SlateTool.create(spec, { + name: 'Manage Log Streams', + key: 'manage_log_streams', + description: + 'Create, update, delete, get, or list Auth0 log streams for delivering tenant logs to HTTP webhooks and supported event destinations.', + instructions: [ + 'For create, provide type and sink. For HTTP streams, sink commonly includes httpEndpoint, httpContentType, httpContentFormat, and optional httpAuthorization.', + 'For update, provide logStreamId plus any of name, status, or filters.' + ], + tags: { + destructive: false + } +}) + .input( + z.object({ + action: z + .enum(['list', 'get', 'create', 'update', 'delete']) + .describe('Action to perform'), + logStreamId: z + .string() + .optional() + .describe('Log stream ID; required for get, update, and delete'), + name: z.string().optional().describe('Log stream name; required for create'), + type: z + .string() + .optional() + .describe( + 'Log stream type, such as http, eventbridge, eventgrid, splunk, sumo, or datadog' + ), + sink: z + .record(z.string(), z.unknown()) + .optional() + .describe('Destination-specific sink configuration; required for create'), + status: z + .enum(['active', 'paused']) + .optional() + .describe('Log stream status for update action'), + filters: z + .array(filterSchema) + .optional() + .describe('Optional log stream filters for create or update') + }) + ) + .output( + z.object({ + logStream: z + .object({ + logStreamId: z.string(), + name: z.string(), + type: z.string(), + status: z.string().optional(), + sink: z.record(z.string(), z.unknown()).optional(), + filters: z.array(filterSchema).optional() + }) + .optional() + .describe('Log stream details'), + logStreams: z + .array( + z.object({ + logStreamId: z.string(), + name: z.string(), + type: z.string(), + status: z.string().optional(), + sink: z.record(z.string(), z.unknown()).optional(), + filters: z.array(filterSchema).optional() + }) + ) + .optional() + .describe('List of log streams'), + deleted: z.boolean().optional().describe('Whether the log stream was deleted') + }) + ) + .handleInvocation(async ctx => { + let client = new Auth0Client({ + token: ctx.auth.token, + domain: ctx.auth.domain + }); + + if (ctx.input.action === 'list') { + let result = await client.listLogStreams(); + let logStreams = (Array.isArray(result) ? result : (result.log_streams ?? [])).map( + mapLogStream + ); + return { + output: { logStreams }, + message: `Found **${logStreams.length}** log stream(s).` + }; + } + + if (ctx.input.action === 'get') { + let logStreamId = requireField(ctx.input.logStreamId, 'logStreamId', 'get'); + let logStream = await client.getLogStream(logStreamId); + return { + output: { logStream: mapLogStream(logStream) }, + message: `Retrieved log stream **${logStream.name}**.` + }; + } + + if (ctx.input.action === 'create') { + let name = requireField(ctx.input.name, 'name', 'create'); + let type = requireField(ctx.input.type, 'type', 'create'); + let sink = requireField(ctx.input.sink, 'sink', 'create'); + let logStream = await client.createLogStream({ + name, + type, + sink, + filters: ctx.input.filters + }); + return { + output: { logStream: mapLogStream(logStream) }, + message: `Created log stream **${logStream.name}**.` + }; + } + + if (ctx.input.action === 'update') { + let logStreamId = requireField(ctx.input.logStreamId, 'logStreamId', 'update'); + let logStream = await client.updateLogStream(logStreamId, { + name: ctx.input.name, + status: ctx.input.status, + filters: ctx.input.filters + }); + return { + output: { logStream: mapLogStream(logStream) }, + message: `Updated log stream **${logStream.name}**.` + }; + } + + if (ctx.input.action === 'delete') { + let logStreamId = requireField(ctx.input.logStreamId, 'logStreamId', 'delete'); + await client.deleteLogStream(logStreamId); + return { + output: { deleted: true }, + message: `Deleted log stream **${logStreamId}**.` + }; + } + + throw auth0ServiceError(`Unknown action: ${ctx.input.action}`); + }) + .build(); diff --git a/integrations/auth0/src/tools/manage-organization-member-roles.ts b/integrations/auth0/src/tools/manage-organization-member-roles.ts new file mode 100644 index 0000000000..2ee6890a0d --- /dev/null +++ b/integrations/auth0/src/tools/manage-organization-member-roles.ts @@ -0,0 +1,101 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Auth0Client } from '../lib/client'; +import { requireNonEmptyArray } from '../lib/errors'; +import { spec } from '../spec'; +import { dispatchAuth0Action } from './shared'; + +let mapRole = (role: any) => ({ + roleId: role.id, + name: role.name, + description: role.description +}); + +export let manageOrganizationMemberRolesTool = SlateTool.create(spec, { + name: 'Manage Organization Member Roles', + key: 'manage_organization_member_roles', + description: + 'List, assign, or remove roles for a user inside a specific Auth0 Organization membership.', + tags: { + destructive: false + } +}) + .input( + z.object({ + organizationId: z.string().describe('The Auth0 organization ID'), + userId: z.string().describe('The Auth0 user ID of the organization member'), + action: z.enum(['list', 'assign', 'remove']).describe('Action to perform'), + roleIds: z + .array(z.string()) + .optional() + .describe('Role IDs to assign or remove; required for assign/remove'), + page: z.number().optional().describe('Page number for list action'), + perPage: z.number().optional().describe('Results per page for list action') + }) + ) + .output( + z.object({ + roles: z + .array( + z.object({ + roleId: z.string(), + name: z.string(), + description: z.string().optional() + }) + ) + .optional() + .describe('Organization member roles for list action'), + success: z.boolean().describe('Whether the operation succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new Auth0Client({ + token: ctx.auth.token, + domain: ctx.auth.domain + }); + + return dispatchAuth0Action(ctx.input.action, { + list: async () => { + let result = await client.getOrganizationMemberRoles( + ctx.input.organizationId, + ctx.input.userId, + { + page: ctx.input.page, + perPage: ctx.input.perPage + } + ); + let roles = (Array.isArray(result) ? result : (result.roles ?? [])).map(mapRole); + return { + output: { roles, success: true }, + message: `Organization member has **${roles.length}** role(s).` + }; + }, + + assign: async () => { + let roleIds = requireNonEmptyArray(ctx.input.roleIds, 'roleIds', 'assign'); + await client.assignOrganizationMemberRoles( + ctx.input.organizationId, + ctx.input.userId, + roleIds + ); + return { + output: { success: true }, + message: `Assigned **${roleIds.length}** organization member role(s).` + }; + }, + + remove: async () => { + let roleIds = requireNonEmptyArray(ctx.input.roleIds, 'roleIds', 'remove'); + await client.removeOrganizationMemberRoles( + ctx.input.organizationId, + ctx.input.userId, + roleIds + ); + return { + output: { success: true }, + message: `Removed **${roleIds.length}** organization member role(s).` + }; + } + }); + }) + .build(); diff --git a/integrations/auth0/src/tools/manage-organization-members.ts b/integrations/auth0/src/tools/manage-organization-members.ts index 4075a14c91..78c64b117d 100644 --- a/integrations/auth0/src/tools/manage-organization-members.ts +++ b/integrations/auth0/src/tools/manage-organization-members.ts @@ -1,7 +1,9 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Auth0Client } from '../lib/client'; +import { requireNonEmptyArray } from '../lib/errors'; import { spec } from '../spec'; +import { dispatchAuth0Action } from './shared'; export let manageOrganizationMembersTool = SlateTool.create(spec, { name: 'Manage Organization Members', @@ -45,44 +47,43 @@ export let manageOrganizationMembersTool = SlateTool.create(spec, { domain: ctx.auth.domain }); - if (ctx.input.action === 'list') { - let result = await client.listOrganizationMembers(ctx.input.organizationId, { - page: ctx.input.page, - perPage: ctx.input.perPage - }); - let members = (Array.isArray(result) ? result : (result.members ?? [])).map( - (m: any) => ({ - userId: m.user_id, - email: m.email, - name: m.name, - picture: m.picture - }) - ); - return { - output: { members, success: true }, - message: `Organization has **${members.length}** member(s).` - }; - } - - if (ctx.input.action === 'add') { - if (!ctx.input.userIds?.length) throw new Error('userIds are required for add action'); - await client.addOrganizationMembers(ctx.input.organizationId, ctx.input.userIds); - return { - output: { success: true }, - message: `Added **${ctx.input.userIds.length}** member(s) to organization.` - }; - } + return dispatchAuth0Action(ctx.input.action, { + list: async () => { + let result = await client.listOrganizationMembers(ctx.input.organizationId, { + page: ctx.input.page, + perPage: ctx.input.perPage + }); + let members = (Array.isArray(result) ? result : (result.members ?? [])).map( + (m: any) => ({ + userId: m.user_id, + email: m.email, + name: m.name, + picture: m.picture + }) + ); + return { + output: { members, success: true }, + message: `Organization has **${members.length}** member(s).` + }; + }, - if (ctx.input.action === 'remove') { - if (!ctx.input.userIds?.length) - throw new Error('userIds are required for remove action'); - await client.removeOrganizationMembers(ctx.input.organizationId, ctx.input.userIds); - return { - output: { success: true }, - message: `Removed **${ctx.input.userIds.length}** member(s) from organization.` - }; - } + add: async () => { + let userIds = requireNonEmptyArray(ctx.input.userIds, 'userIds', 'add'); + await client.addOrganizationMembers(ctx.input.organizationId, userIds); + return { + output: { success: true }, + message: `Added **${userIds.length}** member(s) to organization.` + }; + }, - throw new Error(`Unknown action: ${ctx.input.action}`); + remove: async () => { + let userIds = requireNonEmptyArray(ctx.input.userIds, 'userIds', 'remove'); + await client.removeOrganizationMembers(ctx.input.organizationId, userIds); + return { + output: { success: true }, + message: `Removed **${userIds.length}** member(s) from organization.` + }; + } + }); }) .build(); diff --git a/integrations/auth0/src/tools/manage-organizations.ts b/integrations/auth0/src/tools/manage-organizations.ts index 7921849a36..aef59cc533 100644 --- a/integrations/auth0/src/tools/manage-organizations.ts +++ b/integrations/auth0/src/tools/manage-organizations.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Auth0Client } from '../lib/client'; +import { auth0ServiceError, requireField } from '../lib/errors'; import { spec } from '../spec'; export let manageOrganizationsTool = SlateTool.create(spec, { @@ -83,9 +84,8 @@ export let manageOrganizationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.organizationId) - throw new Error('organizationId is required for get action'); - let org = await client.getOrganization(ctx.input.organizationId); + let organizationId = requireField(ctx.input.organizationId, 'organizationId', 'get'); + let org = await client.getOrganization(organizationId); return { output: { organization: mapOrg(org) }, message: `Retrieved organization **${org.display_name || org.name}**.` @@ -93,7 +93,7 @@ export let manageOrganizationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); + let name = requireField(ctx.input.name, 'name', 'create'); let branding = ctx.input.logoUrl || ctx.input.primaryColor || ctx.input.pageBackgroundColor ? { @@ -107,7 +107,7 @@ export let manageOrganizationsTool = SlateTool.create(spec, { } : undefined; let org = await client.createOrganization({ - name: ctx.input.name, + name, displayName: ctx.input.displayName, branding, metadata: ctx.input.metadata @@ -119,8 +119,7 @@ export let manageOrganizationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.organizationId) - throw new Error('organizationId is required for update action'); + let organizationId = requireField(ctx.input.organizationId, 'organizationId', 'update'); let branding = ctx.input.logoUrl || ctx.input.primaryColor || ctx.input.pageBackgroundColor ? { @@ -133,7 +132,7 @@ export let manageOrganizationsTool = SlateTool.create(spec, { } } : undefined; - let org = await client.updateOrganization(ctx.input.organizationId, { + let org = await client.updateOrganization(organizationId, { name: ctx.input.name, displayName: ctx.input.displayName, branding, @@ -146,15 +145,14 @@ export let manageOrganizationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.organizationId) - throw new Error('organizationId is required for delete action'); - await client.deleteOrganization(ctx.input.organizationId); + let organizationId = requireField(ctx.input.organizationId, 'organizationId', 'delete'); + await client.deleteOrganization(organizationId); return { output: { deleted: true }, - message: `Deleted organization **${ctx.input.organizationId}**.` + message: `Deleted organization **${organizationId}**.` }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw auth0ServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/auth0/src/tools/manage-resource-servers.ts b/integrations/auth0/src/tools/manage-resource-servers.ts index 31522fb116..321901e528 100644 --- a/integrations/auth0/src/tools/manage-resource-servers.ts +++ b/integrations/auth0/src/tools/manage-resource-servers.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Auth0Client } from '../lib/client'; +import { auth0ServiceError, requireField } from '../lib/errors'; import { spec } from '../spec'; export let manageResourceServersTool = SlateTool.create(spec, { @@ -107,9 +108,12 @@ export let manageResourceServersTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.resourceServerId) - throw new Error('resourceServerId is required for get action'); - let rs = await client.getResourceServer(ctx.input.resourceServerId); + let resourceServerId = requireField( + ctx.input.resourceServerId, + 'resourceServerId', + 'get' + ); + let rs = await client.getResourceServer(resourceServerId); return { output: { resourceServer: mapRS(rs) }, message: `Retrieved resource server **${rs.name}**.` @@ -117,11 +121,11 @@ export let manageResourceServersTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); - if (!ctx.input.identifier) throw new Error('identifier is required for create action'); + let name = requireField(ctx.input.name, 'name', 'create'); + let identifier = requireField(ctx.input.identifier, 'identifier', 'create'); let rs = await client.createResourceServer({ - name: ctx.input.name, - identifier: ctx.input.identifier, + name, + identifier, scopes: ctx.input.scopes, signingAlg: ctx.input.signingAlg, tokenLifetime: ctx.input.tokenLifetime, @@ -135,9 +139,12 @@ export let manageResourceServersTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.resourceServerId) - throw new Error('resourceServerId is required for update action'); - let rs = await client.updateResourceServer(ctx.input.resourceServerId, { + let resourceServerId = requireField( + ctx.input.resourceServerId, + 'resourceServerId', + 'update' + ); + let rs = await client.updateResourceServer(resourceServerId, { name: ctx.input.name, scopes: ctx.input.scopes, signingAlg: ctx.input.signingAlg, @@ -152,15 +159,18 @@ export let manageResourceServersTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.resourceServerId) - throw new Error('resourceServerId is required for delete action'); - await client.deleteResourceServer(ctx.input.resourceServerId); + let resourceServerId = requireField( + ctx.input.resourceServerId, + 'resourceServerId', + 'delete' + ); + await client.deleteResourceServer(resourceServerId); return { output: { deleted: true }, - message: `Deleted resource server **${ctx.input.resourceServerId}**.` + message: `Deleted resource server **${resourceServerId}**.` }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw auth0ServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/auth0/src/tools/manage-role-permissions.ts b/integrations/auth0/src/tools/manage-role-permissions.ts new file mode 100644 index 0000000000..31544457cf --- /dev/null +++ b/integrations/auth0/src/tools/manage-role-permissions.ts @@ -0,0 +1,99 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Auth0Client } from '../lib/client'; +import { requireNonEmptyArray } from '../lib/errors'; +import { spec } from '../spec'; +import { dispatchAuth0Action } from './shared'; + +let permissionSchema = z.object({ + resourceServerIdentifier: z + .string() + .describe('API identifier/audience that owns the permission'), + permissionName: z.string().describe('Permission/scope name') +}); + +let mapPermission = (permission: any) => ({ + resourceServerIdentifier: permission.resource_server_identifier, + resourceServerName: permission.resource_server_name, + permissionName: permission.permission_name, + description: permission.description +}); + +export let manageRolePermissionsTool = SlateTool.create(spec, { + name: 'Manage Role Permissions', + key: 'manage_role_permissions', + description: + 'List, assign, or remove Auth0 permissions granted to a role. Permissions reference API resource-server identifiers and scope names.', + tags: { + destructive: false + } +}) + .input( + z.object({ + roleId: z.string().describe('The Auth0 role ID'), + action: z.enum(['list', 'assign', 'remove']).describe('Action to perform'), + permissions: z + .array(permissionSchema) + .optional() + .describe('Permissions to assign or remove; required for assign/remove'), + page: z.number().optional().describe('Page number for list action'), + perPage: z.number().optional().describe('Results per page for list action') + }) + ) + .output( + z.object({ + permissions: z + .array( + z.object({ + resourceServerIdentifier: z.string().optional(), + resourceServerName: z.string().optional(), + permissionName: z.string(), + description: z.string().optional() + }) + ) + .optional() + .describe('Role permissions for list action'), + success: z.boolean().describe('Whether the operation succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new Auth0Client({ + token: ctx.auth.token, + domain: ctx.auth.domain + }); + + return dispatchAuth0Action(ctx.input.action, { + list: async () => { + let result = await client.getRolePermissions(ctx.input.roleId, { + page: ctx.input.page, + perPage: ctx.input.perPage + }); + let permissions = (Array.isArray(result) ? result : (result.permissions ?? [])).map( + mapPermission + ); + return { + output: { permissions, success: true }, + message: `Role has **${permissions.length}** permission(s).` + }; + }, + + assign: async () => { + let permissions = requireNonEmptyArray(ctx.input.permissions, 'permissions', 'assign'); + await client.addRolePermissions(ctx.input.roleId, permissions); + return { + output: { success: true }, + message: `Assigned **${permissions.length}** permission(s) to role.` + }; + }, + + remove: async () => { + let permissions = requireNonEmptyArray(ctx.input.permissions, 'permissions', 'remove'); + await client.removeRolePermissions(ctx.input.roleId, permissions); + return { + output: { success: true }, + message: `Removed **${permissions.length}** permission(s) from role.` + }; + } + }); + }) + .build(); diff --git a/integrations/auth0/src/tools/manage-roles.ts b/integrations/auth0/src/tools/manage-roles.ts index e0498931c6..5f75ea7661 100644 --- a/integrations/auth0/src/tools/manage-roles.ts +++ b/integrations/auth0/src/tools/manage-roles.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Auth0Client } from '../lib/client'; +import { auth0ServiceError, requireField } from '../lib/errors'; import { spec } from '../spec'; export let manageRolesTool = SlateTool.create(spec, { @@ -76,8 +77,8 @@ export let manageRolesTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.roleId) throw new Error('roleId is required for get action'); - let role = await client.getRole(ctx.input.roleId); + let roleId = requireField(ctx.input.roleId, 'roleId', 'get'); + let role = await client.getRole(roleId); return { output: { role: mapRole(role) }, message: `Retrieved role **${role.name}**.` @@ -85,9 +86,9 @@ export let manageRolesTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); + let name = requireField(ctx.input.name, 'name', 'create'); let role = await client.createRole({ - name: ctx.input.name, + name, description: ctx.input.description }); return { @@ -97,8 +98,8 @@ export let manageRolesTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.roleId) throw new Error('roleId is required for update action'); - let role = await client.updateRole(ctx.input.roleId, { + let roleId = requireField(ctx.input.roleId, 'roleId', 'update'); + let role = await client.updateRole(roleId, { name: ctx.input.name, description: ctx.input.description }); @@ -109,14 +110,14 @@ export let manageRolesTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.roleId) throw new Error('roleId is required for delete action'); - await client.deleteRole(ctx.input.roleId); + let roleId = requireField(ctx.input.roleId, 'roleId', 'delete'); + await client.deleteRole(roleId); return { output: { deleted: true }, - message: `Deleted role **${ctx.input.roleId}**.` + message: `Deleted role **${roleId}**.` }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw auth0ServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/auth0/src/tools/manage-user-permissions.ts b/integrations/auth0/src/tools/manage-user-permissions.ts new file mode 100644 index 0000000000..5529ad2755 --- /dev/null +++ b/integrations/auth0/src/tools/manage-user-permissions.ts @@ -0,0 +1,99 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Auth0Client } from '../lib/client'; +import { requireNonEmptyArray } from '../lib/errors'; +import { spec } from '../spec'; +import { dispatchAuth0Action } from './shared'; + +let permissionSchema = z.object({ + resourceServerIdentifier: z + .string() + .describe('API identifier/audience that owns the permission'), + permissionName: z.string().describe('Permission/scope name') +}); + +let mapPermission = (permission: any) => ({ + resourceServerIdentifier: permission.resource_server_identifier, + resourceServerName: permission.resource_server_name, + permissionName: permission.permission_name, + description: permission.description +}); + +export let manageUserPermissionsTool = SlateTool.create(spec, { + name: 'Manage User Permissions', + key: 'manage_user_permissions', + description: + 'List, assign, or remove direct Auth0 permissions for a user. Permissions reference API resource-server identifiers and scope names.', + tags: { + destructive: false + } +}) + .input( + z.object({ + userId: z.string().describe('The Auth0 user ID'), + action: z.enum(['list', 'assign', 'remove']).describe('Action to perform'), + permissions: z + .array(permissionSchema) + .optional() + .describe('Permissions to assign or remove; required for assign/remove'), + page: z.number().optional().describe('Page number for list action'), + perPage: z.number().optional().describe('Results per page for list action') + }) + ) + .output( + z.object({ + permissions: z + .array( + z.object({ + resourceServerIdentifier: z.string().optional(), + resourceServerName: z.string().optional(), + permissionName: z.string(), + description: z.string().optional() + }) + ) + .optional() + .describe('Current direct permissions for list action'), + success: z.boolean().describe('Whether the operation succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new Auth0Client({ + token: ctx.auth.token, + domain: ctx.auth.domain + }); + + return dispatchAuth0Action(ctx.input.action, { + list: async () => { + let result = await client.getUserPermissions(ctx.input.userId, { + page: ctx.input.page, + perPage: ctx.input.perPage + }); + let permissions = (Array.isArray(result) ? result : (result.permissions ?? [])).map( + mapPermission + ); + return { + output: { permissions, success: true }, + message: `User has **${permissions.length}** direct permission(s).` + }; + }, + + assign: async () => { + let permissions = requireNonEmptyArray(ctx.input.permissions, 'permissions', 'assign'); + await client.assignUserPermissions(ctx.input.userId, permissions); + return { + output: { success: true }, + message: `Assigned **${permissions.length}** direct permission(s) to user.` + }; + }, + + remove: async () => { + let permissions = requireNonEmptyArray(ctx.input.permissions, 'permissions', 'remove'); + await client.removeUserPermissions(ctx.input.userId, permissions); + return { + output: { success: true }, + message: `Removed **${permissions.length}** direct permission(s) from user.` + }; + } + }); + }) + .build(); diff --git a/integrations/auth0/src/tools/manage-user-roles.ts b/integrations/auth0/src/tools/manage-user-roles.ts index c4b900b7fa..570e2f9124 100644 --- a/integrations/auth0/src/tools/manage-user-roles.ts +++ b/integrations/auth0/src/tools/manage-user-roles.ts @@ -1,7 +1,9 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Auth0Client } from '../lib/client'; +import { requireNonEmptyArray } from '../lib/errors'; import { spec } from '../spec'; +import { dispatchAuth0Action } from './shared'; export let manageUserRolesTool = SlateTool.create(spec, { name: 'Manage User Roles', @@ -42,41 +44,37 @@ export let manageUserRolesTool = SlateTool.create(spec, { domain: ctx.auth.domain }); - if (ctx.input.action === 'list') { - let roles = await client.getUserRoles(ctx.input.userId); - let mapped = (Array.isArray(roles) ? roles : (roles.roles ?? [])).map((r: any) => ({ - roleId: r.id, - name: r.name, - description: r.description - })); - return { - output: { roles: mapped, success: true }, - message: `User has **${mapped.length}** role(s) assigned.` - }; - } + return dispatchAuth0Action(ctx.input.action, { + list: async () => { + let roles = await client.getUserRoles(ctx.input.userId); + let mapped = (Array.isArray(roles) ? roles : (roles.roles ?? [])).map((r: any) => ({ + roleId: r.id, + name: r.name, + description: r.description + })); + return { + output: { roles: mapped, success: true }, + message: `User has **${mapped.length}** role(s) assigned.` + }; + }, - if (ctx.input.action === 'assign') { - if (!ctx.input.roleIds?.length) { - throw new Error('roleIds are required for assign action'); - } - await client.assignUserRoles(ctx.input.userId, ctx.input.roleIds); - return { - output: { success: true }, - message: `Assigned **${ctx.input.roleIds.length}** role(s) to user.` - }; - } + assign: async () => { + let roleIds = requireNonEmptyArray(ctx.input.roleIds, 'roleIds', 'assign'); + await client.assignUserRoles(ctx.input.userId, roleIds); + return { + output: { success: true }, + message: `Assigned **${roleIds.length}** role(s) to user.` + }; + }, - if (ctx.input.action === 'remove') { - if (!ctx.input.roleIds?.length) { - throw new Error('roleIds are required for remove action'); + remove: async () => { + let roleIds = requireNonEmptyArray(ctx.input.roleIds, 'roleIds', 'remove'); + await client.removeUserRoles(ctx.input.userId, roleIds); + return { + output: { success: true }, + message: `Removed **${roleIds.length}** role(s) from user.` + }; } - await client.removeUserRoles(ctx.input.userId, ctx.input.roleIds); - return { - output: { success: true }, - message: `Removed **${ctx.input.roleIds.length}** role(s) from user.` - }; - } - - throw new Error(`Unknown action: ${ctx.input.action}`); + }); }) .build(); diff --git a/integrations/auth0/src/tools/shared.ts b/integrations/auth0/src/tools/shared.ts new file mode 100644 index 0000000000..81ecf88935 --- /dev/null +++ b/integrations/auth0/src/tools/shared.ts @@ -0,0 +1,15 @@ +import { createApiServiceError } from 'slates'; + +type ActionHandler = () => T | Promise; + +export let dispatchAuth0Action = async ( + action: string, + handlers: Record> +) => { + let handler = handlers[action]; + if (!handler) { + throw createApiServiceError(`Unknown action: ${action}`); + } + + return handler(); +}; diff --git a/integrations/auth0/vitest.config.ts b/integrations/auth0/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/auth0/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/aws-cognito/README.md b/integrations/aws-cognito/README.md index 6c65cf23e0..e2d5ff035e 100644 --- a/integrations/aws-cognito/README.md +++ b/integrations/aws-cognito/README.md @@ -32,14 +32,26 @@ Create, get, update, or delete a group in a Cognito user pool. Groups provide ro Create, get, update, delete, or list Cognito identity pools (federated identities). Identity pools issue temporary AWS credentials to authenticated and guest users, enabling direct access to AWS services. +### Manage Identity Pool Roles + +Get or set IAM roles for a Cognito identity pool. Identity pool roles control the AWS credentials issued to authenticated and unauthenticated identities. + ### Manage Identity Provider Create, get, update, delete, or list federated identity providers (SAML, OIDC, Google, Facebook, Apple, Amazon) in a Cognito user pool. Manages federation configuration for external sign-in sources. +### Manage Resource Server + +Create, get, update, delete, or list Cognito resource servers for a user pool. Resource servers define custom OAuth scopes for external APIs and machine-to-machine authorization. + ### Manage User Pool Create, update, get, or delete a Cognito user pool. When creating, only the pool name is required. When updating, provide the user pool ID and the fields to change. Supports configuring password policies, MFA, auto-verification, and deletion protection. +### Manage User Pool Domain + +Create, get, update, or delete a Cognito user pool domain. User pool domains host managed login, OAuth authorization endpoints, and authentication pages for applications. + ### Manage User Create, get, update, disable, enable, confirm, reset password, set password, or delete a user in a Cognito user pool. Combines all administrative user operations into a single flexible tool. diff --git a/integrations/aws-cognito/package.json b/integrations/aws-cognito/package.json index 970763ffc1..2af75fc80f 100644 --- a/integrations/aws-cognito/package.json +++ b/integrations/aws-cognito/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/aws-cognito/src/index.ts b/integrations/aws-cognito/src/index.ts index f89f20913d..532b29418c 100644 --- a/integrations/aws-cognito/src/index.ts +++ b/integrations/aws-cognito/src/index.ts @@ -8,9 +8,12 @@ import { manageGroup, manageGroupMembership, manageIdentityPool, + manageIdentityPoolRoles, manageIdentityProvider, + manageResourceServer, manageUser, - manageUserPool + manageUserPool, + manageUserPoolDomain } from './tools'; import { groupChanges, inboundWebhook, userChanges } from './triggers'; @@ -26,7 +29,10 @@ export let provider = Slate.create({ manageGroupMembership, manageIdentityProvider, manageAppClient, - manageIdentityPool + manageIdentityPool, + manageIdentityPoolRoles, + manageResourceServer, + manageUserPoolDomain ], triggers: [inboundWebhook, userChanges, groupChanges] }); diff --git a/integrations/aws-cognito/src/lib/client.ts b/integrations/aws-cognito/src/lib/client.ts index d9b537de52..7696f9ce57 100644 --- a/integrations/aws-cognito/src/lib/client.ts +++ b/integrations/aws-cognito/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { cognitoApiError } from './errors'; import { signRequest } from './signing'; export interface CognitoClientConfig { @@ -45,8 +46,12 @@ export class CognitoClient { baseURL: `https://${host}` }); - let response = await ax.post('/', bodyStr, { headers }); - return response.data; + try { + let response = await ax.post('/', bodyStr, { headers }); + return response.data; + } catch (error) { + throw cognitoApiError(error, action); + } } // ---- User Pool Operations ---- @@ -326,4 +331,25 @@ export class CognitoClient { Identifier: identifier }); } + + // ---- User Pool Domain Operations ---- + + async createUserPoolDomain(params: Record): Promise { + return this.request('CreateUserPoolDomain', params); + } + + async describeUserPoolDomain(domain: string): Promise { + return this.request('DescribeUserPoolDomain', { Domain: domain }); + } + + async updateUserPoolDomain(params: Record): Promise { + return this.request('UpdateUserPoolDomain', params); + } + + async deleteUserPoolDomain(userPoolId: string, domain: string): Promise { + return this.request('DeleteUserPoolDomain', { + UserPoolId: userPoolId, + Domain: domain + }); + } } diff --git a/integrations/aws-cognito/src/lib/errors.ts b/integrations/aws-cognito/src/lib/errors.ts new file mode 100644 index 0000000000..63b94dc63c --- /dev/null +++ b/integrations/aws-cognito/src/lib/errors.ts @@ -0,0 +1,104 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushString = (messages: string[], value: unknown) => { + if (typeof value === 'string' && value.trim() && !messages.includes(value.trim())) { + messages.push(value.trim()); + } +}; + +let extractCognitoMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let messages: string[] = []; + + if (isRecord(data)) { + for (let key of ['message', 'Message', 'error', 'Error', 'code', 'Code', '__type']) { + pushString(messages, data[key]); + } + } else { + pushString(messages, data); + } + + if (isRecord(error)) { + for (let key of ['message', 'Message', 'name', 'Code', 'code', '__type']) { + pushString(messages, error[key]); + } + } + + if (error instanceof Error) { + pushString(messages, error.message); + } + + return messages.length > 0 ? [...new Set(messages)].join(' - ') : 'Unknown error'; +}; + +let extractCognitoStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + let metadata = isRecord(error.$metadata) ? error.$metadata : undefined; + let status = + response?.status ?? metadata?.httpStatusCode ?? error.statusCode ?? error.status; + + return typeof status === 'number' || typeof status === 'string' ? status : undefined; +}; + +let extractCognitoCode = (error: unknown) => { + if (!isRecord(error)) return undefined; + + if (typeof error.Code === 'string') return error.Code; + if (typeof error.code === 'string' && !error.code.startsWith('upstream.')) { + return error.code; + } + if (typeof error.name === 'string' && error.name !== 'Error') return error.name; + + let response = error.response as ErrorResponse | undefined; + let data = response?.data; + if (isRecord(data)) { + if (typeof data.Code === 'string') return data.Code; + if (typeof data.code === 'string') return data.code; + if (typeof data.__type === 'string') return data.__type.split('#').at(-1); + } + + return undefined; +}; + +export let cognitoServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let cognitoApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = extractCognitoStatus(error); + let code = extractCognitoCode(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + let codeLabel = code ? `${code} - ` : ''; + let serviceError = cognitoServiceError( + `Amazon Cognito API ${operation} failed: ${statusLabel}${codeLabel}${extractCognitoMessage(error)}` + ); + + serviceError.data.reason = 'aws_cognito_api_error'; + serviceError.data.upstreamStatus = status; + serviceError.data.upstreamCode = code; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/aws-cognito/src/lib/identity-client.ts b/integrations/aws-cognito/src/lib/identity-client.ts index 12457314ca..8fc9a4f2ad 100644 --- a/integrations/aws-cognito/src/lib/identity-client.ts +++ b/integrations/aws-cognito/src/lib/identity-client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { cognitoApiError } from './errors'; import { signRequest } from './signing'; export interface IdentityClientConfig { @@ -45,8 +46,12 @@ export class CognitoIdentityClient { baseURL: `https://${host}` }); - let response = await ax.post('/', bodyStr, { headers }); - return response.data; + try { + let response = await ax.post('/', bodyStr, { headers }); + return response.data; + } catch (error) { + throw cognitoApiError(error, action); + } } async listIdentityPools(maxResults: number = 60, nextToken?: string): Promise { diff --git a/integrations/aws-cognito/src/tools.schema.test.ts b/integrations/aws-cognito/src/tools.schema.test.ts new file mode 100644 index 0000000000..2f5e3675af --- /dev/null +++ b/integrations/aws-cognito/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('AWS Cognito tool input schemas', provider.actions); diff --git a/integrations/aws-cognito/src/tools/index.ts b/integrations/aws-cognito/src/tools/index.ts index 2d3cddfcd6..ff73ab44b4 100644 --- a/integrations/aws-cognito/src/tools/index.ts +++ b/integrations/aws-cognito/src/tools/index.ts @@ -5,6 +5,9 @@ export * from './manage-app-client'; export * from './manage-group'; export * from './manage-group-membership'; export * from './manage-identity-pool'; +export * from './manage-identity-pool-roles'; export * from './manage-identity-provider'; +export * from './manage-resource-server'; export * from './manage-user'; export * from './manage-user-pool'; +export * from './manage-user-pool-domain'; diff --git a/integrations/aws-cognito/src/tools/manage-app-client.ts b/integrations/aws-cognito/src/tools/manage-app-client.ts index d11e9fa40a..d08c23cbde 100644 --- a/integrations/aws-cognito/src/tools/manage-app-client.ts +++ b/integrations/aws-cognito/src/tools/manage-app-client.ts @@ -1,16 +1,29 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; import { createCognitoClient } from '../lib/helpers'; import { spec } from '../spec'; let tokenValidityUnitsSchema = z .object({ - accessToken: z.enum(['seconds', 'minutes', 'hours']).optional(), - idToken: z.enum(['seconds', 'minutes', 'hours']).optional(), + accessToken: z.enum(['seconds', 'minutes', 'hours', 'days']).optional(), + idToken: z.enum(['seconds', 'minutes', 'hours', 'days']).optional(), refreshToken: z.enum(['seconds', 'minutes', 'hours', 'days']).optional() }) .optional(); +let refreshTokenRotationSchema = z + .object({ + feature: z.enum(['ENABLED', 'DISABLED']).describe('Refresh token rotation state'), + retryGracePeriodSeconds: z + .number() + .min(0) + .max(60) + .optional() + .describe('Grace period for token replay during rotation, in seconds') + }) + .optional(); + export let manageAppClient = SlateTool.create(spec, { name: 'Manage App Client', key: 'manage_app_client', @@ -35,10 +48,16 @@ export let manageAppClient = SlateTool.create(spec, { .boolean() .optional() .describe('Generate a client secret (for create only)'), + clientSecret: z + .string() + .optional() + .describe('Custom client secret for create. Do not combine with generateSecret.'), explicitAuthFlows: z .array(z.string()) .optional() - .describe('Allowed auth flows (e.g., ALLOW_USER_SRP_AUTH, ALLOW_REFRESH_TOKEN_AUTH)'), + .describe( + 'Allowed auth flows, including current ALLOW_* values such as ALLOW_USER_AUTH, ALLOW_USER_SRP_AUTH, ALLOW_USER_PASSWORD_AUTH, and ALLOW_REFRESH_TOKEN_AUTH' + ), allowedOAuthFlows: z .array(z.enum(['code', 'implicit', 'client_credentials'])) .optional(), @@ -60,8 +79,21 @@ export let manageAppClient = SlateTool.create(spec, { accessTokenValidity: z.number().optional(), idTokenValidity: z.number().optional(), refreshTokenValidity: z.number().optional(), + authSessionValidity: z + .number() + .min(3) + .max(15) + .optional() + .describe('Authentication flow session validity in minutes (3-15)'), tokenValidityUnits: tokenValidityUnitsSchema, + refreshTokenRotation: refreshTokenRotationSchema, enableTokenRevocation: z.boolean().optional(), + enablePropagateAdditionalUserContextData: z + .boolean() + .optional() + .describe( + 'Allow additional user context data such as source IP for threat protection' + ), preventUserExistenceErrors: z.enum(['ENABLED', 'LEGACY']).optional(), readAttributes: z.array(z.string()).optional(), writeAttributes: z.array(z.string()).optional(), @@ -84,6 +116,13 @@ export let manageAppClient = SlateTool.create(spec, { accessTokenValidity: z.number().optional(), idTokenValidity: z.number().optional(), refreshTokenValidity: z.number().optional(), + authSessionValidity: z.number().optional(), + refreshTokenRotationFeature: z.string().optional(), + refreshTokenRotationRetryGracePeriodSeconds: z.number().optional(), + enableTokenRevocation: z.boolean().optional(), + enablePropagateAdditionalUserContextData: z.boolean().optional(), + preventUserExistenceErrors: z.string().optional(), + defaultRedirectUri: z.string().optional(), creationDate: z.number().optional(), lastModifiedDate: z.number().optional(), clients: z @@ -117,6 +156,14 @@ export let manageAppClient = SlateTool.create(spec, { accessTokenValidity: c.AccessTokenValidity, idTokenValidity: c.IdTokenValidity, refreshTokenValidity: c.RefreshTokenValidity, + authSessionValidity: c.AuthSessionValidity, + refreshTokenRotationFeature: c.RefreshTokenRotation?.Feature, + refreshTokenRotationRetryGracePeriodSeconds: + c.RefreshTokenRotation?.RetryGracePeriodSeconds, + enableTokenRevocation: c.EnableTokenRevocation, + enablePropagateAdditionalUserContextData: c.EnablePropagateAdditionalUserContextData, + preventUserExistenceErrors: c.PreventUserExistenceErrors, + defaultRedirectUri: c.DefaultRedirectURI, creationDate: c.CreationDate, lastModifiedDate: c.LastModifiedDate }); @@ -125,8 +172,12 @@ export let manageAppClient = SlateTool.create(spec, { let params: Record = { UserPoolId: userPoolId }; if (ctx.input.clientName) params.ClientName = ctx.input.clientName; if (ctx.input.clientId) params.ClientId = ctx.input.clientId; + if (ctx.input.generateSecret === true && ctx.input.clientSecret) { + throw cognitoServiceError('clientSecret cannot be combined with generateSecret'); + } if (ctx.input.generateSecret !== undefined) params.GenerateSecret = ctx.input.generateSecret; + if (ctx.input.clientSecret) params.ClientSecret = ctx.input.clientSecret; if (ctx.input.explicitAuthFlows) params.ExplicitAuthFlows = ctx.input.explicitAuthFlows; if (ctx.input.allowedOAuthFlows) params.AllowedOAuthFlows = ctx.input.allowedOAuthFlows; if (ctx.input.allowedOAuthScopes) @@ -145,6 +196,8 @@ export let manageAppClient = SlateTool.create(spec, { params.IdTokenValidity = ctx.input.idTokenValidity; if (ctx.input.refreshTokenValidity !== undefined) params.RefreshTokenValidity = ctx.input.refreshTokenValidity; + if (ctx.input.authSessionValidity !== undefined) + params.AuthSessionValidity = ctx.input.authSessionValidity; if (ctx.input.tokenValidityUnits) { params.TokenValidityUnits = { AccessToken: ctx.input.tokenValidityUnits.accessToken, @@ -152,8 +205,18 @@ export let manageAppClient = SlateTool.create(spec, { RefreshToken: ctx.input.tokenValidityUnits.refreshToken }; } + if (ctx.input.refreshTokenRotation) { + params.RefreshTokenRotation = { + Feature: ctx.input.refreshTokenRotation.feature, + RetryGracePeriodSeconds: ctx.input.refreshTokenRotation.retryGracePeriodSeconds + }; + } if (ctx.input.enableTokenRevocation !== undefined) params.EnableTokenRevocation = ctx.input.enableTokenRevocation; + if (ctx.input.enablePropagateAdditionalUserContextData !== undefined) { + params.EnablePropagateAdditionalUserContextData = + ctx.input.enablePropagateAdditionalUserContextData; + } if (ctx.input.preventUserExistenceErrors) params.PreventUserExistenceErrors = ctx.input.preventUserExistenceErrors; if (ctx.input.readAttributes) params.ReadAttributes = ctx.input.readAttributes; @@ -180,7 +243,9 @@ export let manageAppClient = SlateTool.create(spec, { } if (action === 'create') { - if (!ctx.input.clientName) throw new Error('clientName is required for create'); + if (!ctx.input.clientName) { + throw cognitoServiceError('clientName is required for create'); + } let result = await client.createUserPoolClient(buildClientParams()); return { output: mapClient(result.UserPoolClient), @@ -189,7 +254,9 @@ export let manageAppClient = SlateTool.create(spec, { } if (action === 'get') { - if (!ctx.input.clientId) throw new Error('clientId is required for get'); + if (!ctx.input.clientId) { + throw cognitoServiceError('clientId is required for get'); + } let result = await client.describeUserPoolClient(userPoolId, ctx.input.clientId); return { output: mapClient(result.UserPoolClient), @@ -198,7 +265,9 @@ export let manageAppClient = SlateTool.create(spec, { } if (action === 'update') { - if (!ctx.input.clientId) throw new Error('clientId is required for update'); + if (!ctx.input.clientId) { + throw cognitoServiceError('clientId is required for update'); + } let result = await client.updateUserPoolClient(buildClientParams()); return { output: mapClient(result.UserPoolClient), @@ -207,7 +276,9 @@ export let manageAppClient = SlateTool.create(spec, { } if (action === 'delete') { - if (!ctx.input.clientId) throw new Error('clientId is required for delete'); + if (!ctx.input.clientId) { + throw cognitoServiceError('clientId is required for delete'); + } await client.deleteUserPoolClient(userPoolId, ctx.input.clientId); return { output: { clientId: ctx.input.clientId, deleted: true }, @@ -215,6 +286,6 @@ export let manageAppClient = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw cognitoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/aws-cognito/src/tools/manage-group-membership.ts b/integrations/aws-cognito/src/tools/manage-group-membership.ts index 18465dd1b0..fb026e99ef 100644 --- a/integrations/aws-cognito/src/tools/manage-group-membership.ts +++ b/integrations/aws-cognito/src/tools/manage-group-membership.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; import { createCognitoClient, formatAttributes } from '../lib/helpers'; import { spec } from '../spec'; @@ -57,8 +58,9 @@ export let manageGroupMembership = SlateTool.create(spec, { let { action, userPoolId } = ctx.input; if (action === 'add_user') { - if (!ctx.input.username || !ctx.input.groupName) - throw new Error('username and groupName are required'); + if (!ctx.input.username || !ctx.input.groupName) { + throw cognitoServiceError('username and groupName are required'); + } await client.adminAddUserToGroup(userPoolId, ctx.input.username, ctx.input.groupName); return { output: { success: true }, @@ -67,8 +69,9 @@ export let manageGroupMembership = SlateTool.create(spec, { } if (action === 'remove_user') { - if (!ctx.input.username || !ctx.input.groupName) - throw new Error('username and groupName are required'); + if (!ctx.input.username || !ctx.input.groupName) { + throw cognitoServiceError('username and groupName are required'); + } await client.adminRemoveUserFromGroup( userPoolId, ctx.input.username, @@ -81,7 +84,9 @@ export let manageGroupMembership = SlateTool.create(spec, { } if (action === 'list_users_in_group') { - if (!ctx.input.groupName) throw new Error('groupName is required'); + if (!ctx.input.groupName) { + throw cognitoServiceError('groupName is required'); + } let result = await client.listUsersInGroup( userPoolId, ctx.input.groupName, @@ -103,7 +108,9 @@ export let manageGroupMembership = SlateTool.create(spec, { } if (action === 'list_groups_for_user') { - if (!ctx.input.username) throw new Error('username is required'); + if (!ctx.input.username) { + throw cognitoServiceError('username is required'); + } let result = await client.adminListGroupsForUser( userPoolId, ctx.input.username, @@ -123,6 +130,6 @@ export let manageGroupMembership = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw cognitoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/aws-cognito/src/tools/manage-group.ts b/integrations/aws-cognito/src/tools/manage-group.ts index eb889da877..bfb47efee5 100644 --- a/integrations/aws-cognito/src/tools/manage-group.ts +++ b/integrations/aws-cognito/src/tools/manage-group.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; import { createCognitoClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -99,6 +100,6 @@ export let manageGroup = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw cognitoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/aws-cognito/src/tools/manage-identity-pool-roles.ts b/integrations/aws-cognito/src/tools/manage-identity-pool-roles.ts new file mode 100644 index 0000000000..8ee1109b82 --- /dev/null +++ b/integrations/aws-cognito/src/tools/manage-identity-pool-roles.ts @@ -0,0 +1,80 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; +import { createIdentityClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let manageIdentityPoolRoles = SlateTool.create(spec, { + name: 'Manage Identity Pool Roles', + key: 'manage_identity_pool_roles', + description: `Get or set IAM roles for a Cognito identity pool. Identity pool roles control the AWS credentials issued to authenticated and unauthenticated identities.`, + instructions: [ + 'Roles uses keys "authenticated" and/or "unauthenticated" with IAM role ARNs as values.', + 'Role mappings are keyed by identity provider, such as graph.facebook.com or cognito-idp..amazonaws.com/:.' + ] +}) + .input( + z.object({ + action: z.enum(['get', 'set']).describe('Operation to perform'), + identityPoolId: z.string().describe('Identity pool ID in REGION:GUID format'), + roles: z + .record(z.string(), z.string()) + .optional() + .describe( + 'IAM role ARN map required for set. Use authenticated and/or unauthenticated keys.' + ), + roleMappings: z + .record(z.string(), z.record(z.string(), z.any())) + .optional() + .describe('Advanced provider role mappings for set') + }) + ) + .output( + z.object({ + identityPoolId: z.string(), + roles: z.record(z.string(), z.string()).optional(), + roleMappings: z.record(z.string(), z.any()).optional(), + success: z.boolean().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createIdentityClient(ctx); + let { action, identityPoolId } = ctx.input; + + if (action === 'get') { + let result = await client.getIdentityPoolRoles(identityPoolId); + return { + output: { + identityPoolId: result.IdentityPoolId, + roles: result.Roles, + roleMappings: result.RoleMappings + }, + message: `Retrieved roles for identity pool **${identityPoolId}**.` + }; + } + + if (action === 'set') { + if (!ctx.input.roles) { + throw cognitoServiceError('roles is required for set'); + } + + await client.setIdentityPoolRoles({ + IdentityPoolId: identityPoolId, + Roles: ctx.input.roles, + RoleMappings: ctx.input.roleMappings + }); + + return { + output: { + identityPoolId, + roles: ctx.input.roles, + roleMappings: ctx.input.roleMappings, + success: true + }, + message: `Updated roles for identity pool **${identityPoolId}**.` + }; + } + + throw cognitoServiceError(`Unknown action: ${action}`); + }) + .build(); diff --git a/integrations/aws-cognito/src/tools/manage-identity-pool.ts b/integrations/aws-cognito/src/tools/manage-identity-pool.ts index 7924874f1e..6f80b915bb 100644 --- a/integrations/aws-cognito/src/tools/manage-identity-pool.ts +++ b/integrations/aws-cognito/src/tools/manage-identity-pool.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; import { createIdentityClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -149,7 +150,7 @@ export let manageIdentityPool = SlateTool.create(spec, { !ctx.input.identityPoolName || ctx.input.allowUnauthenticatedIdentities === undefined ) { - throw new Error( + throw cognitoServiceError( 'identityPoolName and allowUnauthenticatedIdentities are required for create' ); } @@ -161,7 +162,9 @@ export let manageIdentityPool = SlateTool.create(spec, { } if (action === 'get') { - if (!ctx.input.identityPoolId) throw new Error('identityPoolId is required for get'); + if (!ctx.input.identityPoolId) { + throw cognitoServiceError('identityPoolId is required for get'); + } let result = await client.describeIdentityPool(ctx.input.identityPoolId); return { output: mapPool(result), @@ -175,7 +178,7 @@ export let manageIdentityPool = SlateTool.create(spec, { !ctx.input.identityPoolName || ctx.input.allowUnauthenticatedIdentities === undefined ) { - throw new Error( + throw cognitoServiceError( 'identityPoolId, identityPoolName, and allowUnauthenticatedIdentities are required for update' ); } @@ -187,7 +190,9 @@ export let manageIdentityPool = SlateTool.create(spec, { } if (action === 'delete') { - if (!ctx.input.identityPoolId) throw new Error('identityPoolId is required for delete'); + if (!ctx.input.identityPoolId) { + throw cognitoServiceError('identityPoolId is required for delete'); + } await client.deleteIdentityPool(ctx.input.identityPoolId); return { output: { identityPoolId: ctx.input.identityPoolId, deleted: true }, @@ -195,6 +200,6 @@ export let manageIdentityPool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw cognitoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/aws-cognito/src/tools/manage-identity-provider.ts b/integrations/aws-cognito/src/tools/manage-identity-provider.ts index c3235f1181..2012ed4d72 100644 --- a/integrations/aws-cognito/src/tools/manage-identity-provider.ts +++ b/integrations/aws-cognito/src/tools/manage-identity-provider.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; import { createCognitoClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -100,7 +101,7 @@ export let manageIdentityProvider = SlateTool.create(spec, { if (action === 'create') { if (!ctx.input.providerName || !ctx.input.providerType || !ctx.input.providerDetails) { - throw new Error( + throw cognitoServiceError( 'providerName, providerType, and providerDetails are required for create' ); } @@ -122,7 +123,9 @@ export let manageIdentityProvider = SlateTool.create(spec, { } if (action === 'get') { - if (!ctx.input.providerName) throw new Error('providerName is required for get'); + if (!ctx.input.providerName) { + throw cognitoServiceError('providerName is required for get'); + } let result = await client.describeIdentityProvider(userPoolId, ctx.input.providerName); return { output: mapProvider(result.IdentityProvider), @@ -131,7 +134,9 @@ export let manageIdentityProvider = SlateTool.create(spec, { } if (action === 'update') { - if (!ctx.input.providerName) throw new Error('providerName is required for update'); + if (!ctx.input.providerName) { + throw cognitoServiceError('providerName is required for update'); + } let params: Record = { UserPoolId: userPoolId, ProviderName: ctx.input.providerName @@ -148,7 +153,9 @@ export let manageIdentityProvider = SlateTool.create(spec, { } if (action === 'delete') { - if (!ctx.input.providerName) throw new Error('providerName is required for delete'); + if (!ctx.input.providerName) { + throw cognitoServiceError('providerName is required for delete'); + } await client.deleteIdentityProvider(userPoolId, ctx.input.providerName); return { output: { providerName: ctx.input.providerName, deleted: true }, @@ -156,6 +163,6 @@ export let manageIdentityProvider = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw cognitoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/aws-cognito/src/tools/manage-resource-server.ts b/integrations/aws-cognito/src/tools/manage-resource-server.ts new file mode 100644 index 0000000000..2b5d438fca --- /dev/null +++ b/integrations/aws-cognito/src/tools/manage-resource-server.ts @@ -0,0 +1,168 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; +import { createCognitoClient } from '../lib/helpers'; +import { spec } from '../spec'; + +let resourceServerScopeSchema = z.object({ + scopeName: z + .string() + .describe('Custom scope name, without the resource-server identifier prefix'), + scopeDescription: z.string().describe('Human-readable description for the custom scope') +}); + +export let manageResourceServer = SlateTool.create(spec, { + name: 'Manage Resource Server', + key: 'manage_resource_server', + description: `Create, get, update, delete, or list Cognito resource servers for a user pool. Resource servers define custom OAuth scopes for external APIs and machine-to-machine authorization.`, + instructions: [ + 'Resource server identifiers appear in access-token scopes as "identifier/scopeName".', + 'For update, provide the full desired name and scope list; omitted optional fields may reset to defaults.' + ] +}) + .input( + z.object({ + action: z + .enum(['create', 'get', 'update', 'delete', 'list']) + .describe('Operation to perform'), + userPoolId: z.string().describe('User pool ID'), + identifier: z + .string() + .optional() + .describe('Resource server identifier (required for create, get, update, delete)'), + name: z + .string() + .optional() + .describe('Friendly resource server name (required for create and update)'), + scopes: z + .array(resourceServerScopeSchema) + .max(100) + .optional() + .describe('Custom OAuth scopes to define on the resource server'), + maxResults: z.number().min(1).max(50).optional().describe('Max results for list'), + nextToken: z.string().optional().describe('Pagination token for list') + }) + ) + .output( + z.object({ + identifier: z.string().optional(), + name: z.string().optional(), + userPoolId: z.string().optional(), + scopes: z + .array( + z.object({ + scopeName: z.string(), + scopeDescription: z.string() + }) + ) + .optional(), + resourceServers: z + .array( + z.object({ + identifier: z.string(), + name: z.string(), + userPoolId: z.string(), + scopes: z.array( + z.object({ + scopeName: z.string(), + scopeDescription: z.string() + }) + ) + }) + ) + .optional(), + deleted: z.boolean().optional(), + nextToken: z.string().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createCognitoClient(ctx); + let { action, userPoolId } = ctx.input; + + let mapResourceServer = (server: any) => ({ + identifier: server.Identifier, + name: server.Name, + userPoolId: server.UserPoolId, + scopes: (server.Scopes || []).map((scope: any) => ({ + scopeName: scope.ScopeName, + scopeDescription: scope.ScopeDescription + })) + }); + + let buildParams = () => { + if (!ctx.input.identifier) { + throw cognitoServiceError(`identifier is required for ${action}`); + } + if (!ctx.input.name) { + throw cognitoServiceError(`name is required for ${action}`); + } + + return { + UserPoolId: userPoolId, + Identifier: ctx.input.identifier, + Name: ctx.input.name, + Scopes: ctx.input.scopes?.map(scope => ({ + ScopeName: scope.scopeName, + ScopeDescription: scope.scopeDescription + })) + }; + }; + + if (action === 'list') { + let result = await client.listResourceServers( + userPoolId, + ctx.input.maxResults, + ctx.input.nextToken + ); + let resourceServers = (result.ResourceServers || []).map(mapResourceServer); + + return { + output: { resourceServers, nextToken: result.NextToken }, + message: `Found **${resourceServers.length}** resource server(s).` + }; + } + + if (action === 'create') { + let result = await client.createResourceServer(buildParams()); + let resourceServer = mapResourceServer(result.ResourceServer); + return { + output: resourceServer, + message: `Created resource server **${resourceServer.identifier}**.` + }; + } + + if (action === 'get') { + if (!ctx.input.identifier) { + throw cognitoServiceError('identifier is required for get'); + } + let result = await client.describeResourceServer(userPoolId, ctx.input.identifier); + let resourceServer = mapResourceServer(result.ResourceServer); + return { + output: resourceServer, + message: `Resource server **${resourceServer.identifier}** details retrieved.` + }; + } + + if (action === 'update') { + let result = await client.updateResourceServer(buildParams()); + let resourceServer = mapResourceServer(result.ResourceServer); + return { + output: resourceServer, + message: `Updated resource server **${resourceServer.identifier}**.` + }; + } + + if (action === 'delete') { + if (!ctx.input.identifier) { + throw cognitoServiceError('identifier is required for delete'); + } + await client.deleteResourceServer(userPoolId, ctx.input.identifier); + return { + output: { identifier: ctx.input.identifier, userPoolId, deleted: true }, + message: `Deleted resource server **${ctx.input.identifier}**.` + }; + } + + throw cognitoServiceError(`Unknown action: ${action}`); + }) + .build(); diff --git a/integrations/aws-cognito/src/tools/manage-user-pool-domain.ts b/integrations/aws-cognito/src/tools/manage-user-pool-domain.ts new file mode 100644 index 0000000000..fbae03fe66 --- /dev/null +++ b/integrations/aws-cognito/src/tools/manage-user-pool-domain.ts @@ -0,0 +1,141 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; +import { createCognitoClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let manageUserPoolDomain = SlateTool.create(spec, { + name: 'Manage User Pool Domain', + key: 'manage_user_pool_domain', + description: `Create, get, update, or delete a Cognito user pool domain. User pool domains host managed login, OAuth authorization endpoints, and authentication pages for applications.`, + instructions: [ + 'For prefix domains, pass only the prefix, such as "myapp"; Cognito expands it to the regional amazoncognito.com domain.', + 'For custom domains, pass the fully-qualified domain and certificateArn for an ACM certificate in us-east-1.', + 'managedLoginVersion 1 selects classic hosted UI; managedLoginVersion 2 selects newer managed login when the user pool tier supports it.' + ] +}) + .input( + z.object({ + action: z.enum(['create', 'get', 'update', 'delete']).describe('Operation to perform'), + userPoolId: z + .string() + .optional() + .describe('User pool ID (required for create, update, delete)'), + domain: z.string().describe('Domain prefix or custom fully-qualified domain name'), + certificateArn: z + .string() + .optional() + .describe('ACM certificate ARN in us-east-1 for a custom domain'), + managedLoginVersion: z + .number() + .int() + .min(1) + .max(2) + .optional() + .describe('1 for classic hosted UI, 2 for newer managed login') + }) + ) + .output( + z.object({ + domain: z.string().optional(), + userPoolId: z.string().optional(), + awsAccountId: z.string().optional(), + cloudFrontDomain: z.string().optional(), + s3Bucket: z.string().optional(), + status: z.string().optional(), + version: z.string().optional(), + certificateArn: z.string().optional(), + managedLoginVersion: z.number().optional(), + deleted: z.boolean().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createCognitoClient(ctx); + let { action, domain } = ctx.input; + + let requireUserPoolId = () => { + if (!ctx.input.userPoolId) { + throw cognitoServiceError(`userPoolId is required for ${action}`); + } + return ctx.input.userPoolId; + }; + + let customDomainConfig = ctx.input.certificateArn + ? { CertificateArn: ctx.input.certificateArn } + : undefined; + + let mapDomainDescription = (description: any) => ({ + domain: description.Domain, + userPoolId: description.UserPoolId, + awsAccountId: description.AWSAccountId, + cloudFrontDomain: description.CloudFrontDistribution, + s3Bucket: description.S3Bucket, + status: description.Status, + version: description.Version, + certificateArn: description.CustomDomainConfig?.CertificateArn, + managedLoginVersion: description.ManagedLoginVersion + }); + + if (action === 'create') { + let result = await client.createUserPoolDomain({ + UserPoolId: requireUserPoolId(), + Domain: domain, + CustomDomainConfig: customDomainConfig, + ManagedLoginVersion: ctx.input.managedLoginVersion + }); + + return { + output: { + domain, + userPoolId: ctx.input.userPoolId, + cloudFrontDomain: result.CloudFrontDomain, + managedLoginVersion: result.ManagedLoginVersion + }, + message: `Created user pool domain **${domain}**.` + }; + } + + if (action === 'get') { + let result = await client.describeUserPoolDomain(domain); + let domainDescription = mapDomainDescription(result.DomainDescription); + return { + output: domainDescription, + message: `User pool domain **${domainDescription.domain}** is ${domainDescription.status ?? 'available'}.` + }; + } + + if (action === 'update') { + if (!customDomainConfig && ctx.input.managedLoginVersion === undefined) { + throw cognitoServiceError( + 'managedLoginVersion or certificateArn is required for update' + ); + } + let result = await client.updateUserPoolDomain({ + UserPoolId: requireUserPoolId(), + Domain: domain, + CustomDomainConfig: customDomainConfig, + ManagedLoginVersion: ctx.input.managedLoginVersion + }); + + return { + output: { + domain, + userPoolId: ctx.input.userPoolId, + cloudFrontDomain: result.CloudFrontDomain, + managedLoginVersion: result.ManagedLoginVersion + }, + message: `Updated user pool domain **${domain}**.` + }; + } + + if (action === 'delete') { + await client.deleteUserPoolDomain(requireUserPoolId(), domain); + return { + output: { domain, userPoolId: ctx.input.userPoolId, deleted: true }, + message: `Deleted user pool domain **${domain}**.` + }; + } + + throw cognitoServiceError(`Unknown action: ${action}`); + }) + .build(); diff --git a/integrations/aws-cognito/src/tools/manage-user-pool.ts b/integrations/aws-cognito/src/tools/manage-user-pool.ts index 90f464bc48..59e280ec2d 100644 --- a/integrations/aws-cognito/src/tools/manage-user-pool.ts +++ b/integrations/aws-cognito/src/tools/manage-user-pool.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; import { createCognitoClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -70,7 +71,9 @@ export let manageUserPool = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { - if (!ctx.input.poolName) throw new Error('poolName is required for create action'); + if (!ctx.input.poolName) { + throw cognitoServiceError('poolName is required for create action'); + } let params: Record = { PoolName: ctx.input.poolName }; if (ctx.input.mfaConfiguration) params.MfaConfiguration = ctx.input.mfaConfiguration; @@ -112,7 +115,9 @@ export let manageUserPool = SlateTool.create(spec, { } if (action === 'get') { - if (!ctx.input.userPoolId) throw new Error('userPoolId is required for get action'); + if (!ctx.input.userPoolId) { + throw cognitoServiceError('userPoolId is required for get action'); + } let result = await client.describeUserPool(ctx.input.userPoolId); let pool = result.UserPool; @@ -132,7 +137,9 @@ export let manageUserPool = SlateTool.create(spec, { } if (action === 'update') { - if (!ctx.input.userPoolId) throw new Error('userPoolId is required for update action'); + if (!ctx.input.userPoolId) { + throw cognitoServiceError('userPoolId is required for update action'); + } let params: Record = { UserPoolId: ctx.input.userPoolId }; if (ctx.input.mfaConfiguration) params.MfaConfiguration = ctx.input.mfaConfiguration; @@ -166,7 +173,9 @@ export let manageUserPool = SlateTool.create(spec, { } if (action === 'delete') { - if (!ctx.input.userPoolId) throw new Error('userPoolId is required for delete action'); + if (!ctx.input.userPoolId) { + throw cognitoServiceError('userPoolId is required for delete action'); + } await client.deleteUserPool(ctx.input.userPoolId); return { @@ -178,6 +187,6 @@ export let manageUserPool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw cognitoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/aws-cognito/src/tools/manage-user.ts b/integrations/aws-cognito/src/tools/manage-user.ts index ff507b1554..42dd3ba52c 100644 --- a/integrations/aws-cognito/src/tools/manage-user.ts +++ b/integrations/aws-cognito/src/tools/manage-user.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { cognitoServiceError } from '../lib/errors'; import { createCognitoClient, formatAttributes, toAttributeList } from '../lib/helpers'; import { spec } from '../spec'; @@ -112,8 +113,9 @@ export let manageUser = SlateTool.create(spec, { } if (action === 'update_attributes') { - if (!ctx.input.attributes) - throw new Error('attributes are required for update_attributes action'); + if (!ctx.input.attributes) { + throw cognitoServiceError('attributes are required for update_attributes action'); + } await client.adminUpdateUserAttributes( userPoolId, @@ -168,7 +170,9 @@ export let manageUser = SlateTool.create(spec, { } if (action === 'set_password') { - if (!ctx.input.password) throw new Error('password is required for set_password action'); + if (!ctx.input.password) { + throw cognitoServiceError('password is required for set_password action'); + } await client.adminSetUserPassword( userPoolId, username, @@ -181,6 +185,6 @@ export let manageUser = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw cognitoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/aws-cognito/vitest.config.ts b/integrations/aws-cognito/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/aws-cognito/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/aws-dynamodb/README.md b/integrations/aws-dynamodb/README.md index 7e4c324a51..85edc1b411 100644 --- a/integrations/aws-dynamodb/README.md +++ b/integrations/aws-dynamodb/README.md @@ -1,6 +1,6 @@ # Aws Dynamodb -Create, manage, and delete DynamoDB tables with configurable capacity modes and key schemas. Perform CRUD operations on items using primary keys, including conditional writes and attribute-level updates. Query items by partition and sort key conditions, or scan entire tables with filter and projection expressions. Support PartiQL (SQL-compatible) syntax for data operations. Manage secondary indexes (GSI and LSI) for alternative query patterns. Execute multi-item transactions with all-or-nothing guarantees across tables. Configure global tables for multi-region active-active replication. Create and restore on-demand backups, enable point-in-time recovery, and set TTL for automatic item expiration. Import and export data between S3 and DynamoDB. Monitor table changes via DynamoDB Streams for near-real-time change data capture with configurable stream view types. +Create, manage, and delete DynamoDB tables with configurable capacity modes, key schemas, deletion protection, secondary indexes, streams, and tags. Perform CRUD operations on items using primary keys, including conditional writes and attribute-level updates. Query items by partition and sort key conditions, or scan tables with filter and projection expressions. Support PartiQL (SQL-compatible) syntax for data operations. Execute multi-item read and write transactions with all-or-nothing guarantees across tables. Create, inspect, delete, and restore from on-demand backups, describe point-in-time recovery settings, and set TTL for automatic item expiration. Inspect DynamoDB Streams and read shard records for near-real-time change data capture. ## Tools @@ -24,6 +24,14 @@ Delete a single item from a DynamoDB table by its primary key. Supports conditio Permanently delete a DynamoDB table and all of its items. This action cannot be undone. +### Describe Backup + +Describe an existing DynamoDB on-demand backup, including backup status and source table details. + +### Describe Stream + +Describe a DynamoDB Stream, including status, table name, key schema, and shards. + ### Describe Table Retrieve detailed information about a DynamoDB table including its key schema, provisioned throughput, indexes, stream configuration, and current status. @@ -36,6 +44,14 @@ Execute a PartiQL statement against DynamoDB. PartiQL is a SQL-compatible query Retrieve a single item from a DynamoDB table by its primary key. Returns the full item or specific attributes via projection expression. Supports strongly consistent reads. +### Get Stream Records + +Read records from a DynamoDB Stream shard by creating a shard iterator and returning the next page of stream records. + +### List Streams + +List DynamoDB Streams in the configured region, optionally filtered to one table. + ### List Tables List all DynamoDB table names in the configured region. Supports pagination for accounts with many tables. @@ -50,7 +66,7 @@ View or configure Time to Live (TTL) settings on a DynamoDB table. When enabled, ### Put Item -Create or replace an item in a DynamoDB table. The entire item is replaced if an item with the same primary key exists. Use DynamoDB JSON format for attribute values (e.g., \ +Create or replace an item in a DynamoDB table. The entire item is replaced if an item with the same primary key exists. Use DynamoDB JSON format for attribute values (for example, `{"S": "hello"}` for strings or `{"N": "42"}` for numbers). ### Query Items @@ -60,6 +76,14 @@ Query items from a DynamoDB table or secondary index using a key condition expre Scan an entire DynamoDB table or secondary index, returning all items or those matching a filter expression. More flexible but less efficient than Query — reads every item in the table. Use Query when possible for better performance. +### Restore Table From Backup + +Create a new DynamoDB table by restoring an existing on-demand backup. + +### Transact Get Items + +Atomically retrieve up to 100 items from one or more DynamoDB tables in the same account and region. + ### Transact Write Items Execute a transactional write with up to 100 actions across one or more DynamoDB tables. All actions succeed or all fail together (ACID). Supports Put, Update, Delete, and ConditionCheck operations within a single transaction. diff --git a/integrations/aws-dynamodb/package.json b/integrations/aws-dynamodb/package.json index 7506146c3a..d1828c73f4 100644 --- a/integrations/aws-dynamodb/package.json +++ b/integrations/aws-dynamodb/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/aws-dynamodb/src/index.ts b/integrations/aws-dynamodb/src/index.ts index 906dc7c429..961af161d2 100644 --- a/integrations/aws-dynamodb/src/index.ts +++ b/integrations/aws-dynamodb/src/index.ts @@ -6,15 +6,21 @@ import { createTable, deleteItem, deleteTable, + describeBackup, + describeStream, describeTable, executePartiql, getItem, + getStreamRecords, + listStreams, listTables, manageBackups, manageTtl, putItem, queryItems, + restoreTableFromBackup, scanItems, + transactGetItems, transactWrite, updateItem, updateTable @@ -38,9 +44,15 @@ export let provider = Slate.create({ executePartiql, batchWriteItems, batchGetItems, + transactGetItems, transactWrite, + listStreams, + describeStream, + getStreamRecords, manageTtl, - manageBackups + manageBackups, + describeBackup, + restoreTableFromBackup ], triggers: [inboundWebhook, streamChanges] }); diff --git a/integrations/aws-dynamodb/src/lib/client.ts b/integrations/aws-dynamodb/src/lib/client.ts index f7883963b3..6ea425c237 100644 --- a/integrations/aws-dynamodb/src/lib/client.ts +++ b/integrations/aws-dynamodb/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { dynamoDbApiError } from './errors'; import { signRequest } from './signing'; export interface DynamoDBClientConfig { @@ -97,11 +98,15 @@ export class DynamoDBClient { baseURL: this.endpoint }); - let response = await axiosInstance.post('/', body, { - headers: signedHeaders - }); + try { + let response = await axiosInstance.post('/', body, { + headers: signedHeaders + }); - return response.data; + return response.data; + } catch (error) { + throw dynamoDbApiError(error, target); + } } private async streamsRequest(target: string, payload: Record): Promise { @@ -126,8 +131,12 @@ export class DynamoDBClient { }); let axiosInstance = createAxios({ baseURL: endpoint }); - let response = await axiosInstance.post('/', body, { headers: signedHeaders }); - return response.data; + try { + let response = await axiosInstance.post('/', body, { headers: signedHeaders }); + return response.data; + } catch (error) { + throw dynamoDbApiError(error, `DynamoDB Streams ${target}`); + } } // Table Management @@ -146,6 +155,7 @@ export class DynamoDBClient { StreamViewType?: 'KEYS_ONLY' | 'NEW_IMAGE' | 'OLD_IMAGE' | 'NEW_AND_OLD_IMAGES'; }; tags?: { Key: string; Value: string }[]; + deletionProtectionEnabled?: boolean; }): Promise { let payload: Record = { TableName: params.tableName, @@ -163,6 +173,8 @@ export class DynamoDBClient { if (params.tableClass) payload.TableClass = params.tableClass; if (params.streamSpecification) payload.StreamSpecification = params.streamSpecification; if (params.tags) payload.Tags = params.tags; + if (params.deletionProtectionEnabled !== undefined) + payload.DeletionProtectionEnabled = params.deletionProtectionEnabled; return this.request('CreateTable', payload); } @@ -196,6 +208,7 @@ export class DynamoDBClient { StreamViewType?: 'KEYS_ONLY' | 'NEW_IMAGE' | 'OLD_IMAGE' | 'NEW_AND_OLD_IMAGES'; }; tableClass?: 'STANDARD' | 'STANDARD_INFREQUENT_ACCESS'; + deletionProtectionEnabled?: boolean; }): Promise { let payload: Record = { TableName: params.tableName @@ -207,6 +220,8 @@ export class DynamoDBClient { payload.GlobalSecondaryIndexUpdates = params.globalSecondaryIndexUpdates; if (params.streamSpecification) payload.StreamSpecification = params.streamSpecification; if (params.tableClass) payload.TableClass = params.tableClass; + if (params.deletionProtectionEnabled !== undefined) + payload.DeletionProtectionEnabled = params.deletionProtectionEnabled; return this.request('UpdateTable', payload); } @@ -451,10 +466,14 @@ export class DynamoDBClient { ExpressionAttributeNames?: Record; }; }>; + returnConsumedCapacity?: 'INDEXES' | 'TOTAL' | 'NONE'; }): Promise { - return this.request('TransactGetItems', { + let payload: Record = { TransactItems: params.transactItems - }); + }; + if (params.returnConsumedCapacity) + payload.ReturnConsumedCapacity = params.returnConsumedCapacity; + return this.request('TransactGetItems', payload); } // PartiQL @@ -580,6 +599,26 @@ export class DynamoDBClient { return this.request('DeleteBackup', { BackupArn: backupArn }); } + async describeBackup(backupArn: string): Promise { + return this.request('DescribeBackup', { BackupArn: backupArn }); + } + + async restoreTableFromBackup(params: { + backupArn: string; + targetTableName: string; + billingModeOverride?: 'PROVISIONED' | 'PAY_PER_REQUEST'; + provisionedThroughputOverride?: ProvisionedThroughput; + }): Promise { + let payload: Record = { + BackupArn: params.backupArn, + TargetTableName: params.targetTableName + }; + if (params.billingModeOverride) payload.BillingModeOverride = params.billingModeOverride; + if (params.provisionedThroughputOverride) + payload.ProvisionedThroughputOverride = params.provisionedThroughputOverride; + return this.request('RestoreTableFromBackup', payload); + } + async describeContinuousBackups(tableName: string): Promise { return this.request('DescribeContinuousBackups', { TableName: tableName }); } diff --git a/integrations/aws-dynamodb/src/lib/errors.ts b/integrations/aws-dynamodb/src/lib/errors.ts new file mode 100644 index 0000000000..0f95333aa8 --- /dev/null +++ b/integrations/aws-dynamodb/src/lib/errors.ts @@ -0,0 +1,107 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushString = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let extractDynamoDbMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let messages: string[] = []; + + if (isRecord(data)) { + for (let key of ['message', 'Message', 'error', 'Error', 'code', 'Code', '__type']) { + pushString(messages, data[key]); + } + } else { + pushString(messages, data); + } + + if (isRecord(error)) { + for (let key of ['message', 'Message', 'name', 'Code', 'code', '__type']) { + pushString(messages, error[key]); + } + } + + if (error instanceof Error) { + pushString(messages, error.message); + } + + return messages.length > 0 ? [...new Set(messages)].join(' - ') : 'Unknown error'; +}; + +let extractDynamoDbStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + let metadata = isRecord(error.$metadata) ? error.$metadata : undefined; + let status = + response?.status ?? metadata?.httpStatusCode ?? error.statusCode ?? error.status; + + return typeof status === 'number' || typeof status === 'string' ? status : undefined; +}; + +let extractDynamoDbCode = (error: unknown) => { + if (!isRecord(error)) return undefined; + + if (typeof error.Code === 'string') return error.Code; + if (typeof error.code === 'string' && !error.code.startsWith('upstream.')) { + return error.code; + } + if (typeof error.name === 'string' && error.name !== 'Error') return error.name; + + let response = error.response as ErrorResponse | undefined; + let data = response?.data; + if (isRecord(data)) { + if (typeof data.Code === 'string') return data.Code; + if (typeof data.code === 'string') return data.code; + if (typeof data.__type === 'string') return data.__type.split('#').at(-1); + } + + return undefined; +}; + +export let dynamoDbServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let dynamoDbApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = extractDynamoDbStatus(error); + let code = extractDynamoDbCode(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + let codeLabel = code ? `${code} - ` : ''; + let serviceError = dynamoDbServiceError( + `Amazon DynamoDB API ${operation} failed: ${statusLabel}${codeLabel}${extractDynamoDbMessage(error)}` + ); + + serviceError.data.reason = 'aws_dynamodb_api_error'; + serviceError.data.upstreamStatus = status; + serviceError.data.upstreamCode = code; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/aws-dynamodb/src/tools/batch-write-items.ts b/integrations/aws-dynamodb/src/tools/batch-write-items.ts index 20b6e95596..96db55d61e 100644 --- a/integrations/aws-dynamodb/src/tools/batch-write-items.ts +++ b/integrations/aws-dynamodb/src/tools/batch-write-items.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { dynamoDbServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -69,14 +70,16 @@ Supports up to 25 put/delete operations per batch. Does not support update opera let requestItems: Record = {}; for (let [tableName, ops] of Object.entries(ctx.input.operations)) { - requestItems[tableName] = ops.map(op => { + requestItems[tableName] = ops.map((op, index) => { + if ((op.putItem ? 1 : 0) + (op.deleteKey ? 1 : 0) !== 1) { + throw dynamoDbServiceError( + `Operation ${index + 1} for table ${tableName} must include exactly one of putItem or deleteKey.` + ); + } if (op.putItem) { return { PutRequest: { Item: op.putItem } }; } - if (op.deleteKey) { - return { DeleteRequest: { Key: op.deleteKey } }; - } - return {}; + return { DeleteRequest: { Key: op.deleteKey } }; }); } diff --git a/integrations/aws-dynamodb/src/tools/create-table.ts b/integrations/aws-dynamodb/src/tools/create-table.ts index 5eb3e42e23..567215ea8f 100644 --- a/integrations/aws-dynamodb/src/tools/create-table.ts +++ b/integrations/aws-dynamodb/src/tools/create-table.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { dynamoDbServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -86,6 +87,11 @@ Supports configuring billing mode (on-demand or provisioned), table class, Dynam .enum(['STANDARD', 'STANDARD_INFREQUENT_ACCESS']) .optional() .describe('Table class'), + deletionProtectionEnabled: z + .boolean() + .optional() + .default(false) + .describe('Enable deletion protection on the table'), enableStreams: z.boolean().optional().describe('Enable DynamoDB Streams'), streamViewType: z .enum(['KEYS_ONLY', 'NEW_IMAGE', 'OLD_IMAGE', 'NEW_AND_OLD_IMAGES']) @@ -107,11 +113,25 @@ Supports configuring billing mode (on-demand or provisioned), table class, Dynam tableName: z.string().describe('Name of the created table'), tableArn: z.string().describe('ARN of the created table'), tableStatus: z.string().describe('Current status of the table'), - tableId: z.string().optional().describe('Unique identifier of the table') + tableId: z.string().optional().describe('Unique identifier of the table'), + deletionProtectionEnabled: z + .boolean() + .optional() + .describe('Whether deletion protection is enabled') }) ) .handleInvocation(async ctx => { let client = createClient(ctx.config, ctx.auth); + if (ctx.input.billingMode === 'PROVISIONED' && !ctx.input.provisionedThroughput) { + throw dynamoDbServiceError( + 'provisionedThroughput is required when billingMode is PROVISIONED.' + ); + } + if (ctx.input.billingMode === 'PAY_PER_REQUEST' && ctx.input.provisionedThroughput) { + throw dynamoDbServiceError( + 'provisionedThroughput cannot be set when billingMode is PAY_PER_REQUEST.' + ); + } let result = await client.createTable({ tableName: ctx.input.tableName, @@ -159,6 +179,7 @@ Supports configuring billing mode (on-demand or provisioned), table class, Dynam } })), tableClass: ctx.input.tableClass, + deletionProtectionEnabled: ctx.input.deletionProtectionEnabled, streamSpecification: ctx.input.enableStreams ? { StreamEnabled: true, @@ -178,7 +199,8 @@ Supports configuring billing mode (on-demand or provisioned), table class, Dynam tableName: tableDesc.TableName, tableArn: tableDesc.TableArn, tableStatus: tableDesc.TableStatus, - tableId: tableDesc.TableId + tableId: tableDesc.TableId, + deletionProtectionEnabled: tableDesc.DeletionProtectionEnabled }, message: `Created table **${tableDesc.TableName}** (status: ${tableDesc.TableStatus})` }; diff --git a/integrations/aws-dynamodb/src/tools/describe-backup.ts b/integrations/aws-dynamodb/src/tools/describe-backup.ts new file mode 100644 index 0000000000..1e00dd8e13 --- /dev/null +++ b/integrations/aws-dynamodb/src/tools/describe-backup.ts @@ -0,0 +1,64 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let describeBackup = SlateTool.create(spec, { + name: 'Describe Backup', + key: 'describe_backup', + description: + 'Describe an existing DynamoDB on-demand backup, including backup status and source table details.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + backupArn: z.string().describe('ARN of the DynamoDB backup') + }) + ) + .output( + z.object({ + backupArn: z.string().describe('ARN of the backup'), + backupName: z.string().optional().describe('Name of the backup'), + backupStatus: z.string().optional().describe('Current backup status'), + backupType: z.string().optional().describe('Backup type'), + backupSizeBytes: z.number().optional().describe('Backup size in bytes'), + backupCreationTimestamp: z.string().optional().describe('When the backup was created'), + backupExpiryTimestamp: z.string().optional().describe('When the backup expires'), + sourceTableName: z.string().optional().describe('Source table name'), + sourceTableArn: z.string().optional().describe('Source table ARN'), + sourceTableId: z.string().optional().describe('Source table ID'), + sourceTableSizeBytes: z.number().optional().describe('Source table size in bytes'), + sourceItemCount: z.number().optional().describe('Approximate source item count') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.config, ctx.auth); + let result = await client.describeBackup(ctx.input.backupArn); + let details = result.BackupDescription?.BackupDetails || {}; + let sourceTable = result.BackupDescription?.SourceTableDetails || {}; + + return { + output: { + backupArn: details.BackupArn || ctx.input.backupArn, + backupName: details.BackupName, + backupStatus: details.BackupStatus, + backupType: details.BackupType, + backupSizeBytes: details.BackupSizeBytes, + backupCreationTimestamp: details.BackupCreationDateTime + ? String(details.BackupCreationDateTime) + : undefined, + backupExpiryTimestamp: details.BackupExpiryDateTime + ? String(details.BackupExpiryDateTime) + : undefined, + sourceTableName: sourceTable.TableName, + sourceTableArn: sourceTable.TableArn, + sourceTableId: sourceTable.TableId, + sourceTableSizeBytes: sourceTable.TableSizeBytes, + sourceItemCount: sourceTable.ItemCount + }, + message: `Backup **${details.BackupName || ctx.input.backupArn}** is **${details.BackupStatus || 'UNKNOWN'}**` + }; + }) + .build(); diff --git a/integrations/aws-dynamodb/src/tools/describe-stream.ts b/integrations/aws-dynamodb/src/tools/describe-stream.ts new file mode 100644 index 0000000000..d689feef51 --- /dev/null +++ b/integrations/aws-dynamodb/src/tools/describe-stream.ts @@ -0,0 +1,89 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let describeStream = SlateTool.create(spec, { + name: 'Describe Stream', + key: 'describe_stream', + description: + 'Describe a DynamoDB Stream, including status, table name, key schema, and shards.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + streamArn: z.string().describe('ARN of the DynamoDB Stream'), + limit: z.number().optional().describe('Maximum number of shards to return'), + exclusiveStartShardId: z + .string() + .optional() + .describe('Shard ID from the previous page for pagination') + }) + ) + .output( + z.object({ + streamArn: z.string().describe('ARN of the stream'), + tableName: z.string().optional().describe('Table associated with the stream'), + streamStatus: z.string().optional().describe('Current stream status'), + streamViewType: z.string().optional().describe('Stream view type'), + streamLabel: z.string().optional().describe('Stream label'), + creationTimestamp: z.string().optional().describe('When the stream was created'), + keySchema: z + .array( + z.object({ + attributeName: z.string(), + keyType: z.string() + }) + ) + .describe('Table key schema for stream records'), + shards: z + .array( + z.object({ + shardId: z.string(), + parentShardId: z.string().optional(), + startingSequenceNumber: z.string().optional(), + endingSequenceNumber: z.string().optional() + }) + ) + .describe('Stream shards'), + lastEvaluatedShardId: z.string().optional().describe('Pagination token') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.config, ctx.auth); + let result = await client.describeStream({ + streamArn: ctx.input.streamArn, + limit: ctx.input.limit, + exclusiveStartShardId: ctx.input.exclusiveStartShardId + }); + let stream = result.StreamDescription || {}; + let shards = (stream.Shards || []).map((shard: any) => ({ + shardId: shard.ShardId, + parentShardId: shard.ParentShardId, + startingSequenceNumber: shard.SequenceNumberRange?.StartingSequenceNumber, + endingSequenceNumber: shard.SequenceNumberRange?.EndingSequenceNumber + })); + + return { + output: { + streamArn: stream.StreamArn || ctx.input.streamArn, + tableName: stream.TableName, + streamStatus: stream.StreamStatus, + streamViewType: stream.StreamViewType, + streamLabel: stream.StreamLabel, + creationTimestamp: stream.CreationRequestDateTime + ? String(stream.CreationRequestDateTime) + : undefined, + keySchema: (stream.KeySchema || []).map((key: any) => ({ + attributeName: key.AttributeName, + keyType: key.KeyType + })), + shards, + lastEvaluatedShardId: stream.LastEvaluatedShardId + }, + message: `Stream **${stream.StreamArn || ctx.input.streamArn}** is **${stream.StreamStatus || 'UNKNOWN'}** with **${shards.length}** shards${stream.LastEvaluatedShardId ? ' (more available)' : ''}` + }; + }) + .build(); diff --git a/integrations/aws-dynamodb/src/tools/describe-table.ts b/integrations/aws-dynamodb/src/tools/describe-table.ts index 3a1766d29f..a447d34e2e 100644 --- a/integrations/aws-dynamodb/src/tools/describe-table.ts +++ b/integrations/aws-dynamodb/src/tools/describe-table.ts @@ -81,7 +81,11 @@ export let describeTable = SlateTool.create(spec, { streamEnabled: z.boolean().optional(), streamViewType: z.string().optional(), latestStreamArn: z.string().optional(), - tableClass: z.string().optional() + tableClass: z.string().optional(), + deletionProtectionEnabled: z + .boolean() + .optional() + .describe('Whether deletion protection is enabled') }) ) .handleInvocation(async ctx => { @@ -134,7 +138,8 @@ export let describeTable = SlateTool.create(spec, { streamEnabled: table.StreamSpecification?.StreamEnabled, streamViewType: table.StreamSpecification?.StreamViewType, latestStreamArn: table.LatestStreamArn, - tableClass: table.TableClassSummary?.TableClass + tableClass: table.TableClassSummary?.TableClass, + deletionProtectionEnabled: table.DeletionProtectionEnabled }, message: `Table **${table.TableName}** is ${table.TableStatus} with ~${table.ItemCount ?? 0} items (${table.TableSizeBytes ?? 0} bytes)` }; diff --git a/integrations/aws-dynamodb/src/tools/get-stream-records.ts b/integrations/aws-dynamodb/src/tools/get-stream-records.ts new file mode 100644 index 0000000000..8fb878e98b --- /dev/null +++ b/integrations/aws-dynamodb/src/tools/get-stream-records.ts @@ -0,0 +1,114 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { dynamoDbServiceError } from '../lib/errors'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getStreamRecords = SlateTool.create(spec, { + name: 'Get Stream Records', + key: 'get_stream_records', + description: `Read records from a DynamoDB Stream shard. +The tool creates a shard iterator for the requested position and returns the next page of stream records.`, + constraints: [ + 'DynamoDB Streams retain records for up to 24 hours', + 'GetRecords returns at most 1 MB or 1000 records per call' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + streamArn: z.string().describe('ARN of the DynamoDB Stream'), + shardId: z.string().describe('Shard ID to read from'), + shardIteratorType: z + .enum(['TRIM_HORIZON', 'LATEST', 'AT_SEQUENCE_NUMBER', 'AFTER_SEQUENCE_NUMBER']) + .optional() + .default('TRIM_HORIZON') + .describe('Position in the shard to start reading from'), + sequenceNumber: z + .string() + .optional() + .describe( + 'Required when shardIteratorType is AT_SEQUENCE_NUMBER or AFTER_SEQUENCE_NUMBER' + ), + limit: z.number().optional().describe('Maximum number of records to return') + }) + ) + .output( + z.object({ + records: z + .array( + z.object({ + eventId: z.string().optional(), + eventName: z.string().optional(), + awsRegion: z.string().optional(), + keys: z.record(z.string(), z.any()).optional(), + newImage: z.record(z.string(), z.any()).optional(), + oldImage: z.record(z.string(), z.any()).optional(), + sequenceNumber: z.string().optional(), + approximateCreationTimestamp: z.string().optional(), + sizeBytes: z.number().optional(), + streamViewType: z.string().optional() + }) + ) + .describe('Stream records read from the shard'), + nextShardIterator: z + .string() + .optional() + .describe('Iterator to continue reading from this shard'), + count: z.number().describe('Number of records returned') + }) + ) + .handleInvocation(async ctx => { + let shardIteratorType = ctx.input.shardIteratorType ?? 'TRIM_HORIZON'; + let requiresSequenceNumber = + shardIteratorType === 'AT_SEQUENCE_NUMBER' || + shardIteratorType === 'AFTER_SEQUENCE_NUMBER'; + if (requiresSequenceNumber && !ctx.input.sequenceNumber) { + throw dynamoDbServiceError( + 'sequenceNumber is required when shardIteratorType is AT_SEQUENCE_NUMBER or AFTER_SEQUENCE_NUMBER.' + ); + } + + let client = createClient(ctx.config, ctx.auth); + let iteratorResult = await client.getShardIterator({ + streamArn: ctx.input.streamArn, + shardId: ctx.input.shardId, + shardIteratorType, + sequenceNumber: ctx.input.sequenceNumber + }); + + if (!iteratorResult.ShardIterator) { + throw dynamoDbServiceError('DynamoDB did not return a shard iterator.'); + } + + let result = await client.getRecords({ + shardIterator: iteratorResult.ShardIterator, + limit: ctx.input.limit + }); + let records = (result.Records || []).map((record: any) => ({ + eventId: record.eventID, + eventName: record.eventName, + awsRegion: record.awsRegion, + keys: record.dynamodb?.Keys, + newImage: record.dynamodb?.NewImage, + oldImage: record.dynamodb?.OldImage, + sequenceNumber: record.dynamodb?.SequenceNumber, + approximateCreationTimestamp: record.dynamodb?.ApproximateCreationDateTime + ? String(record.dynamodb.ApproximateCreationDateTime) + : undefined, + sizeBytes: record.dynamodb?.SizeBytes, + streamViewType: record.dynamodb?.StreamViewType + })); + + return { + output: { + records, + nextShardIterator: result.NextShardIterator, + count: records.length + }, + message: `Read **${records.length}** stream records from shard **${ctx.input.shardId}**` + }; + }) + .build(); diff --git a/integrations/aws-dynamodb/src/tools/index.ts b/integrations/aws-dynamodb/src/tools/index.ts index 20008c3a89..2943829d1a 100644 --- a/integrations/aws-dynamodb/src/tools/index.ts +++ b/integrations/aws-dynamodb/src/tools/index.ts @@ -3,15 +3,21 @@ export { batchWriteItems } from './batch-write-items'; export { createTable } from './create-table'; export { deleteItem } from './delete-item'; export { deleteTable } from './delete-table'; +export { describeBackup } from './describe-backup'; +export { describeStream } from './describe-stream'; export { describeTable } from './describe-table'; export { executePartiql } from './execute-partiql'; export { getItem } from './get-item'; +export { getStreamRecords } from './get-stream-records'; +export { listStreams } from './list-streams'; export { listTables } from './list-tables'; export { manageBackups } from './manage-backups'; export { manageTtl } from './manage-ttl'; export { putItem } from './put-item'; export { queryItems } from './query-items'; +export { restoreTableFromBackup } from './restore-table-from-backup'; export { scanItems } from './scan-items'; +export { transactGetItems } from './transact-get-items'; export { transactWrite } from './transact-write'; export { updateItem } from './update-item'; export { updateTable } from './update-table'; diff --git a/integrations/aws-dynamodb/src/tools/list-streams.ts b/integrations/aws-dynamodb/src/tools/list-streams.ts new file mode 100644 index 0000000000..cdc336df84 --- /dev/null +++ b/integrations/aws-dynamodb/src/tools/list-streams.ts @@ -0,0 +1,66 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listStreams = SlateTool.create(spec, { + name: 'List Streams', + key: 'list_streams', + description: + 'List DynamoDB Streams in the configured region, optionally filtered to one table.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + tableName: z + .string() + .optional() + .describe('Only return streams associated with this table'), + limit: z.number().optional().describe('Maximum number of streams to return'), + exclusiveStartStreamArn: z + .string() + .optional() + .describe('Stream ARN from the previous page for pagination') + }) + ) + .output( + z.object({ + streams: z + .array( + z.object({ + streamArn: z.string(), + tableName: z.string().optional(), + streamLabel: z.string().optional() + }) + ) + .describe('Stream descriptors'), + lastEvaluatedStreamArn: z + .string() + .optional() + .describe('Pagination token for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.config, ctx.auth); + let result = await client.listStreams({ + tableName: ctx.input.tableName, + limit: ctx.input.limit, + exclusiveStartStreamArn: ctx.input.exclusiveStartStreamArn + }); + let streams = (result.Streams || []).map((stream: any) => ({ + streamArn: stream.StreamArn, + tableName: stream.TableName, + streamLabel: stream.StreamLabel + })); + + return { + output: { + streams, + lastEvaluatedStreamArn: result.LastEvaluatedStreamArn + }, + message: `Found **${streams.length}** DynamoDB streams${ctx.input.tableName ? ` for **${ctx.input.tableName}**` : ''}${result.LastEvaluatedStreamArn ? ' (more available)' : ''}` + }; + }) + .build(); diff --git a/integrations/aws-dynamodb/src/tools/manage-backups.ts b/integrations/aws-dynamodb/src/tools/manage-backups.ts index 5eb0ccbb9f..41e67b199b 100644 --- a/integrations/aws-dynamodb/src/tools/manage-backups.ts +++ b/integrations/aws-dynamodb/src/tools/manage-backups.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { dynamoDbServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -24,7 +25,15 @@ export let manageBackups = SlateTool.create(spec, { ), backupName: z.string().optional().describe('Name for the backup (required for create)'), backupArn: z.string().optional().describe('ARN of the backup (required for delete)'), - limit: z.number().optional().describe('Maximum number of backups to list') + limit: z.number().optional().describe('Maximum number of backups to list'), + exclusiveStartBackupArn: z + .string() + .optional() + .describe('Backup ARN from the previous page for list pagination'), + backupType: z + .enum(['USER', 'SYSTEM', 'AWS_BACKUP', 'ALL']) + .optional() + .describe('Backup type filter for list') }) ) .output( @@ -52,7 +61,11 @@ export let manageBackups = SlateTool.create(spec, { latestRestorableTimestamp: z .string() .optional() - .describe('Latest point you can restore to') + .describe('Latest point you can restore to'), + lastEvaluatedBackupArn: z + .string() + .optional() + .describe('Pagination token for the next backup list page') }) ) .handleInvocation(async ctx => { @@ -60,7 +73,9 @@ export let manageBackups = SlateTool.create(spec, { if (ctx.input.action === 'create') { if (!ctx.input.tableName || !ctx.input.backupName) { - throw new Error('tableName and backupName are required for creating a backup'); + throw dynamoDbServiceError( + 'tableName and backupName are required for creating a backup.' + ); } let result = await client.createBackup({ tableName: ctx.input.tableName, @@ -79,7 +94,9 @@ export let manageBackups = SlateTool.create(spec, { if (ctx.input.action === 'list') { let result = await client.listBackups({ tableName: ctx.input.tableName, - limit: ctx.input.limit + limit: ctx.input.limit, + exclusiveStartBackupArn: ctx.input.exclusiveStartBackupArn, + backupType: ctx.input.backupType }); let backups = (result.BackupSummaries || []).map((b: any) => ({ backupArn: b.BackupArn, @@ -91,14 +108,14 @@ export let manageBackups = SlateTool.create(spec, { : undefined })); return { - output: { backups }, + output: { backups, lastEvaluatedBackupArn: result.LastEvaluatedBackupArn }, message: `Found **${backups.length}** backups${ctx.input.tableName ? ` for table **${ctx.input.tableName}**` : ''}` }; } if (ctx.input.action === 'delete') { if (!ctx.input.backupArn) { - throw new Error('backupArn is required for deleting a backup'); + throw dynamoDbServiceError('backupArn is required for deleting a backup.'); } let result = await client.deleteBackup(ctx.input.backupArn); let details = result.BackupDescription?.BackupDetails; @@ -113,7 +130,7 @@ export let manageBackups = SlateTool.create(spec, { if (ctx.input.action === 'describe_pitr') { if (!ctx.input.tableName) { - throw new Error('tableName is required for describing PITR'); + throw dynamoDbServiceError('tableName is required for describing PITR.'); } let result = await client.describeContinuousBackups(ctx.input.tableName); let pitr = result.ContinuousBackupsDescription?.PointInTimeRecoveryDescription; @@ -134,7 +151,7 @@ export let manageBackups = SlateTool.create(spec, { if (ctx.input.action === 'enable_pitr' || ctx.input.action === 'disable_pitr') { if (!ctx.input.tableName) { - throw new Error('tableName is required for updating PITR'); + throw dynamoDbServiceError('tableName is required for updating PITR.'); } let enabled = ctx.input.action === 'enable_pitr'; await client.updateContinuousBackups({ @@ -150,6 +167,6 @@ export let manageBackups = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw dynamoDbServiceError(`Unknown backup action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/aws-dynamodb/src/tools/manage-ttl.ts b/integrations/aws-dynamodb/src/tools/manage-ttl.ts index 287d4e80a6..7860505fc9 100644 --- a/integrations/aws-dynamodb/src/tools/manage-ttl.ts +++ b/integrations/aws-dynamodb/src/tools/manage-ttl.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { dynamoDbServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -23,7 +24,9 @@ When enabled, items with an expired TTL attribute are automatically deleted. Use ttlAttributeName: z .string() .optional() - .describe('Name of the attribute containing the TTL timestamp (required for enable)') + .describe( + 'Name of the attribute containing the TTL timestamp (required for enable and disable)' + ) }) ) .output( @@ -49,14 +52,16 @@ When enabled, items with an expired TTL attribute are automatically deleted. Use }; } - if (!ctx.input.ttlAttributeName && ctx.input.action === 'enable') { - throw new Error('ttlAttributeName is required when enabling TTL'); + if (!ctx.input.ttlAttributeName) { + throw dynamoDbServiceError( + 'ttlAttributeName is required when enabling or disabling TTL.' + ); } let result = await client.updateTimeToLive({ tableName: ctx.input.tableName, enabled: ctx.input.action === 'enable', - attributeName: ctx.input.ttlAttributeName || '' + attributeName: ctx.input.ttlAttributeName! }); let ttlSpec = result.TimeToLiveSpecification; diff --git a/integrations/aws-dynamodb/src/tools/restore-table-from-backup.ts b/integrations/aws-dynamodb/src/tools/restore-table-from-backup.ts new file mode 100644 index 0000000000..34bd1a52df --- /dev/null +++ b/integrations/aws-dynamodb/src/tools/restore-table-from-backup.ts @@ -0,0 +1,82 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { dynamoDbServiceError } from '../lib/errors'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let restoreTableFromBackup = SlateTool.create(spec, { + name: 'Restore Table From Backup', + key: 'restore_table_from_backup', + description: 'Create a new DynamoDB table by restoring an existing on-demand backup.', + tags: { + destructive: false + } +}) + .input( + z.object({ + backupArn: z.string().describe('ARN of the backup to restore'), + targetTableName: z.string().describe('Name of the new table to create'), + billingModeOverride: z + .enum(['PROVISIONED', 'PAY_PER_REQUEST']) + .optional() + .describe('Optional billing mode override for the restored table'), + provisionedThroughputOverride: z + .object({ + readCapacityUnits: z.number().describe('Read capacity units'), + writeCapacityUnits: z.number().describe('Write capacity units') + }) + .optional() + .describe('Required when billingModeOverride is PROVISIONED') + }) + ) + .output( + z.object({ + tableName: z.string().describe('Name of the restored table'), + tableArn: z.string().optional().describe('ARN of the restored table'), + tableStatus: z.string().describe('Current table status'), + tableId: z.string().optional().describe('Unique table identifier') + }) + ) + .handleInvocation(async ctx => { + if ( + ctx.input.billingModeOverride === 'PROVISIONED' && + !ctx.input.provisionedThroughputOverride + ) { + throw dynamoDbServiceError( + 'provisionedThroughputOverride is required when billingModeOverride is PROVISIONED.' + ); + } + if ( + ctx.input.billingModeOverride === 'PAY_PER_REQUEST' && + ctx.input.provisionedThroughputOverride + ) { + throw dynamoDbServiceError( + 'provisionedThroughputOverride cannot be set when billingModeOverride is PAY_PER_REQUEST.' + ); + } + + let client = createClient(ctx.config, ctx.auth); + let result = await client.restoreTableFromBackup({ + backupArn: ctx.input.backupArn, + targetTableName: ctx.input.targetTableName, + billingModeOverride: ctx.input.billingModeOverride, + provisionedThroughputOverride: ctx.input.provisionedThroughputOverride + ? { + ReadCapacityUnits: ctx.input.provisionedThroughputOverride.readCapacityUnits, + WriteCapacityUnits: ctx.input.provisionedThroughputOverride.writeCapacityUnits + } + : undefined + }); + let table = result.TableDescription; + + return { + output: { + tableName: table.TableName, + tableArn: table.TableArn, + tableStatus: table.TableStatus, + tableId: table.TableId + }, + message: `Restoring backup to table **${table.TableName}** (status: ${table.TableStatus})` + }; + }) + .build(); diff --git a/integrations/aws-dynamodb/src/tools/transact-get-items.ts b/integrations/aws-dynamodb/src/tools/transact-get-items.ts new file mode 100644 index 0000000000..80d4a08ffd --- /dev/null +++ b/integrations/aws-dynamodb/src/tools/transact-get-items.ts @@ -0,0 +1,109 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +let attributeValueSchema: z.ZodType = z.lazy(() => + z.object({ + S: z.string().optional(), + N: z.string().optional(), + B: z.string().optional(), + SS: z.array(z.string()).optional(), + NS: z.array(z.string()).optional(), + BS: z.array(z.string()).optional(), + M: z.record(z.string(), attributeValueSchema).optional(), + L: z.array(attributeValueSchema).optional(), + NULL: z.boolean().optional(), + BOOL: z.boolean().optional() + }) +); + +export let transactGetItems = SlateTool.create(spec, { + name: 'Transact Get Items', + key: 'transact_get_items', + description: `Atomically retrieve up to 100 items from one or more DynamoDB tables in the same account and region. +Use this when multiple strongly related reads must succeed or fail together.`, + constraints: [ + 'Maximum 100 item reads per transaction', + 'The aggregate size of retrieved items cannot exceed 4 MB', + 'Transactions cannot read from secondary indexes' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + items: z + .array( + z.object({ + tableName: z.string().describe('Name of the table to read from'), + key: z + .record(z.string(), attributeValueSchema) + .describe('Primary key of the item in DynamoDB JSON format'), + projectionExpression: z + .string() + .optional() + .describe('Projection expression for this item'), + expressionAttributeNames: z + .record(z.string(), z.string()) + .optional() + .describe('Attribute name substitutions for the projection expression') + }) + ) + .min(1) + .max(100) + .describe('Items to retrieve transactionally'), + returnConsumedCapacity: z + .enum(['INDEXES', 'TOTAL', 'NONE']) + .optional() + .default('NONE') + .describe('Whether to return consumed capacity details') + }) + ) + .output( + z.object({ + responses: z + .array( + z.object({ + index: z.number().describe('Zero-based position matching the requested item'), + found: z.boolean().describe('Whether the item was found'), + item: z + .record(z.string(), z.any()) + .optional() + .describe('Retrieved item in DynamoDB JSON format') + }) + ) + .describe('Transactional read responses in request order'), + consumedCapacity: z.array(z.any()).optional().describe('Consumed capacity details') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.config, ctx.auth); + let result = await client.transactGetItems({ + transactItems: ctx.input.items.map(item => ({ + Get: { + TableName: item.tableName, + Key: item.key, + ProjectionExpression: item.projectionExpression, + ExpressionAttributeNames: item.expressionAttributeNames + } + })), + returnConsumedCapacity: ctx.input.returnConsumedCapacity + }); + + let responses = (result.Responses || []).map((response: any, index: number) => ({ + index, + found: response?.Item !== undefined && response.Item !== null, + item: response?.Item + })); + + return { + output: { + responses, + consumedCapacity: result.ConsumedCapacity + }, + message: `Transactional read returned **${responses.filter((response: { found: boolean }) => response.found).length}** of **${ctx.input.items.length}** requested items` + }; + }) + .build(); diff --git a/integrations/aws-dynamodb/src/tools/transact-write.ts b/integrations/aws-dynamodb/src/tools/transact-write.ts index 076ce149bc..86f2d8b4fe 100644 --- a/integrations/aws-dynamodb/src/tools/transact-write.ts +++ b/integrations/aws-dynamodb/src/tools/transact-write.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { dynamoDbServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -107,7 +108,18 @@ All actions succeed or all fail together (ACID). Supports Put, Update, Delete, a .handleInvocation(async ctx => { let client = createClient(ctx.config, ctx.auth); - let transactItems = ctx.input.transactItems.map(item => { + let transactItems = ctx.input.transactItems.map((item, index) => { + let actionCount = + (item.put ? 1 : 0) + + (item.update ? 1 : 0) + + (item.delete ? 1 : 0) + + (item.conditionCheck ? 1 : 0); + if (actionCount !== 1) { + throw dynamoDbServiceError( + `Transaction item ${index + 1} must include exactly one of put, update, delete, or conditionCheck.` + ); + } + let result: any = {}; if (item.put) { result.Put = { diff --git a/integrations/aws-dynamodb/src/tools/update-table.ts b/integrations/aws-dynamodb/src/tools/update-table.ts index 871fd28f0b..ffca5c8777 100644 --- a/integrations/aws-dynamodb/src/tools/update-table.ts +++ b/integrations/aws-dynamodb/src/tools/update-table.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { dynamoDbServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -34,17 +35,34 @@ Can also be used to manage global secondary indexes (create, update, or delete). tableClass: z .enum(['STANDARD', 'STANDARD_INFREQUENT_ACCESS']) .optional() - .describe('New table class') + .describe('New table class'), + deletionProtectionEnabled: z + .boolean() + .optional() + .describe('Enable or disable deletion protection') }) ) .output( z.object({ tableName: z.string().describe('Name of the updated table'), - tableStatus: z.string().describe('Current status of the table') + tableStatus: z.string().describe('Current status of the table'), + deletionProtectionEnabled: z + .boolean() + .optional() + .describe('Whether deletion protection is enabled') }) ) .handleInvocation(async ctx => { let client = createClient(ctx.config, ctx.auth); + if ( + ctx.input.billingMode === undefined && + ctx.input.provisionedThroughput === undefined && + ctx.input.enableStreams === undefined && + ctx.input.tableClass === undefined && + ctx.input.deletionProtectionEnabled === undefined + ) { + throw dynamoDbServiceError('Provide at least one table setting to update.'); + } let result = await client.updateTable({ tableName: ctx.input.tableName, @@ -62,7 +80,8 @@ Can also be used to manage global secondary indexes (create, update, or delete). StreamViewType: ctx.input.streamViewType } : undefined, - tableClass: ctx.input.tableClass + tableClass: ctx.input.tableClass, + deletionProtectionEnabled: ctx.input.deletionProtectionEnabled }); let tableDesc = result.TableDescription; @@ -70,7 +89,8 @@ Can also be used to manage global secondary indexes (create, update, or delete). return { output: { tableName: tableDesc.TableName, - tableStatus: tableDesc.TableStatus + tableStatus: tableDesc.TableStatus, + deletionProtectionEnabled: tableDesc.DeletionProtectionEnabled }, message: `Updated table **${tableDesc.TableName}** (status: ${tableDesc.TableStatus})` }; diff --git a/integrations/aws-ses/README.md b/integrations/aws-ses/README.md index 895cfb6a1b..182cf2dc6e 100644 --- a/integrations/aws-ses/README.md +++ b/integrations/aws-ses/README.md @@ -1,6 +1,6 @@ # Aws Ses -Send formatted, raw, and bulk templated emails to recipients. Create and manage reusable email templates with personalization variables. Manage contact lists and topic-based subscription preferences. Verify and configure sending identities (email addresses and domains) with DKIM authentication. Create configuration sets to control delivery options, reputation monitoring, and event tracking. Manage account-level suppression lists for bounces and complaints. Organize dedicated IP addresses into pools for reputation isolation. Monitor deliverability with Virtual Deliverability Manager dashboard and sending statistics. Track email events including sends, deliveries, bounces, complaints, opens, and clicks. Process inbound emails with receipt rules and filters (v1 API). +Send formatted, raw, and bulk templated emails to recipients, including SES v2 attachments, custom headers, tenant routing, and multi-region endpoint routing. Create and manage reusable email templates with personalization variables. Manage contact lists and topic-based subscription preferences. Verify and configure sending identities (email addresses and domains) with DKIM authentication. Create configuration sets to control delivery options, reputation monitoring, and event tracking. Manage account-level suppression lists for bounces and complaints. Organize dedicated IP addresses into pools for reputation isolation. Monitor deliverability with account status, message insights, and email address validation insights. Track email events including sends, deliveries, bounces, complaints, opens, and clicks. Process inbound emails with receipt rules and filters (v1 API). ## Tools @@ -8,13 +8,17 @@ Send formatted, raw, and bulk templated emails to recipients. Create and manage Retrieve SES account details including sending quotas, reputation status, enforcement status, and whether the account has production access. Also shows suppression and Virtual Deliverability Manager (VDM) settings. +### Get Email Address Insights + +Analyze an email address with SES validation insights, including syntax, DNS, disposable-address, role-address, random-input, and mailbox-existence signals. + ### Get Message Insights Retrieve detailed delivery insights for a specific sent email by its message ID. Shows delivery events per recipient, including delivery status, bounces, complaints, opens, and clicks. Useful for troubleshooting delivery issues and tracking individual message outcomes. ### Manage Configuration Set -Create, retrieve, delete, or list SES configuration sets. Configuration sets control delivery options, reputation monitoring, click/open tracking, and suppression behavior for emails. You can also update individual options (sending, reputation, tracking, suppression) on an existing set. +Create, retrieve, delete, or list SES configuration sets. Configuration sets control delivery options, reputation monitoring, click/open tracking, and suppression behavior for emails. You can also update individual options (delivery, sending, reputation, tracking, suppression) on an existing set. ### Manage Contact List @@ -38,7 +42,7 @@ Create, update, retrieve, delete, or list SES email templates. Templates support ### Manage Event Destination -Create, list, or delete event destinations on an SES configuration set. Event destinations publish email sending events (sends, deliveries, bounces, complaints, opens, clicks, etc.) to SNS topics, CloudWatch, or EventBridge for monitoring and alerting. +Create, update, list, or delete event destinations on an SES configuration set. Event destinations publish email sending events (sends, deliveries, bounces, complaints, opens, clicks, etc.) to SNS topics, CloudWatch, Kinesis Data Firehose, Pinpoint, or EventBridge for monitoring and alerting. ### Manage Suppression List @@ -46,11 +50,11 @@ Manage the account-level suppression list in SES. Add, remove, retrieve, or list ### Send Bulk Email -Send a templated email to multiple recipients in bulk. Each recipient can have personalized replacement data. Uses SES email templates for consistent formatting with per-recipient customization. +Send a templated email to multiple recipients in bulk. Each recipient can have personalized replacement data and headers. Uses SES email templates for consistent formatting with per-recipient customization and supports default attachments for every recipient. ### Send Email -Send an email through AWS SES. Supports three content modes: - **Simple**: Provide subject and body (text/HTML) — SES handles MIME formatting. - **Raw**: Supply a complete MIME message for full control over headers and content. - **Template**: Use a pre-created SES template with dynamic replacement data. Emails can be sent to multiple recipients via To, Cc, and Bcc fields. +Send an email through AWS SES. Supports three content modes: - **Simple**: Provide subject and body (text/HTML) — SES handles MIME formatting and can include custom headers and attachments. - **Raw**: Supply a complete MIME message for full control over headers and content. - **Template**: Use a pre-created SES template with dynamic replacement data, custom headers, and attachments. Emails can be sent to multiple recipients via To, Cc, and Bcc fields. ## License diff --git a/integrations/aws-ses/docs/SPEC.md b/integrations/aws-ses/docs/SPEC.md index b60527b3de..86db13b964 100644 --- a/integrations/aws-ses/docs/SPEC.md +++ b/integrations/aws-ses/docs/SPEC.md @@ -35,7 +35,7 @@ AWS SES uses standard AWS authentication mechanisms. All API requests must be si ### Email Sending -Send emails in two modes: **Formatted** (provide From, To, subject, and body — SES handles formatting) or **Raw** (manually compose the full MIME message for complete control over headers and content). Supports sending to multiple recipients with To, Cc, and Bcc fields. Emails can also be sent via SMTP. +Send emails in three SESv2 modes: **Simple** (provide From, To, subject, body, optional headers, and optional attachments), **Raw** (manually compose the full MIME message for complete control over headers and content), or **Template** (use a reusable or ARN-addressed template with replacement data, optional headers, and optional attachments). Supports sending to multiple recipients with To, Cc, and Bcc fields, plus tenant and multi-region endpoint routing. Emails can also be sent via SMTP. ### Email Templates @@ -51,7 +51,7 @@ Verify and manage sending identities (email addresses and domains). Configure DK ### Configuration Sets -Configuration sets are groups of rules that you can apply to the emails that you send. You apply a configuration set to an email by specifying its name when you call the API. All rules in that configuration set are applied to the email. Configuration sets control delivery options, reputation monitoring, tracking options, and suppression behavior. +Configuration sets are groups of rules that you can apply to the emails that you send. You apply a configuration set to an email by specifying its name when you call the API. All rules in that configuration set are applied to the email. Configuration sets control delivery options such as TLS policy and maximum delivery time, reputation monitoring, tracking options, event destinations, and suppression behavior. ### Suppression List Management @@ -65,6 +65,10 @@ Grouping dedicated IPs together in a pool makes them easier to manage. A common The SES Virtual Deliverability Manager provides insights into your sending and delivery data. VDM provides near-realtime advice on how to fix issues negatively affecting your delivery success rate and reputation. Includes dashboard metrics and guardian features for proactive deliverability management. +### Email Address Validation Insights + +SES can analyze a specific email address for validation signals, including syntax, DNS, disposable-address, role-address, random-input, mailbox-existence, and overall validity verdicts. This is useful before adding contacts or sending campaigns to a recipient address. + ### Sending Statistics and Account Management Retrieve account-level sending statistics, quotas, and reputation metrics. View delivery, bounce, and complaint rates. Check whether the account is still in the SES sandbox (which restricts sending to verified addresses only). diff --git a/integrations/aws-ses/package.json b/integrations/aws-ses/package.json index b35d0da335..da8f361ec4 100644 --- a/integrations/aws-ses/package.json +++ b/integrations/aws-ses/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/aws-ses/src/index.ts b/integrations/aws-ses/src/index.ts index b660d635dd..ebb471e476 100644 --- a/integrations/aws-ses/src/index.ts +++ b/integrations/aws-ses/src/index.ts @@ -2,6 +2,7 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { getAccount, + getEmailAddressInsights, getMessageInsights, manageConfigurationSet, manageContact, @@ -30,6 +31,7 @@ export let provider = Slate.create({ manageConfigurationSet, manageDedicatedIpPool, manageEventDestination, + getEmailAddressInsights, getMessageInsights ], triggers: [inboundWebhook, suppressionChanges, identityChanges] diff --git a/integrations/aws-ses/src/lib/client.ts b/integrations/aws-ses/src/lib/client.ts index 24d113a25c..536bc60c85 100644 --- a/integrations/aws-ses/src/lib/client.ts +++ b/integrations/aws-ses/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { awsSesApiError } from './errors'; import { signRequest } from './signing'; export interface SesClientConfig { @@ -8,6 +9,41 @@ export interface SesClientConfig { region: string; } +export interface SesAttachment { + fileName: string; + rawContentBase64: string; + contentType?: string; + contentDisposition?: 'ATTACHMENT' | 'INLINE'; + contentDescription?: string; + contentId?: string; + contentTransferEncoding?: 'BASE64' | 'QUOTED_PRINTABLE' | 'SEVEN_BIT'; +} + +export interface SesHeader { + name: string; + value: string; +} + +let mapHeaders = (headers?: SesHeader[]) => + headers?.map(header => ({ Name: header.name, Value: header.value })); + +let mapAttachments = (attachments?: SesAttachment[]) => + attachments?.map(attachment => ({ + FileName: attachment.fileName, + RawContent: attachment.rawContentBase64, + ...(attachment.contentType ? { ContentType: attachment.contentType } : {}), + ...(attachment.contentDisposition + ? { ContentDisposition: attachment.contentDisposition } + : {}), + ...(attachment.contentDescription + ? { ContentDescription: attachment.contentDescription } + : {}), + ...(attachment.contentId ? { ContentId: attachment.contentId } : {}), + ...(attachment.contentTransferEncoding + ? { ContentTransferEncoding: attachment.contentTransferEncoding } + : {}) + })); + export class SesClient { private baseUrl: string; private config: SesClientConfig; @@ -52,15 +88,19 @@ export class SesClient { service: 'ses' }); - let ax = createAxios({ baseURL: this.baseUrl }); - let response = await ax.request({ - method, - url, - data: bodyStr, - headers: signedHeaders - }); - - return response.data; + try { + let ax = createAxios({ baseURL: this.baseUrl }); + let response = await ax.request({ + method, + url, + data: bodyStr, + headers: signedHeaders + }); + + return response.data; + } catch (error) { + throw awsSesApiError(error, `${method.toUpperCase()} ${path}`); + } } // ==================== Email Sending ==================== @@ -79,18 +119,24 @@ export class SesClient { text?: { data: string; charset?: string }; html?: { data: string; charset?: string }; }; - headers?: { name: string; value: string }[]; + headers?: SesHeader[]; + attachments?: SesAttachment[]; }; raw?: { data: string }; template?: { templateName?: string; templateArn?: string; templateData?: string; - headers?: { name: string; value: string }[]; + headers?: SesHeader[]; + attachments?: SesAttachment[]; }; }; replyToAddresses?: string[]; feedbackForwardingEmailAddress?: string; + feedbackForwardingEmailAddressIdentityArn?: string; + fromEmailAddressIdentityArn?: string; + endpointId?: string; + tenantName?: string; emailTags?: { name: string; value: string }[]; configurationSetName?: string; listManagementOptions?: { @@ -101,6 +147,8 @@ export class SesClient { let body: any = {}; if (params.fromEmailAddress) body.FromEmailAddress = params.fromEmailAddress; + if (params.fromEmailAddressIdentityArn) + body.FromEmailAddressIdentityArn = params.fromEmailAddressIdentityArn; if (params.destination) { body.Destination = {}; if (params.destination.toAddresses) @@ -135,7 +183,10 @@ export class SesClient { }; } if (s.headers && s.headers.length > 0) { - body.Content.Simple.Headers = s.headers.map(h => ({ Name: h.name, Value: h.value })); + body.Content.Simple.Headers = mapHeaders(s.headers); + } + if (s.attachments && s.attachments.length > 0) { + body.Content.Simple.Attachments = mapAttachments(s.attachments); } } else if (params.content.raw) { body.Content = { Raw: { Data: params.content.raw.data } }; @@ -146,16 +197,24 @@ export class SesClient { if (t.templateArn) body.Content.Template.TemplateArn = t.templateArn; if (t.templateData) body.Content.Template.TemplateData = t.templateData; if (t.headers && t.headers.length > 0) { - body.Content.Template.Headers = t.headers.map(h => ({ Name: h.name, Value: h.value })); + body.Content.Template.Headers = mapHeaders(t.headers); + } + if (t.attachments && t.attachments.length > 0) { + body.Content.Template.Attachments = mapAttachments(t.attachments); } } if (params.replyToAddresses) body.ReplyToAddresses = params.replyToAddresses; if (params.feedbackForwardingEmailAddress) body.FeedbackForwardingEmailAddress = params.feedbackForwardingEmailAddress; + if (params.feedbackForwardingEmailAddressIdentityArn) + body.FeedbackForwardingEmailAddressIdentityArn = + params.feedbackForwardingEmailAddressIdentityArn; if (params.emailTags) body.EmailTags = params.emailTags.map(t => ({ Name: t.name, Value: t.value })); if (params.configurationSetName) body.ConfigurationSetName = params.configurationSetName; + if (params.endpointId) body.EndpointId = params.endpointId; + if (params.tenantName) body.TenantName = params.tenantName; if (params.listManagementOptions) { body.ListManagementOptions = { ContactListName: params.listManagementOptions.contactListName @@ -177,12 +236,18 @@ export class SesClient { fromEmailAddress?: string; replyToAddresses?: string[]; feedbackForwardingEmailAddress?: string; + feedbackForwardingEmailAddressIdentityArn?: string; + fromEmailAddressIdentityArn?: string; + endpointId?: string; + tenantName?: string; defaultEmailTags?: { name: string; value: string }[]; defaultContent: { template: { templateName?: string; templateArn?: string; templateData?: string; + headers?: SesHeader[]; + attachments?: SesAttachment[]; }; }; configurationSetName?: string; @@ -197,6 +262,7 @@ export class SesClient { replacementTemplateData?: string; }; }; + replacementHeaders?: SesHeader[]; replacementTags?: { name: string; value: string }[]; }[]; }): Promise<{ @@ -205,21 +271,32 @@ export class SesClient { let body: any = {}; if (params.fromEmailAddress) body.FromEmailAddress = params.fromEmailAddress; + if (params.fromEmailAddressIdentityArn) + body.FromEmailAddressIdentityArn = params.fromEmailAddressIdentityArn; if (params.replyToAddresses) body.ReplyToAddresses = params.replyToAddresses; if (params.feedbackForwardingEmailAddress) body.FeedbackForwardingEmailAddress = params.feedbackForwardingEmailAddress; + if (params.feedbackForwardingEmailAddressIdentityArn) + body.FeedbackForwardingEmailAddressIdentityArn = + params.feedbackForwardingEmailAddressIdentityArn; if (params.defaultEmailTags) body.DefaultEmailTags = params.defaultEmailTags.map(t => ({ Name: t.name, Value: t.value })); if (params.configurationSetName) body.ConfigurationSetName = params.configurationSetName; + if (params.endpointId) body.EndpointId = params.endpointId; + if (params.tenantName) body.TenantName = params.tenantName; body.DefaultContent = { Template: {} }; let dt = params.defaultContent.template; if (dt.templateName) body.DefaultContent.Template.TemplateName = dt.templateName; if (dt.templateArn) body.DefaultContent.Template.TemplateArn = dt.templateArn; if (dt.templateData) body.DefaultContent.Template.TemplateData = dt.templateData; + if (dt.headers && dt.headers.length > 0) + body.DefaultContent.Template.Headers = mapHeaders(dt.headers); + if (dt.attachments && dt.attachments.length > 0) + body.DefaultContent.Template.Attachments = mapAttachments(dt.attachments); body.BulkEmailEntries = params.bulkEmailEntries.map(entry => { let e: any = { @@ -240,6 +317,9 @@ export class SesClient { } }; } + if (entry.replacementHeaders) { + e.ReplacementHeaders = mapHeaders(entry.replacementHeaders); + } if (entry.replacementTags) { e.ReplacementTags = entry.replacementTags.map(t => ({ Name: t.name, Value: t.value })); } @@ -855,6 +935,7 @@ export class SesClient { deliveryOptions?: { sendingPoolName?: string; tlsPolicy?: 'REQUIRE' | 'OPTIONAL'; + maxDeliverySeconds?: number; }; reputationOptions?: { reputationMetricsEnabled?: boolean; @@ -879,6 +960,8 @@ export class SesClient { body.DeliveryOptions.SendingPoolName = params.deliveryOptions.sendingPoolName; if (params.deliveryOptions.tlsPolicy) body.DeliveryOptions.TlsPolicy = params.deliveryOptions.tlsPolicy; + if (params.deliveryOptions.maxDeliverySeconds !== undefined) + body.DeliveryOptions.MaxDeliverySeconds = params.deliveryOptions.maxDeliverySeconds; } if (params.reputationOptions) { body.ReputationOptions = {}; @@ -918,6 +1001,7 @@ export class SesClient { deliveryOptions?: { sendingPoolName?: string; tlsPolicy?: string; + maxDeliverySeconds?: number; }; reputationOptions?: { reputationMetricsEnabled?: boolean; @@ -942,7 +1026,8 @@ export class SesClient { deliveryOptions: result.DeliveryOptions ? { sendingPoolName: result.DeliveryOptions.SendingPoolName, - tlsPolicy: result.DeliveryOptions.TlsPolicy + tlsPolicy: result.DeliveryOptions.TlsPolicy, + maxDeliverySeconds: result.DeliveryOptions.MaxDeliverySeconds } : undefined, reputationOptions: result.ReputationOptions @@ -1003,6 +1088,28 @@ export class SesClient { ); } + async putConfigurationSetDeliveryOptions( + configurationSetName: string, + deliveryOptions: { + sendingPoolName?: string; + tlsPolicy?: 'REQUIRE' | 'OPTIONAL'; + maxDeliverySeconds?: number; + } + ): Promise { + let body: any = {}; + if (deliveryOptions.sendingPoolName !== undefined) + body.SendingPoolName = deliveryOptions.sendingPoolName; + if (deliveryOptions.tlsPolicy !== undefined) body.TlsPolicy = deliveryOptions.tlsPolicy; + if (deliveryOptions.maxDeliverySeconds !== undefined) + body.MaxDeliverySeconds = deliveryOptions.maxDeliverySeconds; + + await this.request( + 'PUT', + `/v2/email/configuration-sets/${encodeURIComponent(configurationSetName)}/delivery-options`, + body + ); + } + async putConfigurationSetReputationOptions( configurationSetName: string, reputationMetricsEnabled: boolean @@ -1048,6 +1155,7 @@ export class SesClient { configurationSetName: string; eventDestinationName: string; matchingEventTypes: string[]; + enabled?: boolean; snsDestination?: { topicArn: string }; cloudWatchDestination?: { dimensionConfigurations: { @@ -1057,12 +1165,14 @@ export class SesClient { }[]; }; eventBridgeDestination?: { eventBusArn: string }; + kinesisFirehoseDestination?: { deliveryStreamArn: string; iamRoleArn: string }; + pinpointDestination?: { applicationArn: string }; }): Promise { let body: any = { EventDestinationName: params.eventDestinationName, EventDestination: { MatchingEventTypes: params.matchingEventTypes, - Enabled: true + Enabled: params.enabled ?? true } }; if (params.snsDestination) { @@ -1084,6 +1194,17 @@ export class SesClient { EventBusArn: params.eventBridgeDestination.eventBusArn }; } + if (params.kinesisFirehoseDestination) { + body.EventDestination.KinesisFirehoseDestination = { + DeliveryStreamArn: params.kinesisFirehoseDestination.deliveryStreamArn, + IamRoleArn: params.kinesisFirehoseDestination.iamRoleArn + }; + } + if (params.pinpointDestination) { + body.EventDestination.PinpointDestination = { + ApplicationArn: params.pinpointDestination.applicationArn + }; + } await this.request( 'POST', `/v2/email/configuration-sets/${encodeURIComponent(params.configurationSetName)}/event-destinations`, @@ -1091,6 +1212,66 @@ export class SesClient { ); } + async updateConfigurationSetEventDestination(params: { + configurationSetName: string; + eventDestinationName: string; + matchingEventTypes: string[]; + enabled?: boolean; + snsDestination?: { topicArn: string }; + cloudWatchDestination?: { + dimensionConfigurations: { + dimensionName: string; + dimensionValueSource: 'MESSAGE_TAG' | 'EMAIL_HEADER' | 'LINK_TAG'; + defaultDimensionValue: string; + }[]; + }; + eventBridgeDestination?: { eventBusArn: string }; + kinesisFirehoseDestination?: { deliveryStreamArn: string; iamRoleArn: string }; + pinpointDestination?: { applicationArn: string }; + }): Promise { + let eventDestination: any = { + MatchingEventTypes: params.matchingEventTypes, + Enabled: params.enabled ?? true + }; + + if (params.snsDestination) { + eventDestination.SnsDestination = { TopicArn: params.snsDestination.topicArn }; + } + if (params.cloudWatchDestination) { + eventDestination.CloudWatchDestination = { + DimensionConfigurations: params.cloudWatchDestination.dimensionConfigurations.map( + d => ({ + DimensionName: d.dimensionName, + DimensionValueSource: d.dimensionValueSource, + DefaultDimensionValue: d.defaultDimensionValue + }) + ) + }; + } + if (params.eventBridgeDestination) { + eventDestination.EventBridgeDestination = { + EventBusArn: params.eventBridgeDestination.eventBusArn + }; + } + if (params.kinesisFirehoseDestination) { + eventDestination.KinesisFirehoseDestination = { + DeliveryStreamArn: params.kinesisFirehoseDestination.deliveryStreamArn, + IamRoleArn: params.kinesisFirehoseDestination.iamRoleArn + }; + } + if (params.pinpointDestination) { + eventDestination.PinpointDestination = { + ApplicationArn: params.pinpointDestination.applicationArn + }; + } + + await this.request( + 'PUT', + `/v2/email/configuration-sets/${encodeURIComponent(params.configurationSetName)}/event-destinations/${encodeURIComponent(params.eventDestinationName)}`, + { EventDestination: eventDestination } + ); + } + async getConfigurationSetEventDestinations(configurationSetName: string): Promise<{ eventDestinations: { name: string; @@ -1099,6 +1280,8 @@ export class SesClient { snsDestination?: { topicArn: string }; cloudWatchDestination?: any; eventBridgeDestination?: { eventBusArn: string }; + kinesisFirehoseDestination?: { deliveryStreamArn: string; iamRoleArn: string }; + pinpointDestination?: { applicationArn: string }; }[]; }> { let result = await this.request( @@ -1114,6 +1297,15 @@ export class SesClient { cloudWatchDestination: d.CloudWatchDestination, eventBridgeDestination: d.EventBridgeDestination ? { eventBusArn: d.EventBridgeDestination.EventBusArn } + : undefined, + kinesisFirehoseDestination: d.KinesisFirehoseDestination + ? { + deliveryStreamArn: d.KinesisFirehoseDestination.DeliveryStreamArn, + iamRoleArn: d.KinesisFirehoseDestination.IamRoleArn + } + : undefined, + pinpointDestination: d.PinpointDestination + ? { applicationArn: d.PinpointDestination.ApplicationArn } : undefined })) }; @@ -1185,6 +1377,14 @@ export class SesClient { async getAccount(): Promise<{ dedicatedIpAutoWarmupEnabled: boolean; + details?: { + additionalContactEmailAddresses?: string[]; + contactLanguage?: string; + mailType?: string; + reviewDetails?: { caseId?: string; status?: string }; + useCaseDescription?: string; + websiteUrl?: string; + }; enforcementStatus: string; productionAccessEnabled: boolean; sendingEnabled: boolean; @@ -1195,6 +1395,12 @@ export class SesClient { }; suppressionAttributes?: { suppressedReasons: string[]; + validationAttributes?: { + conditionThreshold?: { + conditionThresholdEnabled?: string; + overallConfidenceThreshold?: { confidenceVerdictThreshold?: string }; + }; + }; }; vdmAttributes?: { vdmEnabled: string; @@ -1205,6 +1411,21 @@ export class SesClient { let result = await this.request('GET', '/v2/email/account'); return { dedicatedIpAutoWarmupEnabled: result.DedicatedIpAutoWarmupEnabled || false, + details: result.Details + ? { + additionalContactEmailAddresses: result.Details.AdditionalContactEmailAddresses, + contactLanguage: result.Details.ContactLanguage, + mailType: result.Details.MailType, + reviewDetails: result.Details.ReviewDetails + ? { + caseId: result.Details.ReviewDetails.CaseId, + status: result.Details.ReviewDetails.Status + } + : undefined, + useCaseDescription: result.Details.UseCaseDescription, + websiteUrl: result.Details.WebsiteURL + } + : undefined, enforcementStatus: result.EnforcementStatus || 'HEALTHY', productionAccessEnabled: result.ProductionAccessEnabled || false, sendingEnabled: result.SendingEnabled || false, @@ -1214,7 +1435,30 @@ export class SesClient { sentLast24Hours: result.SendQuota?.SentLast24Hours || 0 }, suppressionAttributes: result.SuppressionAttributes - ? { suppressedReasons: result.SuppressionAttributes.SuppressedReasons || [] } + ? { + suppressedReasons: result.SuppressionAttributes.SuppressedReasons || [], + validationAttributes: result.SuppressionAttributes.ValidationAttributes + ? { + conditionThreshold: result.SuppressionAttributes.ValidationAttributes + .ConditionThreshold + ? { + conditionThresholdEnabled: + result.SuppressionAttributes.ValidationAttributes.ConditionThreshold + .ConditionThresholdEnabled, + overallConfidenceThreshold: result.SuppressionAttributes + .ValidationAttributes.ConditionThreshold.OverallConfidenceThreshold + ? { + confidenceVerdictThreshold: + result.SuppressionAttributes.ValidationAttributes + .ConditionThreshold.OverallConfidenceThreshold + .ConfidenceVerdictThreshold + } + : undefined + } + : undefined + } + : undefined + } : undefined, vdmAttributes: result.VdmAttributes ? { @@ -1241,6 +1485,58 @@ export class SesClient { }); } + // ==================== Email Address Insights ==================== + + async getEmailAddressInsights(emailAddress: string): Promise<{ + emailAddress: string; + mailboxValidation?: { + isValid?: { confidenceVerdict?: string }; + evaluations?: Record; + }; + }> { + let result = await this.request('POST', '/v2/email/email-address-insights/', { + EmailAddress: emailAddress + }); + let evaluations = result.MailboxValidation?.Evaluations; + + return { + emailAddress, + mailboxValidation: result.MailboxValidation + ? { + isValid: result.MailboxValidation.IsValid + ? { + confidenceVerdict: result.MailboxValidation.IsValid.ConfidenceVerdict + } + : undefined, + evaluations: evaluations + ? { + hasValidDnsRecords: evaluations.HasValidDnsRecords + ? { + confidenceVerdict: evaluations.HasValidDnsRecords.ConfidenceVerdict + } + : undefined, + hasValidSyntax: evaluations.HasValidSyntax + ? { confidenceVerdict: evaluations.HasValidSyntax.ConfidenceVerdict } + : undefined, + isDisposable: evaluations.IsDisposable + ? { confidenceVerdict: evaluations.IsDisposable.ConfidenceVerdict } + : undefined, + isRandomInput: evaluations.IsRandomInput + ? { confidenceVerdict: evaluations.IsRandomInput.ConfidenceVerdict } + : undefined, + isRoleAddress: evaluations.IsRoleAddress + ? { confidenceVerdict: evaluations.IsRoleAddress.ConfidenceVerdict } + : undefined, + mailboxExists: evaluations.MailboxExists + ? { confidenceVerdict: evaluations.MailboxExists.ConfidenceVerdict } + : undefined + } + : undefined + } + : undefined + }; + } + // ==================== Message Insights ==================== async getMessageInsights(messageId: string): Promise<{ diff --git a/integrations/aws-ses/src/lib/errors.ts b/integrations/aws-ses/src/lib/errors.ts new file mode 100644 index 0000000000..0b68fad986 --- /dev/null +++ b/integrations/aws-ses/src/lib/errors.ts @@ -0,0 +1,48 @@ +import { buildApiServiceError, createApiServiceError } from 'slates'; + +export let awsSesApiError = (error: unknown, operation = 'request') => + buildApiServiceError(error, { + providerLabel: 'AWS SES', + operation, + reason: 'aws_ses_api_error', + detailKeys: [ + 'message', + 'Message', + 'detail', + 'title', + 'error', + 'Error', + 'code', + 'Code', + '__type' + ], + nestedKeys: ['data', 'errors'], + extractUpstreamCode: (_input, response, helpers) => { + if (!helpers.isRecord(response?.data)) return undefined; + + let code = response.data.Code ?? response.data.code ?? response.data.__type; + return typeof code === 'string' ? code : undefined; + } + }); + +export let requireAwsSesString = (value: unknown, label: string, action?: string) => { + if (typeof value === 'string' && value.trim()) { + return value; + } + + throw createApiServiceError(`${label} is required${action ? ` for "${action}"` : ''}.`); +}; + +export let requireAwsSesArray = ( + value: T[] | undefined, + label: string, + action?: string +) => { + if (Array.isArray(value) && value.length > 0) { + return value; + } + + throw createApiServiceError( + `${label} must contain at least one item${action ? ` for "${action}"` : ''}.` + ); +}; diff --git a/integrations/aws-ses/src/spec.ts b/integrations/aws-ses/src/spec.ts index 98a236a582..b2214ac872 100644 --- a/integrations/aws-ses/src/spec.ts +++ b/integrations/aws-ses/src/spec.ts @@ -6,7 +6,7 @@ export let spec = SlateSpecification.create({ key: 'aws-ses', name: 'AWS SES', description: - 'Amazon Simple Email Service (SES) integration for sending marketing, transactional, and notification emails. Supports formatted, raw, and templated email sending, contact list management, identity verification, suppression lists, configuration sets, and deliverability insights.', + 'Amazon Simple Email Service (SES) integration for sending marketing, transactional, and notification emails. Supports formatted, raw, and templated email sending, contact list management, identity verification, suppression lists, configuration sets, event destinations, and deliverability insights.', metadata: {}, config, auth diff --git a/integrations/aws-ses/src/tools.schema.test.ts b/integrations/aws-ses/src/tools.schema.test.ts new file mode 100644 index 0000000000..1018c894f5 --- /dev/null +++ b/integrations/aws-ses/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('AWS SES tool input schemas', provider.actions); diff --git a/integrations/aws-ses/src/tools/get-account.ts b/integrations/aws-ses/src/tools/get-account.ts index f0a8db6626..2892574252 100644 --- a/integrations/aws-ses/src/tools/get-account.ts +++ b/integrations/aws-ses/src/tools/get-account.ts @@ -17,6 +17,22 @@ export let getAccount = SlateTool.create(spec, { dedicatedIpAutoWarmupEnabled: z .boolean() .describe('Whether automatic IP warmup is enabled'), + details: z + .object({ + additionalContactEmailAddresses: z.array(z.string()).optional(), + contactLanguage: z.string().optional(), + mailType: z.string().optional(), + reviewDetails: z + .object({ + caseId: z.string().optional(), + status: z.string().optional() + }) + .optional(), + useCaseDescription: z.string().optional(), + websiteUrl: z.string().optional() + }) + .optional() + .describe('SES account details and production access review status'), enforcementStatus: z .string() .describe('Account enforcement status (HEALTHY, PROBATION, SHUTDOWN)'), @@ -33,7 +49,21 @@ export let getAccount = SlateTool.create(spec, { .describe('Sending quota and usage'), suppressionAttributes: z .object({ - suppressedReasons: z.array(z.string()) + suppressedReasons: z.array(z.string()), + validationAttributes: z + .object({ + conditionThreshold: z + .object({ + conditionThresholdEnabled: z.string().optional(), + overallConfidenceThreshold: z + .object({ + confidenceVerdictThreshold: z.string().optional() + }) + .optional() + }) + .optional() + }) + .optional() }) .optional() .describe('Account suppression settings'), diff --git a/integrations/aws-ses/src/tools/get-email-address-insights.ts b/integrations/aws-ses/src/tools/get-email-address-insights.ts new file mode 100644 index 0000000000..72d3d6f4ed --- /dev/null +++ b/integrations/aws-ses/src/tools/get-email-address-insights.ts @@ -0,0 +1,63 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SesClient } from '../lib/client'; +import { spec } from '../spec'; + +let confidenceVerdictSchema = z + .object({ + confidenceVerdict: z.string().optional() + }) + .optional(); + +export let getEmailAddressInsights = SlateTool.create(spec, { + name: 'Get Email Address Insights', + key: 'get_email_address_insights', + description: + 'Analyze an email address with Amazon SES validation insights, including syntax, DNS, disposable-address, role-address, random-input, and mailbox-existence signals.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + emailAddress: z.string().describe('Email address to analyze for validation insights') + }) + ) + .output( + z.object({ + emailAddress: z.string(), + mailboxValidation: z + .object({ + isValid: confidenceVerdictSchema, + evaluations: z + .object({ + hasValidDnsRecords: confidenceVerdictSchema, + hasValidSyntax: confidenceVerdictSchema, + isDisposable: confidenceVerdictSchema, + isRandomInput: confidenceVerdictSchema, + isRoleAddress: confidenceVerdictSchema, + mailboxExists: confidenceVerdictSchema + }) + .optional() + }) + .optional() + .describe('Mailbox validation verdicts returned by SES') + }) + ) + .handleInvocation(async ctx => { + let client = new SesClient({ + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken, + region: ctx.config.region + }); + + let result = await client.getEmailAddressInsights(ctx.input.emailAddress); + let verdict = result.mailboxValidation?.isValid?.confidenceVerdict ?? 'UNKNOWN'; + + return { + output: result, + message: `Email address **${ctx.input.emailAddress}** validation verdict: **${verdict}**.` + }; + }) + .build(); diff --git a/integrations/aws-ses/src/tools/index.ts b/integrations/aws-ses/src/tools/index.ts index 7b02341a70..d2831ef665 100644 --- a/integrations/aws-ses/src/tools/index.ts +++ b/integrations/aws-ses/src/tools/index.ts @@ -1,4 +1,5 @@ export * from './get-account'; +export * from './get-email-address-insights'; export * from './get-message-insights'; export * from './manage-configuration-set'; export * from './manage-contact'; diff --git a/integrations/aws-ses/src/tools/manage-configuration-set.ts b/integrations/aws-ses/src/tools/manage-configuration-set.ts index 76b390a19b..9f6ad606f8 100644 --- a/integrations/aws-ses/src/tools/manage-configuration-set.ts +++ b/integrations/aws-ses/src/tools/manage-configuration-set.ts @@ -1,6 +1,7 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; +import { requireAwsSesString } from '../lib/errors'; import { spec } from '../spec'; export let manageConfigurationSet = SlateTool.create(spec, { @@ -20,6 +21,7 @@ export let manageConfigurationSet = SlateTool.create(spec, { 'get', 'delete', 'list', + 'updateDelivery', 'updateSending', 'updateReputation', 'updateTracking', @@ -51,6 +53,10 @@ export let manageConfigurationSet = SlateTool.create(spec, { .enum(['REQUIRE', 'OPTIONAL']) .optional() .describe('TLS policy for email delivery'), + maxDeliverySeconds: z + .number() + .optional() + .describe('Maximum time in seconds SES attempts delivery (300 to 50400)'), nextToken: z.string().optional().describe('Pagination token for "list"'), pageSize: z.number().optional().describe('Number of results per page') }) @@ -61,7 +67,8 @@ export let manageConfigurationSet = SlateTool.create(spec, { deliveryOptions: z .object({ sendingPoolName: z.string().optional(), - tlsPolicy: z.string().optional() + tlsPolicy: z.string().optional(), + maxDeliverySeconds: z.number().optional() }) .optional(), reputationOptions: z @@ -103,11 +110,22 @@ export let manageConfigurationSet = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { + let configurationSetName = requireAwsSesString( + ctx.input.configurationSetName, + 'configurationSetName', + action + ); await client.createConfigurationSet({ - configurationSetName: ctx.input.configurationSetName!, + configurationSetName, deliveryOptions: - ctx.input.sendingPoolName || ctx.input.tlsPolicy - ? { sendingPoolName: ctx.input.sendingPoolName, tlsPolicy: ctx.input.tlsPolicy } + ctx.input.sendingPoolName || + ctx.input.tlsPolicy || + ctx.input.maxDeliverySeconds !== undefined + ? { + sendingPoolName: ctx.input.sendingPoolName, + tlsPolicy: ctx.input.tlsPolicy, + maxDeliverySeconds: ctx.input.maxDeliverySeconds + } : undefined, reputationOptions: ctx.input.reputationMetricsEnabled !== undefined @@ -125,13 +143,18 @@ export let manageConfigurationSet = SlateTool.create(spec, { : undefined }); return { - output: { configurationSetName: ctx.input.configurationSetName }, - message: `Configuration set **${ctx.input.configurationSetName}** created.` + output: { configurationSetName }, + message: `Configuration set **${configurationSetName}** created.` }; } if (action === 'get') { - let result = await client.getConfigurationSet(ctx.input.configurationSetName!); + let configurationSetName = requireAwsSesString( + ctx.input.configurationSetName, + 'configurationSetName', + action + ); + let result = await client.getConfigurationSet(configurationSetName); return { output: result, message: `Retrieved configuration set **${result.configurationSetName}**.` @@ -139,10 +162,15 @@ export let manageConfigurationSet = SlateTool.create(spec, { } if (action === 'delete') { - await client.deleteConfigurationSet(ctx.input.configurationSetName!); + let configurationSetName = requireAwsSesString( + ctx.input.configurationSetName, + 'configurationSetName', + action + ); + await client.deleteConfigurationSet(configurationSetName); return { - output: { configurationSetName: ctx.input.configurationSetName }, - message: `Configuration set **${ctx.input.configurationSetName}** deleted.` + output: { configurationSetName }, + message: `Configuration set **${configurationSetName}** deleted.` }; } @@ -160,47 +188,93 @@ export let manageConfigurationSet = SlateTool.create(spec, { }; } + if (action === 'updateDelivery') { + let configurationSetName = requireAwsSesString( + ctx.input.configurationSetName, + 'configurationSetName', + action + ); + if ( + ctx.input.sendingPoolName === undefined && + ctx.input.tlsPolicy === undefined && + ctx.input.maxDeliverySeconds === undefined + ) { + throw createApiServiceError( + 'sendingPoolName, tlsPolicy, or maxDeliverySeconds is required for "updateDelivery".' + ); + } + await client.putConfigurationSetDeliveryOptions(configurationSetName, { + sendingPoolName: ctx.input.sendingPoolName, + tlsPolicy: ctx.input.tlsPolicy, + maxDeliverySeconds: ctx.input.maxDeliverySeconds + }); + return { + output: { configurationSetName }, + message: `Delivery options updated for **${configurationSetName}**.` + }; + } + if (action === 'updateSending') { + let configurationSetName = requireAwsSesString( + ctx.input.configurationSetName, + 'configurationSetName', + action + ); await client.putConfigurationSetSendingOptions( - ctx.input.configurationSetName!, + configurationSetName, ctx.input.sendingEnabled ?? true ); return { - output: { configurationSetName: ctx.input.configurationSetName }, - message: `Sending ${ctx.input.sendingEnabled ? 'enabled' : 'disabled'} for **${ctx.input.configurationSetName}**.` + output: { configurationSetName }, + message: `Sending ${(ctx.input.sendingEnabled ?? true) ? 'enabled' : 'disabled'} for **${configurationSetName}**.` }; } if (action === 'updateReputation') { + let configurationSetName = requireAwsSesString( + ctx.input.configurationSetName, + 'configurationSetName', + action + ); await client.putConfigurationSetReputationOptions( - ctx.input.configurationSetName!, + configurationSetName, ctx.input.reputationMetricsEnabled ?? true ); return { - output: { configurationSetName: ctx.input.configurationSetName }, - message: `Reputation metrics ${ctx.input.reputationMetricsEnabled ? 'enabled' : 'disabled'} for **${ctx.input.configurationSetName}**.` + output: { configurationSetName }, + message: `Reputation metrics ${(ctx.input.reputationMetricsEnabled ?? true) ? 'enabled' : 'disabled'} for **${configurationSetName}**.` }; } if (action === 'updateTracking') { + let configurationSetName = requireAwsSesString( + ctx.input.configurationSetName, + 'configurationSetName', + action + ); await client.putConfigurationSetTrackingOptions( - ctx.input.configurationSetName!, + configurationSetName, ctx.input.customRedirectDomain ); return { - output: { configurationSetName: ctx.input.configurationSetName }, - message: `Tracking options updated for **${ctx.input.configurationSetName}**.` + output: { configurationSetName }, + message: `Tracking options updated for **${configurationSetName}**.` }; } if (action === 'updateSuppression') { + let configurationSetName = requireAwsSesString( + ctx.input.configurationSetName, + 'configurationSetName', + action + ); await client.putConfigurationSetSuppressionOptions( - ctx.input.configurationSetName!, + configurationSetName, ctx.input.suppressedReasons ); return { - output: { configurationSetName: ctx.input.configurationSetName }, - message: `Suppression options updated for **${ctx.input.configurationSetName}**.` + output: { configurationSetName }, + message: `Suppression options updated for **${configurationSetName}**.` }; } diff --git a/integrations/aws-ses/src/tools/manage-contact-list.ts b/integrations/aws-ses/src/tools/manage-contact-list.ts index 92f3126a2e..ec27739ec2 100644 --- a/integrations/aws-ses/src/tools/manage-contact-list.ts +++ b/integrations/aws-ses/src/tools/manage-contact-list.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; +import { requireAwsSesString } from '../lib/errors'; import { spec } from '../spec'; let topicSchema = z.object({ @@ -76,19 +77,29 @@ export let manageContactList = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { + let contactListName = requireAwsSesString( + ctx.input.contactListName, + 'contactListName', + action + ); await client.createContactList({ - contactListName: ctx.input.contactListName!, + contactListName, description: ctx.input.description, topics: ctx.input.topics }); return { - output: { contactListName: ctx.input.contactListName }, - message: `Contact list **${ctx.input.contactListName}** created.` + output: { contactListName }, + message: `Contact list **${contactListName}** created.` }; } if (action === 'get') { - let result = await client.getContactList(ctx.input.contactListName!); + let contactListName = requireAwsSesString( + ctx.input.contactListName, + 'contactListName', + action + ); + let result = await client.getContactList(contactListName); return { output: result, message: `Retrieved contact list **${result.contactListName}**${result.topics ? ` with ${result.topics.length} topic(s)` : ''}.` @@ -96,22 +107,32 @@ export let manageContactList = SlateTool.create(spec, { } if (action === 'update') { + let contactListName = requireAwsSesString( + ctx.input.contactListName, + 'contactListName', + action + ); await client.updateContactList({ - contactListName: ctx.input.contactListName!, + contactListName, description: ctx.input.description, topics: ctx.input.topics }); return { - output: { contactListName: ctx.input.contactListName }, - message: `Contact list **${ctx.input.contactListName}** updated.` + output: { contactListName }, + message: `Contact list **${contactListName}** updated.` }; } if (action === 'delete') { - await client.deleteContactList(ctx.input.contactListName!); + let contactListName = requireAwsSesString( + ctx.input.contactListName, + 'contactListName', + action + ); + await client.deleteContactList(contactListName); return { - output: { contactListName: ctx.input.contactListName }, - message: `Contact list **${ctx.input.contactListName}** deleted.` + output: { contactListName }, + message: `Contact list **${contactListName}** deleted.` }; } diff --git a/integrations/aws-ses/src/tools/manage-contact.ts b/integrations/aws-ses/src/tools/manage-contact.ts index 31aec5474c..6c619ddfca 100644 --- a/integrations/aws-ses/src/tools/manage-contact.ts +++ b/integrations/aws-ses/src/tools/manage-contact.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; +import { requireAwsSesString } from '../lib/errors'; import { spec } from '../spec'; let topicPreferenceSchema = z.object({ @@ -103,9 +104,10 @@ export let manageContact = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { + let emailAddress = requireAwsSesString(ctx.input.emailAddress, 'emailAddress', action); await client.createContact({ contactListName: ctx.input.contactListName, - emailAddress: ctx.input.emailAddress!, + emailAddress, topicPreferences: ctx.input.topicPreferences, unsubscribeAll: ctx.input.unsubscribeAll, attributesData: ctx.input.attributesData @@ -113,14 +115,15 @@ export let manageContact = SlateTool.create(spec, { return { output: { contactListName: ctx.input.contactListName, - emailAddress: ctx.input.emailAddress + emailAddress }, - message: `Contact **${ctx.input.emailAddress}** added to list **${ctx.input.contactListName}**.` + message: `Contact **${emailAddress}** added to list **${ctx.input.contactListName}**.` }; } if (action === 'get') { - let result = await client.getContact(ctx.input.contactListName, ctx.input.emailAddress!); + let emailAddress = requireAwsSesString(ctx.input.emailAddress, 'emailAddress', action); + let result = await client.getContact(ctx.input.contactListName, emailAddress); return { output: result, message: `Retrieved contact **${result.emailAddress}** from list **${result.contactListName}**.` @@ -128,9 +131,10 @@ export let manageContact = SlateTool.create(spec, { } if (action === 'update') { + let emailAddress = requireAwsSesString(ctx.input.emailAddress, 'emailAddress', action); await client.updateContact({ contactListName: ctx.input.contactListName, - emailAddress: ctx.input.emailAddress!, + emailAddress, topicPreferences: ctx.input.topicPreferences, unsubscribeAll: ctx.input.unsubscribeAll, attributesData: ctx.input.attributesData @@ -138,20 +142,21 @@ export let manageContact = SlateTool.create(spec, { return { output: { contactListName: ctx.input.contactListName, - emailAddress: ctx.input.emailAddress + emailAddress }, - message: `Contact **${ctx.input.emailAddress}** updated in list **${ctx.input.contactListName}**.` + message: `Contact **${emailAddress}** updated in list **${ctx.input.contactListName}**.` }; } if (action === 'delete') { - await client.deleteContact(ctx.input.contactListName, ctx.input.emailAddress!); + let emailAddress = requireAwsSesString(ctx.input.emailAddress, 'emailAddress', action); + await client.deleteContact(ctx.input.contactListName, emailAddress); return { output: { contactListName: ctx.input.contactListName, - emailAddress: ctx.input.emailAddress + emailAddress }, - message: `Contact **${ctx.input.emailAddress}** removed from list **${ctx.input.contactListName}**.` + message: `Contact **${emailAddress}** removed from list **${ctx.input.contactListName}**.` }; } diff --git a/integrations/aws-ses/src/tools/manage-dedicated-ip-pool.ts b/integrations/aws-ses/src/tools/manage-dedicated-ip-pool.ts index 3994b470b8..da5f2139ed 100644 --- a/integrations/aws-ses/src/tools/manage-dedicated-ip-pool.ts +++ b/integrations/aws-ses/src/tools/manage-dedicated-ip-pool.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; +import { requireAwsSesString } from '../lib/errors'; import { spec } from '../spec'; export let manageDedicatedIpPool = SlateTool.create(spec, { @@ -46,18 +47,20 @@ export let manageDedicatedIpPool = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { + let poolName = requireAwsSesString(ctx.input.poolName, 'poolName', action); await client.createDedicatedIpPool({ - poolName: ctx.input.poolName!, + poolName, scalingMode: ctx.input.scalingMode }); return { - output: { poolName: ctx.input.poolName, scalingMode: ctx.input.scalingMode }, - message: `Dedicated IP pool **${ctx.input.poolName}** created${ctx.input.scalingMode ? ` (mode: ${ctx.input.scalingMode})` : ''}.` + output: { poolName, scalingMode: ctx.input.scalingMode }, + message: `Dedicated IP pool **${poolName}** created${ctx.input.scalingMode ? ` (mode: ${ctx.input.scalingMode})` : ''}.` }; } if (action === 'get') { - let result = await client.getDedicatedIpPool(ctx.input.poolName!); + let poolName = requireAwsSesString(ctx.input.poolName, 'poolName', action); + let result = await client.getDedicatedIpPool(poolName); return { output: result, message: `Pool **${result.poolName}**: scaling mode = ${result.scalingMode}.` @@ -65,10 +68,11 @@ export let manageDedicatedIpPool = SlateTool.create(spec, { } if (action === 'delete') { - await client.deleteDedicatedIpPool(ctx.input.poolName!); + let poolName = requireAwsSesString(ctx.input.poolName, 'poolName', action); + await client.deleteDedicatedIpPool(poolName); return { - output: { poolName: ctx.input.poolName }, - message: `Dedicated IP pool **${ctx.input.poolName}** deleted.` + output: { poolName }, + message: `Dedicated IP pool **${poolName}** deleted.` }; } diff --git a/integrations/aws-ses/src/tools/manage-email-identity.ts b/integrations/aws-ses/src/tools/manage-email-identity.ts index ad8de6acba..f1c0e7472a 100644 --- a/integrations/aws-ses/src/tools/manage-email-identity.ts +++ b/integrations/aws-ses/src/tools/manage-email-identity.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; +import { requireAwsSesString } from '../lib/errors'; import { spec } from '../spec'; export let manageEmailIdentity = SlateTool.create(spec, { @@ -106,8 +107,13 @@ export let manageEmailIdentity = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { + let emailIdentity = requireAwsSesString( + ctx.input.emailIdentity, + 'emailIdentity', + action + ); let result = await client.createEmailIdentity({ - emailIdentity: ctx.input.emailIdentity!, + emailIdentity, configurationSetName: ctx.input.configurationSetName }); return { @@ -116,23 +122,33 @@ export let manageEmailIdentity = SlateTool.create(spec, { verifiedForSendingStatus: result.verifiedForSendingStatus, dkimAttributes: result.dkimAttributes }, - message: `Identity **${ctx.input.emailIdentity}** created (type: ${result.identityType}). Verified: ${result.verifiedForSendingStatus}.` + message: `Identity **${emailIdentity}** created (type: ${result.identityType}). Verified: ${result.verifiedForSendingStatus}.` }; } if (action === 'get') { - let result = await client.getEmailIdentity(ctx.input.emailIdentity!); + let emailIdentity = requireAwsSesString( + ctx.input.emailIdentity, + 'emailIdentity', + action + ); + let result = await client.getEmailIdentity(emailIdentity); return { output: result, - message: `Identity **${ctx.input.emailIdentity}**: type=${result.identityType}, verified=${result.verifiedForSendingStatus}.` + message: `Identity **${emailIdentity}**: type=${result.identityType}, verified=${result.verifiedForSendingStatus}.` }; } if (action === 'delete') { - await client.deleteEmailIdentity(ctx.input.emailIdentity!); + let emailIdentity = requireAwsSesString( + ctx.input.emailIdentity, + 'emailIdentity', + action + ); + await client.deleteEmailIdentity(emailIdentity); return { - output: { identityName: ctx.input.emailIdentity }, - message: `Identity **${ctx.input.emailIdentity}** deleted.` + output: { identityName: emailIdentity }, + message: `Identity **${emailIdentity}** deleted.` }; } @@ -151,36 +167,47 @@ export let manageEmailIdentity = SlateTool.create(spec, { } if (action === 'configureDkim') { - await client.putEmailIdentityDkimAttributes( - ctx.input.emailIdentity!, - ctx.input.dkimSigningEnabled ?? true + let emailIdentity = requireAwsSesString( + ctx.input.emailIdentity, + 'emailIdentity', + action ); + let signingEnabled = ctx.input.dkimSigningEnabled ?? true; + await client.putEmailIdentityDkimAttributes(emailIdentity, signingEnabled); return { - output: { identityName: ctx.input.emailIdentity }, - message: `DKIM signing ${ctx.input.dkimSigningEnabled ? 'enabled' : 'disabled'} for **${ctx.input.emailIdentity}**.` + output: { identityName: emailIdentity }, + message: `DKIM signing ${signingEnabled ? 'enabled' : 'disabled'} for **${emailIdentity}**.` }; } if (action === 'configureMailFrom') { + let emailIdentity = requireAwsSesString( + ctx.input.emailIdentity, + 'emailIdentity', + action + ); await client.putEmailIdentityMailFromAttributes( - ctx.input.emailIdentity!, + emailIdentity, ctx.input.mailFromDomain, ctx.input.behaviorOnMxFailure ); return { - output: { identityName: ctx.input.emailIdentity }, - message: `MAIL FROM domain updated for **${ctx.input.emailIdentity}**.` + output: { identityName: emailIdentity }, + message: `MAIL FROM domain updated for **${emailIdentity}**.` }; } if (action === 'configureFeedback') { - await client.putEmailIdentityFeedbackAttributes( - ctx.input.emailIdentity!, - ctx.input.emailForwardingEnabled ?? true + let emailIdentity = requireAwsSesString( + ctx.input.emailIdentity, + 'emailIdentity', + action ); + let emailForwardingEnabled = ctx.input.emailForwardingEnabled ?? true; + await client.putEmailIdentityFeedbackAttributes(emailIdentity, emailForwardingEnabled); return { - output: { identityName: ctx.input.emailIdentity }, - message: `Feedback forwarding ${ctx.input.emailForwardingEnabled ? 'enabled' : 'disabled'} for **${ctx.input.emailIdentity}**.` + output: { identityName: emailIdentity }, + message: `Feedback forwarding ${emailForwardingEnabled ? 'enabled' : 'disabled'} for **${emailIdentity}**.` }; } diff --git a/integrations/aws-ses/src/tools/manage-email-template.ts b/integrations/aws-ses/src/tools/manage-email-template.ts index c6d6a42b2d..6edda25c75 100644 --- a/integrations/aws-ses/src/tools/manage-email-template.ts +++ b/integrations/aws-ses/src/tools/manage-email-template.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; +import { requireAwsSesString } from '../lib/errors'; import { spec } from '../spec'; export let manageEmailTemplate = SlateTool.create(spec, { @@ -71,20 +72,23 @@ Use the **testRender** action to preview how a template renders with sample data let { action } = ctx.input; if (action === 'create') { + let templateName = requireAwsSesString(ctx.input.templateName, 'templateName', action); + let subject = requireAwsSesString(ctx.input.subject, 'subject', action); await client.createEmailTemplate({ - templateName: ctx.input.templateName!, - subject: ctx.input.subject!, + templateName, + subject, html: ctx.input.html, text: ctx.input.text }); return { - output: { templateName: ctx.input.templateName }, - message: `Template **${ctx.input.templateName}** created successfully.` + output: { templateName }, + message: `Template **${templateName}** created successfully.` }; } if (action === 'get') { - let template = await client.getEmailTemplate(ctx.input.templateName!); + let templateName = requireAwsSesString(ctx.input.templateName, 'templateName', action); + let template = await client.getEmailTemplate(templateName); return { output: template, message: `Retrieved template **${template.templateName}**.` @@ -92,23 +96,26 @@ Use the **testRender** action to preview how a template renders with sample data } if (action === 'update') { + let templateName = requireAwsSesString(ctx.input.templateName, 'templateName', action); + let subject = requireAwsSesString(ctx.input.subject, 'subject', action); await client.updateEmailTemplate({ - templateName: ctx.input.templateName!, - subject: ctx.input.subject!, + templateName, + subject, html: ctx.input.html, text: ctx.input.text }); return { - output: { templateName: ctx.input.templateName }, - message: `Template **${ctx.input.templateName}** updated successfully.` + output: { templateName }, + message: `Template **${templateName}** updated successfully.` }; } if (action === 'delete') { - await client.deleteEmailTemplate(ctx.input.templateName!); + let templateName = requireAwsSesString(ctx.input.templateName, 'templateName', action); + await client.deleteEmailTemplate(templateName); return { - output: { templateName: ctx.input.templateName }, - message: `Template **${ctx.input.templateName}** deleted.` + output: { templateName }, + message: `Template **${templateName}** deleted.` }; } @@ -127,16 +134,17 @@ Use the **testRender** action to preview how a template renders with sample data } if (action === 'testRender') { + let templateName = requireAwsSesString(ctx.input.templateName, 'templateName', action); let result = await client.testRenderEmailTemplate( - ctx.input.templateName!, + templateName, ctx.input.templateData || '{}' ); return { output: { - templateName: ctx.input.templateName, + templateName, renderedTemplate: result.renderedTemplate }, - message: `Template **${ctx.input.templateName}** rendered successfully.` + message: `Template **${templateName}** rendered successfully.` }; } diff --git a/integrations/aws-ses/src/tools/manage-event-destination.ts b/integrations/aws-ses/src/tools/manage-event-destination.ts index 53c4ec3327..2acc4c97a7 100644 --- a/integrations/aws-ses/src/tools/manage-event-destination.ts +++ b/integrations/aws-ses/src/tools/manage-event-destination.ts @@ -1,12 +1,76 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; +import { requireAwsSesArray, requireAwsSesString } from '../lib/errors'; import { spec } from '../spec'; +let destinationCount = (input: { + snsTopicArn?: string; + eventBusArn?: string; + cloudWatchDimensions?: unknown[]; + kinesisFirehoseDeliveryStreamArn?: string; + kinesisFirehoseIamRoleArn?: string; + pinpointApplicationArn?: string; +}) => + [ + input.snsTopicArn, + input.eventBusArn, + input.cloudWatchDimensions && input.cloudWatchDimensions.length > 0 + ? 'cloudwatch' + : undefined, + input.kinesisFirehoseDeliveryStreamArn || input.kinesisFirehoseIamRoleArn + ? 'firehose' + : undefined, + input.pinpointApplicationArn + ].filter(Boolean).length; + +let validateEventDestinationMutation = ( + input: { + eventDestinationName?: string; + matchingEventTypes?: string[]; + snsTopicArn?: string; + eventBusArn?: string; + cloudWatchDimensions?: unknown[]; + kinesisFirehoseDeliveryStreamArn?: string; + kinesisFirehoseIamRoleArn?: string; + pinpointApplicationArn?: string; + }, + action: string +) => { + let eventDestinationName = requireAwsSesString( + input.eventDestinationName, + 'eventDestinationName', + action + ); + let matchingEventTypes = requireAwsSesArray( + input.matchingEventTypes, + 'matchingEventTypes', + action + ); + let destinations = destinationCount(input); + + if (destinations !== 1) { + throw createApiServiceError( + `Exactly one destination type is required for "${action}": snsTopicArn, eventBusArn, cloudWatchDimensions, kinesisFirehose*, or pinpointApplicationArn.` + ); + } + + if (input.kinesisFirehoseDeliveryStreamArn || input.kinesisFirehoseIamRoleArn) { + requireAwsSesString( + input.kinesisFirehoseDeliveryStreamArn, + 'kinesisFirehoseDeliveryStreamArn', + action + ); + requireAwsSesString(input.kinesisFirehoseIamRoleArn, 'kinesisFirehoseIamRoleArn', action); + } + + return { eventDestinationName, matchingEventTypes }; +}; + export let manageEventDestination = SlateTool.create(spec, { name: 'Manage Event Destination', key: 'manage_event_destination', - description: `Create, list, or delete event destinations on an SES configuration set. Event destinations publish email sending events (sends, deliveries, bounces, complaints, opens, clicks, etc.) to SNS topics, CloudWatch, or EventBridge for monitoring and alerting.`, + description: `Create, update, list, or delete event destinations on an SES configuration set. Event destinations publish email sending events (sends, deliveries, bounces, complaints, opens, clicks, etc.) to SNS topics, CloudWatch, Kinesis Data Firehose, Pinpoint, or EventBridge for monitoring and alerting.`, instructions: [ 'Valid event types: SEND, DELIVERY, BOUNCE, COMPLAINT, REJECT, DELIVERY_DELAY, OPEN, CLICK, RENDERING_FAILURE, SUBSCRIPTION.' ], @@ -17,20 +81,36 @@ export let manageEventDestination = SlateTool.create(spec, { }) .input( z.object({ - action: z.enum(['create', 'list', 'delete']).describe('Operation to perform'), + action: z.enum(['create', 'update', 'list', 'delete']).describe('Operation to perform'), configurationSetName: z .string() .describe('Configuration set to manage event destinations for'), eventDestinationName: z .string() .optional() - .describe('Event destination name (required for create/delete)'), + .describe('Event destination name (required for create/update/delete)'), matchingEventTypes: z .array(z.string()) .optional() - .describe('Event types to publish (required for create)'), + .describe('Event types to publish (required for create/update)'), + enabled: z + .boolean() + .optional() + .describe('Whether the event destination is enabled (for create/update)'), snsTopicArn: z.string().optional().describe('SNS topic ARN for SNS destination'), eventBusArn: z.string().optional().describe('EventBridge event bus ARN'), + kinesisFirehoseDeliveryStreamArn: z + .string() + .optional() + .describe('Kinesis Data Firehose delivery stream ARN'), + kinesisFirehoseIamRoleArn: z + .string() + .optional() + .describe('IAM role ARN that SES assumes for Kinesis Data Firehose delivery'), + pinpointApplicationArn: z + .string() + .optional() + .describe('Amazon Pinpoint application ARN'), cloudWatchDimensions: z .array( z.object({ @@ -45,6 +125,7 @@ export let manageEventDestination = SlateTool.create(spec, { ) .output( z.object({ + eventDestinationName: z.string().optional().describe('Event destination name'), eventDestinations: z .array( z.object({ @@ -52,7 +133,15 @@ export let manageEventDestination = SlateTool.create(spec, { enabled: z.boolean(), matchingEventTypes: z.array(z.string()), snsDestination: z.object({ topicArn: z.string() }).optional(), - eventBridgeDestination: z.object({ eventBusArn: z.string() }).optional() + cloudWatchDestination: z.any().optional(), + eventBridgeDestination: z.object({ eventBusArn: z.string() }).optional(), + kinesisFirehoseDestination: z + .object({ + deliveryStreamArn: z.string(), + iamRoleArn: z.string() + }) + .optional(), + pinpointDestination: z.object({ applicationArn: z.string() }).optional() }) ) .optional() @@ -70,23 +159,74 @@ export let manageEventDestination = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { + let { eventDestinationName, matchingEventTypes } = validateEventDestinationMutation( + ctx.input, + action + ); await client.createConfigurationSetEventDestination({ configurationSetName: ctx.input.configurationSetName, - eventDestinationName: ctx.input.eventDestinationName!, - matchingEventTypes: ctx.input.matchingEventTypes!, + eventDestinationName, + matchingEventTypes, + enabled: ctx.input.enabled, + snsDestination: ctx.input.snsTopicArn + ? { topicArn: ctx.input.snsTopicArn } + : undefined, + eventBridgeDestination: ctx.input.eventBusArn + ? { eventBusArn: ctx.input.eventBusArn } + : undefined, + kinesisFirehoseDestination: + ctx.input.kinesisFirehoseDeliveryStreamArn && ctx.input.kinesisFirehoseIamRoleArn + ? { + deliveryStreamArn: ctx.input.kinesisFirehoseDeliveryStreamArn, + iamRoleArn: ctx.input.kinesisFirehoseIamRoleArn + } + : undefined, + pinpointDestination: ctx.input.pinpointApplicationArn + ? { applicationArn: ctx.input.pinpointApplicationArn } + : undefined, + cloudWatchDestination: ctx.input.cloudWatchDimensions + ? { dimensionConfigurations: ctx.input.cloudWatchDimensions } + : undefined + }); + return { + output: { eventDestinationName }, + message: `Event destination **${eventDestinationName}** created on configuration set **${ctx.input.configurationSetName}**.` + }; + } + + if (action === 'update') { + let { eventDestinationName, matchingEventTypes } = validateEventDestinationMutation( + ctx.input, + action + ); + await client.updateConfigurationSetEventDestination({ + configurationSetName: ctx.input.configurationSetName, + eventDestinationName, + matchingEventTypes, + enabled: ctx.input.enabled, snsDestination: ctx.input.snsTopicArn ? { topicArn: ctx.input.snsTopicArn } : undefined, eventBridgeDestination: ctx.input.eventBusArn ? { eventBusArn: ctx.input.eventBusArn } : undefined, + kinesisFirehoseDestination: + ctx.input.kinesisFirehoseDeliveryStreamArn && ctx.input.kinesisFirehoseIamRoleArn + ? { + deliveryStreamArn: ctx.input.kinesisFirehoseDeliveryStreamArn, + iamRoleArn: ctx.input.kinesisFirehoseIamRoleArn + } + : undefined, + pinpointDestination: ctx.input.pinpointApplicationArn + ? { applicationArn: ctx.input.pinpointApplicationArn } + : undefined, cloudWatchDestination: ctx.input.cloudWatchDimensions ? { dimensionConfigurations: ctx.input.cloudWatchDimensions } : undefined }); return { - output: {}, - message: `Event destination **${ctx.input.eventDestinationName}** created on configuration set **${ctx.input.configurationSetName}**.` + output: { eventDestinationName }, + message: `Event destination **${eventDestinationName}** updated on configuration set **${ctx.input.configurationSetName}**.` }; } @@ -101,13 +241,18 @@ export let manageEventDestination = SlateTool.create(spec, { } if (action === 'delete') { + let eventDestinationName = requireAwsSesString( + ctx.input.eventDestinationName, + 'eventDestinationName', + action + ); await client.deleteConfigurationSetEventDestination( ctx.input.configurationSetName, - ctx.input.eventDestinationName! + eventDestinationName ); return { - output: {}, - message: `Event destination **${ctx.input.eventDestinationName}** deleted from **${ctx.input.configurationSetName}**.` + output: { eventDestinationName }, + message: `Event destination **${eventDestinationName}** deleted from **${ctx.input.configurationSetName}**.` }; } diff --git a/integrations/aws-ses/src/tools/manage-suppression.ts b/integrations/aws-ses/src/tools/manage-suppression.ts index 0d9461edcf..1383d6298b 100644 --- a/integrations/aws-ses/src/tools/manage-suppression.ts +++ b/integrations/aws-ses/src/tools/manage-suppression.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; +import { requireAwsSesString } from '../lib/errors'; import { spec } from '../spec'; export let manageSuppression = SlateTool.create(spec, { @@ -69,15 +70,20 @@ export let manageSuppression = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'add') { - await client.putSuppressedDestination(ctx.input.emailAddress!, ctx.input.reason!); + let emailAddress = requireAwsSesString(ctx.input.emailAddress, 'emailAddress', action); + let reason = requireAwsSesString(ctx.input.reason, 'reason', action) as + | 'BOUNCE' + | 'COMPLAINT'; + await client.putSuppressedDestination(emailAddress, reason); return { - output: { emailAddress: ctx.input.emailAddress, reason: ctx.input.reason }, - message: `**${ctx.input.emailAddress}** added to suppression list (reason: ${ctx.input.reason}).` + output: { emailAddress, reason }, + message: `**${emailAddress}** added to suppression list (reason: ${reason}).` }; } if (action === 'get') { - let result = await client.getSuppressedDestination(ctx.input.emailAddress!); + let emailAddress = requireAwsSesString(ctx.input.emailAddress, 'emailAddress', action); + let result = await client.getSuppressedDestination(emailAddress); return { output: result, message: `**${result.emailAddress}** is suppressed (reason: ${result.reason}, since: ${result.lastUpdateTime}).` @@ -85,10 +91,11 @@ export let manageSuppression = SlateTool.create(spec, { } if (action === 'remove') { - await client.deleteSuppressedDestination(ctx.input.emailAddress!); + let emailAddress = requireAwsSesString(ctx.input.emailAddress, 'emailAddress', action); + await client.deleteSuppressedDestination(emailAddress); return { - output: { emailAddress: ctx.input.emailAddress }, - message: `**${ctx.input.emailAddress}** removed from suppression list.` + output: { emailAddress }, + message: `**${emailAddress}** removed from suppression list.` }; } diff --git a/integrations/aws-ses/src/tools/send-bulk-email.ts b/integrations/aws-ses/src/tools/send-bulk-email.ts index e06826fd6e..16a3860c75 100644 --- a/integrations/aws-ses/src/tools/send-bulk-email.ts +++ b/integrations/aws-ses/src/tools/send-bulk-email.ts @@ -1,8 +1,68 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; import { spec } from '../spec'; +let headerSchema = z.object({ + name: z.string().describe('Header name'), + value: z.string().describe('Header value') +}); + +let attachmentSchema = z.object({ + fileName: z.string().describe('Attachment file name shown to recipients'), + rawContentBase64: z + .string() + .describe('Base64-encoded attachment content for the SES HTTPS API'), + contentType: z.string().optional().describe('Attachment MIME type, such as application/pdf'), + contentDisposition: z + .enum(['ATTACHMENT', 'INLINE']) + .optional() + .describe('How the attachment should be rendered'), + contentDescription: z.string().optional().describe('Human-readable attachment description'), + contentId: z.string().optional().describe('Content ID for inline attachment references'), + contentTransferEncoding: z + .enum(['BASE64', 'QUOTED_PRINTABLE', 'SEVEN_BIT']) + .optional() + .describe('Transfer encoding for the attachment') +}); + +let recipientCount = (entry: { + toAddresses?: string[]; + ccAddresses?: string[]; + bccAddresses?: string[]; +}) => + (entry.toAddresses?.length ?? 0) + + (entry.ccAddresses?.length ?? 0) + + (entry.bccAddresses?.length ?? 0); + +let validateBulkEmailInput = (input: { + templateName?: string; + templateArn?: string; + entries: { + toAddresses?: string[]; + ccAddresses?: string[]; + bccAddresses?: string[]; + }[]; +}) => { + if (!input.templateName && !input.templateArn) { + throw createApiServiceError('templateName or templateArn is required for bulk emails.'); + } + if (input.templateName && input.templateArn) { + throw createApiServiceError('Provide only one of templateName or templateArn.'); + } + if (input.entries.length === 0) { + throw createApiServiceError('entries must contain at least one recipient group.'); + } + + input.entries.forEach((entry, index) => { + if (recipientCount(entry) === 0) { + throw createApiServiceError( + `entries[${index}] must include at least one To, Cc, or Bcc recipient.` + ); + } + }); +}; + export let sendBulkEmail = SlateTool.create(spec, { name: 'Send Bulk Email', key: 'send_bulk_email', @@ -19,12 +79,41 @@ export let sendBulkEmail = SlateTool.create(spec, { .input( z.object({ fromEmailAddress: z.string().describe('Verified sender email address'), - templateName: z.string().describe('Name of the SES email template to use'), + templateName: z.string().optional().describe('Name of the SES email template to use'), + templateArn: z.string().optional().describe('ARN of the SES email template to use'), defaultTemplateData: z .string() .optional() .describe('Default JSON string of template replacement data'), + headers: z + .array(headerSchema) + .optional() + .describe('Default message headers applied to all entries'), + attachments: z + .array(attachmentSchema) + .optional() + .describe('Default attachments sent to every recipient'), configurationSetName: z.string().optional().describe('Configuration set to apply'), + fromEmailAddressIdentityArn: z + .string() + .optional() + .describe('Identity ARN authorizing the From email address'), + feedbackForwardingEmailAddress: z + .string() + .optional() + .describe('Address that should receive bounce and complaint notifications'), + feedbackForwardingEmailAddressIdentityArn: z + .string() + .optional() + .describe('Identity ARN authorizing the feedback forwarding address'), + endpointId: z + .string() + .optional() + .describe('SES multi-region endpoint ID to route this send through'), + tenantName: z + .string() + .optional() + .describe('SES tenant name to associate with this send'), replyToAddresses: z.array(z.string()).optional().describe('Reply-to email addresses'), defaultTags: z .array( @@ -47,6 +136,10 @@ export let sendBulkEmail = SlateTool.create(spec, { .string() .optional() .describe('Per-recipient JSON template data overriding defaults'), + replacementHeaders: z + .array(headerSchema) + .optional() + .describe('Per-recipient headers overriding or adding to defaults'), replacementTags: z .array( z.object({ @@ -75,6 +168,7 @@ export let sendBulkEmail = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + validateBulkEmailInput(ctx.input); let client = new SesClient({ accessKeyId: ctx.auth.accessKeyId, secretAccessKey: ctx.auth.secretAccessKey, @@ -90,9 +184,18 @@ export let sendBulkEmail = SlateTool.create(spec, { defaultContent: { template: { templateName: ctx.input.templateName, - templateData: ctx.input.defaultTemplateData + templateArn: ctx.input.templateArn, + templateData: ctx.input.defaultTemplateData, + headers: ctx.input.headers, + attachments: ctx.input.attachments } }, + feedbackForwardingEmailAddress: ctx.input.feedbackForwardingEmailAddress, + feedbackForwardingEmailAddressIdentityArn: + ctx.input.feedbackForwardingEmailAddressIdentityArn, + fromEmailAddressIdentityArn: ctx.input.fromEmailAddressIdentityArn, + endpointId: ctx.input.endpointId, + tenantName: ctx.input.tenantName, bulkEmailEntries: ctx.input.entries.map(e => ({ destination: { toAddresses: e.toAddresses, @@ -102,6 +205,7 @@ export let sendBulkEmail = SlateTool.create(spec, { replacementEmailContent: e.replacementTemplateData ? { replacementTemplate: { replacementTemplateData: e.replacementTemplateData } } : undefined, + replacementHeaders: e.replacementHeaders, replacementTags: e.replacementTags })) }); diff --git a/integrations/aws-ses/src/tools/send-email.ts b/integrations/aws-ses/src/tools/send-email.ts index f4ddb4c794..2ef0a2b201 100644 --- a/integrations/aws-ses/src/tools/send-email.ts +++ b/integrations/aws-ses/src/tools/send-email.ts @@ -1,8 +1,105 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { SesClient } from '../lib/client'; +import { requireAwsSesString } from '../lib/errors'; import { spec } from '../spec'; +let headerSchema = z.object({ + name: z.string().describe('Header name'), + value: z.string().describe('Header value') +}); + +let attachmentSchema = z.object({ + fileName: z.string().describe('Attachment file name shown to recipients'), + rawContentBase64: z + .string() + .describe('Base64-encoded attachment content for the SES HTTPS API'), + contentType: z.string().optional().describe('Attachment MIME type, such as application/pdf'), + contentDisposition: z + .enum(['ATTACHMENT', 'INLINE']) + .optional() + .describe('How the attachment should be rendered'), + contentDescription: z.string().optional().describe('Human-readable attachment description'), + contentId: z.string().optional().describe('Content ID for inline attachment references'), + contentTransferEncoding: z + .enum(['BASE64', 'QUOTED_PRINTABLE', 'SEVEN_BIT']) + .optional() + .describe('Transfer encoding for the attachment') +}); + +let recipientCount = (input: { + toAddresses?: string[]; + ccAddresses?: string[]; + bccAddresses?: string[]; +}) => + (input.toAddresses?.length ?? 0) + + (input.ccAddresses?.length ?? 0) + + (input.bccAddresses?.length ?? 0); + +let determineContentMode = (input: { + rawData?: string; + templateName?: string; + templateArn?: string; + templateData?: string; + subject?: string; + textBody?: string; + htmlBody?: string; + headers?: unknown[]; + attachments?: unknown[]; + toAddresses?: string[]; + ccAddresses?: string[]; + bccAddresses?: string[]; +}) => { + let rawMode = Boolean(input.rawData); + let templateMode = Boolean(input.templateName || input.templateArn || input.templateData); + let simpleMode = Boolean(input.subject || input.textBody || input.htmlBody); + let modeCount = [rawMode, templateMode, simpleMode].filter(Boolean).length; + + if (modeCount !== 1) { + throw createApiServiceError( + 'Provide exactly one email content mode: rawData, templateName/templateArn, or simple subject/body fields.' + ); + } + + if (rawMode) { + if ((input.headers?.length ?? 0) > 0 || (input.attachments?.length ?? 0) > 0) { + throw createApiServiceError( + 'headers and attachments can only be used with simple or template emails.' + ); + } + return 'raw' as const; + } + + if (templateMode) { + if (!input.templateName && !input.templateArn) { + throw createApiServiceError( + 'templateName or templateArn is required for template emails.' + ); + } + if (input.templateName && input.templateArn) { + throw createApiServiceError('Provide only one of templateName or templateArn.'); + } + if (recipientCount(input) === 0) { + throw createApiServiceError( + 'At least one To, Cc, or Bcc recipient is required for template emails.' + ); + } + return 'template' as const; + } + + requireAwsSesString(input.subject, 'subject', 'simple email'); + if (!input.textBody && !input.htmlBody) { + throw createApiServiceError('textBody or htmlBody is required for simple emails.'); + } + if (recipientCount(input) === 0) { + throw createApiServiceError( + 'At least one To, Cc, or Bcc recipient is required for simple emails.' + ); + } + + return 'simple' as const; +}; + export let sendEmail = SlateTool.create(spec, { name: 'Send Email', key: 'send_email', @@ -49,11 +146,43 @@ Emails can be sent to multiple recipients via To, Cc, and Bcc fields.`, .string() .optional() .describe('Name of the SES template to use (for template mode)'), + templateArn: z + .string() + .optional() + .describe('ARN of the SES template to use (for template mode)'), templateData: z.string().optional().describe('JSON string of template replacement data'), + headers: z + .array(headerSchema) + .optional() + .describe('Custom message headers for simple or template mode'), + attachments: z + .array(attachmentSchema) + .optional() + .describe('Attachments for simple or template mode'), configurationSetName: z .string() .optional() .describe('Configuration set to apply to this email'), + fromEmailAddressIdentityArn: z + .string() + .optional() + .describe('Identity ARN authorizing the From email address'), + feedbackForwardingEmailAddress: z + .string() + .optional() + .describe('Address that should receive bounce and complaint notifications'), + feedbackForwardingEmailAddressIdentityArn: z + .string() + .optional() + .describe('Identity ARN authorizing the feedback forwarding address'), + endpointId: z + .string() + .optional() + .describe('SES multi-region endpoint ID to route this send through'), + tenantName: z + .string() + .optional() + .describe('SES tenant name to associate with this send'), emailTags: z .array( z.object({ @@ -76,6 +205,7 @@ Emails can be sent to multiple recipients via To, Cc, and Bcc fields.`, }) ) .handleInvocation(async ctx => { + let contentMode = determineContentMode(ctx.input); let client = new SesClient({ accessKeyId: ctx.auth.accessKeyId, secretAccessKey: ctx.auth.secretAccessKey, @@ -84,20 +214,25 @@ Emails can be sent to multiple recipients via To, Cc, and Bcc fields.`, }); let content: any = {}; - if (ctx.input.rawData) { + if (contentMode === 'raw') { content.raw = { data: ctx.input.rawData }; - } else if (ctx.input.templateName) { + } else if (contentMode === 'template') { content.template = { templateName: ctx.input.templateName, - templateData: ctx.input.templateData + templateArn: ctx.input.templateArn, + templateData: ctx.input.templateData, + headers: ctx.input.headers, + attachments: ctx.input.attachments }; } else { content.simple = { - subject: { data: ctx.input.subject || '' }, + subject: { data: requireAwsSesString(ctx.input.subject, 'subject', 'simple email') }, body: { text: ctx.input.textBody ? { data: ctx.input.textBody } : undefined, html: ctx.input.htmlBody ? { data: ctx.input.htmlBody } : undefined - } + }, + headers: ctx.input.headers, + attachments: ctx.input.attachments }; } @@ -110,8 +245,14 @@ Emails can be sent to multiple recipients via To, Cc, and Bcc fields.`, }, content, replyToAddresses: ctx.input.replyToAddresses, + feedbackForwardingEmailAddress: ctx.input.feedbackForwardingEmailAddress, + feedbackForwardingEmailAddressIdentityArn: + ctx.input.feedbackForwardingEmailAddressIdentityArn, + fromEmailAddressIdentityArn: ctx.input.fromEmailAddressIdentityArn, emailTags: ctx.input.emailTags, configurationSetName: ctx.input.configurationSetName, + endpointId: ctx.input.endpointId, + tenantName: ctx.input.tenantName, listManagementOptions: ctx.input.contactListName ? { contactListName: ctx.input.contactListName, topicName: ctx.input.topicName } : undefined diff --git a/integrations/aws-ses/vitest.config.ts b/integrations/aws-ses/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/aws-ses/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/aws-sns/README.md b/integrations/aws-sns/README.md index 7144c5e136..dd00ed99ab 100644 --- a/integrations/aws-sns/README.md +++ b/integrations/aws-sns/README.md @@ -1,6 +1,6 @@ # Aws Sns -Create and manage SNS topics (standard and FIFO) for pub/sub messaging. Publish messages to topics or directly to endpoints such as SMS phone numbers, email addresses, HTTP/HTTPS endpoints, SQS queues, Lambda functions, and mobile push devices. Subscribe and unsubscribe endpoints using various protocols. Configure message filtering policies so subscribers receive only relevant notifications. Send SMS text messages to 200+ countries. Manage mobile push platform applications and device endpoints for iOS, Android, and other platforms. Configure topic attributes including delivery policies, server-side encryption with KMS, and access policies. Archive and replay messages on FIFO topics for up to 365 days. +Create and manage SNS topics (standard and FIFO) for pub/sub messaging. Publish messages or message batches to topics, directly to SMS phone numbers, or to existing mobile platform endpoints. Subscribe and unsubscribe endpoints using various protocols. Configure message filtering policies so subscribers receive only relevant notifications. Send SMS text messages, inspect SMS account status, manage SMS defaults, check opt-out status, and list origination numbers. Configure topic attributes including delivery policies, server-side encryption with KMS, FIFO throughput scope, access policies, archive policies, replay policies, and tags. ## Tools @@ -8,18 +8,34 @@ Create and manage SNS topics (standard and FIFO) for pub/sub messaging. Publish Confirm a pending SNS subscription using the confirmation token. HTTP/S, email, and cross-account subscriptions require explicit confirmation before notifications are delivered. Tokens are valid for 2 days. +### Check SMS Opt Out + +Check whether a phone number has opted out of receiving SMS messages from this AWS account. SNS cannot send SMS messages to opted-out phone numbers. + ### Create Topic -Create a new SNS topic (standard or FIFO) for publishing messages to subscribers. Optionally configure display name, encryption, delivery policies, and tags. FIFO topic names must end with \ +Create a new SNS topic (standard or FIFO) for publishing messages to subscribers. Optionally configure display name, encryption, delivery policies, FIFO archive/throughput settings, and tags. FIFO topic names must end with `.fifo`. ### Delete Topic Delete an SNS topic and all its subscriptions. This action is idempotent; deleting a non-existent topic does not cause an error. Previously sent messages may not be delivered after deletion. +### Get SMS Status + +Retrieve SNS SMS account settings, SMS sandbox status, and the current page of phone numbers that are opted out of SMS delivery. + +### Get Subscription + +Retrieve all attributes for an SNS subscription, including topic, owner, filter policy, delivery policy, raw delivery mode, pending confirmation state, redrive policy, and FIFO replay status when present. + ### Get Topic Retrieve all attributes and tags for an SNS topic, including owner, display name, subscription counts, delivery policy, encryption settings, and FIFO configuration. +### List Origination Numbers + +List dedicated SNS SMS origination numbers and their metadata for the configured AWS account and region. + ### List Subscriptions List subscriptions, optionally filtered by a specific topic. Returns subscription details including protocol, endpoint, and owner. Each page returns up to 100 subscriptions. @@ -28,6 +44,10 @@ List subscriptions, optionally filtered by a specific topic. Returns subscriptio List all SNS topics in the configured region. Returns topic ARNs with pagination support. Each page returns up to 100 topics. +### Publish Batch + +Publish up to 10 messages to a single SNS topic in one request. SNS reports success or failure for each individual batch entry. + ### Publish Message Publish a message to an SNS topic, directly to a phone number via SMS, or to a mobile platform endpoint. Supports protocol-specific messages via JSON message structure, message attributes, and FIFO topic ordering/deduplication. @@ -44,13 +64,17 @@ Subscribe an endpoint to an SNS topic. Supports SQS, HTTP/HTTPS, email, SMS, Lam Remove a subscription from an SNS topic. Requires the subscription ARN. Only the subscription owner or topic owner can unsubscribe when authentication is required. +### Update SMS Settings + +Update default SNS SMS account settings such as monthly spend limit, default sender ID, default SMS type, delivery status logging, and usage report bucket. + ### Update Subscription -Update attributes of an existing SNS subscription. Configure filter policies, raw message delivery, delivery retry policies, or dead-letter queue settings. +Update attributes of an existing SNS subscription. Configure filter policies, raw message delivery, delivery retry policies, dead-letter queue settings, or FIFO replay policy. ### Update Topic -Update attributes and/or tags of an SNS topic. Set any combination of display name, delivery policy, access policy, encryption, tracing, and FIFO-specific settings. Tags can be added or removed independently. +Update attributes and/or tags of an SNS topic. Set any combination of display name, delivery policy, access policy, encryption, tracing, FIFO throughput/archive settings, and tags. ## License diff --git a/integrations/aws-sns/docs/SPEC.md b/integrations/aws-sns/docs/SPEC.md index 34107ea42a..7a07ca7eb5 100644 --- a/integrations/aws-sns/docs/SPEC.md +++ b/integrations/aws-sns/docs/SPEC.md @@ -38,7 +38,7 @@ FIFO topics are designed to enhance messaging between applications when the orde ### Message Publishing -Publish messages to topics or directly to specific endpoints (phone numbers, platform endpoints). Each message can contain up to 256KB of data. You can set MessageStructure to JSON to send a different message for each protocol, e.g., a short message to SMS subscribers and a longer message to email subscribers. Messages can include custom message attributes for metadata. +Publish messages to topics or directly to specific endpoints (phone numbers, platform endpoints). PublishBatch sends up to 10 messages to one topic in a single request and reports per-entry success or failure. Each message can contain up to 256KB of data. You can set MessageStructure to JSON to send a different message for each protocol, e.g., a short message to SMS subscribers and a longer message to email subscribers. Messages can include custom message attributes for metadata. ### Subscription Management @@ -50,11 +50,11 @@ Subscriber applications can create filter policies so they receive only the noti ### SMS Messaging -Amazon SNS supports sending SMS text messages at scale to 200+ countries. You can control your originating identity by using a sender ID, long codes, or short codes, and use the SNS sandbox to validate SMS workloads before moving to production. SMS messages can be sent directly to a phone number or via a topic subscription. +Amazon SNS supports sending SMS text messages at scale to 200+ countries. You can control your originating identity by using a sender ID, long codes, or short codes, and use the SNS sandbox to validate SMS workloads before moving to production. SMS messages can be sent directly to a phone number or via a topic subscription. This integration can inspect SMS account settings and sandbox status, update default SMS settings, list dedicated origination numbers, and check whether a phone number is opted out. ### Mobile Push Notifications -Amazon SNS mobile notifications allow you to fan out mobile push notifications to iOS, Android, Fire, Windows, and Baidu devices. You create platform applications for supported push services (APNs, FCM, ADM, WNS, etc.) and register device endpoints to receive messages. +Amazon SNS mobile notifications allow you to fan out mobile push notifications to existing platform endpoints for iOS, Android, Fire, Windows, and Baidu devices. This integration can publish to an existing platform endpoint ARN; it does not create or manage platform applications or device endpoints. ### Message Archiving and Replay diff --git a/integrations/aws-sns/package.json b/integrations/aws-sns/package.json index 0f2f5bf8a5..4672cc9669 100644 --- a/integrations/aws-sns/package.json +++ b/integrations/aws-sns/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/aws-sns/slate.json b/integrations/aws-sns/slate.json index 416d75f97c..7508e20a79 100644 --- a/integrations/aws-sns/slate.json +++ b/integrations/aws-sns/slate.json @@ -1,6 +1,6 @@ { "name": "@amazon/aws-sns", - "description": "Create and manage SNS topics (standard and FIFO) for pub/sub messaging. Publish messages to topics or directly to endpoints such as SMS phone numbers, email addresses, HTTP/HTTPS endpoints, SQS queues, Lambda functions, and mobile push devices. Subscribe and unsubscribe endpoints using various protocols. Configure message filtering policies so subscribers receive only relevant notifications. Send SMS text messages to 200+ countries. Manage mobile push platform applications and device endpoints for iOS, Android, and other platforms. Configure topic attributes including delivery policies, server-side encryption with KMS, and access policies. Archive and replay messages on FIFO topics for up to 365 days.", + "description": "Create and manage SNS topics (standard and FIFO) for pub/sub messaging. Publish messages or message batches to topics, directly to SMS phone numbers, or to existing mobile platform endpoints. Subscribe and unsubscribe endpoints using various protocols. Configure message filtering policies so subscribers receive only relevant notifications. Send SMS text messages, inspect SMS account status, manage SMS defaults, check opt-out status, and list origination numbers. Configure topic attributes including delivery policies, server-side encryption with KMS, FIFO throughput scope, access policies, archive policies, replay policies, and tags.", "categories": [ "apis-and-http-requests", "email-and-messaging", @@ -9,8 +9,13 @@ "skills": [ "create and manage topics", "publish messages to topics", + "publish message batches", "manage topic subscriptions", "send SMS text messages", + "inspect SMS account status", + "manage SMS account settings", + "check SMS opt-out status", + "list SMS origination numbers", "send mobile push notifications", "configure message filtering", "send email notifications", diff --git a/integrations/aws-sns/src/index.ts b/integrations/aws-sns/src/index.ts index a510253167..03081fe769 100644 --- a/integrations/aws-sns/src/index.ts +++ b/integrations/aws-sns/src/index.ts @@ -1,16 +1,22 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + checkSmsOptOut, confirmSubscription, createTopic, deleteTopic, + getSmsStatus, + getSubscription, getTopic, + listOriginationNumbers, listSubscriptions, listTopics, + publishBatch, publishMessage, sendSms, subscribeToTopic, unsubscribeFromTopic, + updateSmsSettings, updateSubscription, updateTopic } from './tools'; @@ -25,12 +31,18 @@ export let provider = Slate.create({ getTopic, updateTopic, publishMessage, + publishBatch, subscribeToTopic, unsubscribeFromTopic, + getSubscription, listSubscriptions, updateSubscription, confirmSubscription, - sendSms + sendSms, + getSmsStatus, + updateSmsSettings, + checkSmsOptOut, + listOriginationNumbers ], triggers: [topicNotification] }); diff --git a/integrations/aws-sns/src/lib/client.ts b/integrations/aws-sns/src/lib/client.ts index d24784faa3..7244ddd908 100644 --- a/integrations/aws-sns/src/lib/client.ts +++ b/integrations/aws-sns/src/lib/client.ts @@ -1,4 +1,6 @@ +import { ServiceError } from '@lowerdeck/error'; import { createAxios } from 'slates'; +import { snsApiError } from './errors'; import { signRequest } from './sigv4'; import { parseAwsError, parseXml } from './xml-parser'; @@ -16,6 +18,7 @@ export interface TopicAttributes { kmsMasterKeyId?: string; fifoTopic?: boolean; contentBasedDeduplication?: boolean; + fifoThroughputScope?: string; tracingConfig?: string; archivePolicy?: string; } @@ -35,6 +38,19 @@ export interface PublishParams { >; } +export interface PublishBatchEntry { + id: string; + message: string; + subject?: string; + messageStructure?: string; + messageGroupId?: string; + messageDeduplicationId?: string; + messageAttributes?: Record< + string, + { dataType: string; stringValue?: string; binaryValue?: string } + >; +} + export interface SubscribeParams { topicArn: string; protocol: string; @@ -43,6 +59,49 @@ export interface SubscribeParams { attributes?: Record; } +export interface Subscription { + subscriptionArn: string; + owner: string; + protocol: string; + endpoint: string; + topicArn: string; +} + +export interface OriginationNumber { + createdAt?: string; + iso2CountryCode?: string; + numberCapabilities: string[]; + phoneNumber: string; + routeType?: string; + status?: string; +} + +let asArray = (value: T | T[] | undefined): T[] => { + if (value === undefined) return []; + return Array.isArray(value) ? value : [value]; +}; + +let addMessageAttributes = ( + params: Record, + prefix: string, + attributes?: Record +) => { + if (!attributes) return; + + let attrIndex = 1; + for (let [name, attr] of Object.entries(attributes)) { + params[`${prefix}.entry.${attrIndex}.Name`] = name; + params[`${prefix}.entry.${attrIndex}.Value.DataType`] = attr.dataType; + if (attr.stringValue !== undefined) { + params[`${prefix}.entry.${attrIndex}.Value.StringValue`] = attr.stringValue; + } + if (attr.binaryValue !== undefined) { + params[`${prefix}.entry.${attrIndex}.Value.BinaryValue`] = attr.binaryValue; + } + attrIndex++; + } +}; + export class SnsClient { private region: string; private accessKeyId: string; @@ -59,6 +118,7 @@ export class SnsClient { } private async request(params: Record): Promise> { + let operation = params.Action || 'request'; let allParams: Record = { ...params, Version: '2010-03-31' @@ -88,23 +148,40 @@ export class SnsClient { baseURL: this.baseUrl }); - let response = await ax.post('/', body, { - headers: { - ...headers, - ...sigHeaders - }, - validateStatus: () => true - }); + try { + let response = await ax.post('/', body, { + headers: { + ...headers, + ...sigHeaders + }, + validateStatus: () => true + }); + + let responseText = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data); + + if (response.status >= 400) { + let error = parseAwsError(responseText); + throw snsApiError({ + operation, + status: response.status, + code: error.code, + message: error.message + }); + } - let responseText = - typeof response.data === 'string' ? response.data : JSON.stringify(response.data); + return parseXml(responseText); + } catch (error) { + if (error instanceof ServiceError) { + throw error; + } - if (response.status >= 400) { - let error = parseAwsError(responseText); - throw new Error(`AWS SNS Error (${error.code}): ${error.message}`); + throw snsApiError({ + operation, + message: error instanceof Error ? error.message : 'Unknown error', + parent: error + }); } - - return parseXml(responseText); } // Topic Management @@ -153,6 +230,11 @@ export class SnsClient { ); attrIndex++; } + if (attributes.fifoThroughputScope !== undefined) { + params[`Attributes.entry.${attrIndex}.key`] = 'FifoThroughputScope'; + params[`Attributes.entry.${attrIndex}.value`] = attributes.fifoThroughputScope; + attrIndex++; + } if (attributes.tracingConfig !== undefined) { params[`Attributes.entry.${attrIndex}.key`] = 'TracingConfig'; params[`Attributes.entry.${attrIndex}.value`] = attributes.tracingConfig; @@ -255,20 +337,7 @@ export class SnsClient { if (publishParams.messageDeduplicationId) params.MessageDeduplicationId = publishParams.messageDeduplicationId; - if (publishParams.messageAttributes) { - let attrIndex = 1; - for (let [name, attr] of Object.entries(publishParams.messageAttributes)) { - params[`MessageAttributes.entry.${attrIndex}.Name`] = name; - params[`MessageAttributes.entry.${attrIndex}.Value.DataType`] = attr.dataType; - if (attr.stringValue !== undefined) { - params[`MessageAttributes.entry.${attrIndex}.Value.StringValue`] = attr.stringValue; - } - if (attr.binaryValue !== undefined) { - params[`MessageAttributes.entry.${attrIndex}.Value.BinaryValue`] = attr.binaryValue; - } - attrIndex++; - } - } + addMessageAttributes(params, 'MessageAttributes', publishParams.messageAttributes); let result = await this.request(params); let publishResult = result?.PublishResponse?.PublishResult; @@ -279,6 +348,50 @@ export class SnsClient { }; } + async publishBatch( + topicArn: string, + entries: PublishBatchEntry[] + ): Promise<{ + successful: Array<{ id: string; messageId: string; sequenceNumber?: string }>; + failed: Array<{ id: string; code: string; message?: string; senderFault: boolean }>; + }> { + let params: Record = { + Action: 'PublishBatch', + TopicArn: topicArn + }; + + entries.forEach((entry, index) => { + let prefix = `PublishBatchRequestEntries.member.${index + 1}`; + params[`${prefix}.Id`] = entry.id; + params[`${prefix}.Message`] = entry.message; + if (entry.subject) params[`${prefix}.Subject`] = entry.subject; + if (entry.messageStructure) + params[`${prefix}.MessageStructure`] = entry.messageStructure; + if (entry.messageGroupId) params[`${prefix}.MessageGroupId`] = entry.messageGroupId; + if (entry.messageDeduplicationId) { + params[`${prefix}.MessageDeduplicationId`] = entry.messageDeduplicationId; + } + addMessageAttributes(params, `${prefix}.MessageAttributes`, entry.messageAttributes); + }); + + let result = await this.request(params); + let batchResult = result?.PublishBatchResponse?.PublishBatchResult; + + return { + successful: asArray(batchResult?.Successful).map(entry => ({ + id: entry.Id || '', + messageId: entry.MessageId || '', + sequenceNumber: entry.SequenceNumber || undefined + })), + failed: asArray(batchResult?.Failed).map(entry => ({ + id: entry.Id || '', + code: entry.Code || '', + message: entry.Message || undefined, + senderFault: entry.SenderFault === true || entry.SenderFault === 'true' + })) + }; + } + // Subscription Management async subscribe(subscribeParams: SubscribeParams): Promise<{ subscriptionArn: string }> { @@ -338,13 +451,7 @@ export class SnsClient { topicArn: string, nextToken?: string ): Promise<{ - subscriptions: Array<{ - subscriptionArn: string; - owner: string; - protocol: string; - endpoint: string; - topicArn: string; - }>; + subscriptions: Subscription[]; nextToken?: string; }> { let params: Record = { @@ -359,13 +466,7 @@ export class SnsClient { let subResult = result?.ListSubscriptionsByTopicResponse?.ListSubscriptionsByTopicResult; let rawSubs = subResult?.Subscriptions; - let subscriptions: Array<{ - subscriptionArn: string; - owner: string; - protocol: string; - endpoint: string; - topicArn: string; - }> = []; + let subscriptions: Subscription[] = []; if (Array.isArray(rawSubs)) { subscriptions = rawSubs.map((s: any) => ({ @@ -394,13 +495,7 @@ export class SnsClient { } async listSubscriptions(nextToken?: string): Promise<{ - subscriptions: Array<{ - subscriptionArn: string; - owner: string; - protocol: string; - endpoint: string; - topicArn: string; - }>; + subscriptions: Subscription[]; nextToken?: string; }> { let params: Record = { @@ -414,13 +509,7 @@ export class SnsClient { let subResult = result?.ListSubscriptionsResponse?.ListSubscriptionsResult; let rawSubs = subResult?.Subscriptions; - let subscriptions: Array<{ - subscriptionArn: string; - owner: string; - protocol: string; - endpoint: string; - topicArn: string; - }> = []; + let subscriptions: Subscription[] = []; if (Array.isArray(rawSubs)) { subscriptions = rawSubs.map((s: any) => ({ @@ -521,14 +610,45 @@ export class SnsClient { // SMS - async getSMSAttributes(): Promise> { - let result = await this.request({ + async getSMSAttributes(attributeNames?: string[]): Promise> { + let params: Record = { Action: 'GetSMSAttributes' + }; + attributeNames?.forEach((name, index) => { + params[`attributes.member.${index + 1}`] = name; }); + + let result = await this.request(params); let attrs = result?.GetSMSAttributesResponse?.GetSMSAttributesResult?.attributes; return attrs || {}; } + async setSMSAttributes(attributes: Record): Promise { + let params: Record = { + Action: 'SetSMSAttributes' + }; + + let attrIndex = 1; + for (let [key, value] of Object.entries(attributes)) { + params[`attributes.entry.${attrIndex}.key`] = key; + params[`attributes.entry.${attrIndex}.value`] = value; + attrIndex++; + } + + await this.request(params); + } + + async getSMSSandboxAccountStatus(): Promise<{ isInSandbox: boolean }> { + let result = await this.request({ + Action: 'GetSMSSandboxAccountStatus' + }); + let isInSandbox = + result?.GetSMSSandboxAccountStatusResponse?.GetSMSSandboxAccountStatusResult + ?.IsInSandbox; + + return { isInSandbox: isInSandbox === true || isInSandbox === 'true' }; + } + async checkIfPhoneNumberIsOptedOut(phoneNumber: string): Promise { let result = await this.request({ Action: 'CheckIfPhoneNumberIsOptedOut', @@ -537,6 +657,55 @@ export class SnsClient { let isOptedOut = result?.CheckIfPhoneNumberIsOptedOutResponse?.CheckIfPhoneNumberIsOptedOutResult ?.isOptedOut; - return isOptedOut === 'true'; + return isOptedOut === true || isOptedOut === 'true'; + } + + async listPhoneNumbersOptedOut( + nextToken?: string + ): Promise<{ phoneNumbers: string[]; nextToken?: string }> { + let params: Record = { + Action: 'ListPhoneNumbersOptedOut' + }; + if (nextToken) { + params.nextToken = nextToken; + } + + let result = await this.request(params); + let listResult = result?.ListPhoneNumbersOptedOutResponse?.ListPhoneNumbersOptedOutResult; + + return { + phoneNumbers: asArray(listResult?.phoneNumbers).filter(Boolean), + nextToken: listResult?.nextToken || undefined + }; + } + + async listOriginationNumbers(paramsInput?: { + maxResults?: number; + nextToken?: string; + }): Promise<{ phoneNumbers: OriginationNumber[]; nextToken?: string }> { + let params: Record = { + Action: 'ListOriginationNumbers' + }; + if (paramsInput?.maxResults !== undefined) { + params.MaxResults = String(paramsInput.maxResults); + } + if (paramsInput?.nextToken) { + params.NextToken = paramsInput.nextToken; + } + + let result = await this.request(params); + let listResult = result?.ListOriginationNumbersResponse?.ListOriginationNumbersResult; + + return { + phoneNumbers: asArray(listResult?.PhoneNumbers).map(phoneNumber => ({ + createdAt: phoneNumber.CreatedAt || undefined, + iso2CountryCode: phoneNumber.Iso2CountryCode || undefined, + numberCapabilities: asArray(phoneNumber.NumberCapabilities).filter(Boolean), + phoneNumber: phoneNumber.PhoneNumber || '', + routeType: phoneNumber.RouteType || undefined, + status: phoneNumber.Status || undefined + })), + nextToken: listResult?.NextToken || undefined + }; } } diff --git a/integrations/aws-sns/src/lib/errors.ts b/integrations/aws-sns/src/lib/errors.ts new file mode 100644 index 0000000000..2b97656dc3 --- /dev/null +++ b/integrations/aws-sns/src/lib/errors.ts @@ -0,0 +1,28 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +export let snsServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let snsApiError = (params: { + operation: string; + status?: number; + code?: string; + message: string; + parent?: unknown; +}) => { + let statusLabel = params.status !== undefined ? `HTTP ${params.status}: ` : ''; + let codeLabel = params.code ? `${params.code} - ` : ''; + let error = snsServiceError( + `Amazon SNS ${params.operation} failed: ${statusLabel}${codeLabel}${params.message}` + ); + + error.data.reason = 'aws_sns_api_error'; + error.data.upstreamStatus = params.status; + error.data.upstreamCode = params.code; + + if (params.parent instanceof Error) { + error.setParent(params.parent); + } + + return error; +}; diff --git a/integrations/aws-sns/src/lib/xml-parser.ts b/integrations/aws-sns/src/lib/xml-parser.ts index 7421f12f51..4bd7888f0d 100644 --- a/integrations/aws-sns/src/lib/xml-parser.ts +++ b/integrations/aws-sns/src/lib/xml-parser.ts @@ -1,5 +1,5 @@ -// Lightweight XML parser for AWS SNS API responses -// Handles the simple XML structure returned by SNS +// Lightweight XML parser for AWS SNS API responses. +// Handles the simple XML structure returned by SNS. export let parseXml = (xml: string): Record => { let result: Record = {}; @@ -7,8 +7,20 @@ export let parseXml = (xml: string): Record => { return result; }; +let decodeXmlEntities = (value: string): string => + value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/&#(\d+);/g, (_, code: string) => String.fromCharCode(Number(code))) + .replace(/&#x([0-9a-fA-F]+);/g, (_, code: string) => + String.fromCharCode(Number.parseInt(code, 16)) + ); + let parseElement = (xml: string, target: Record): void => { - let tagRegex = /<(\w+)>([\s\S]*?)<\/\1>/g; + let tagRegex = /<(\w+)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g; let match: RegExpExecArray | null; while ((match = tagRegex.exec(xml)) !== null) { @@ -18,26 +30,33 @@ let parseElement = (xml: string, target: Record): void => { // Check if content contains child elements if (/<\w+>/.test(content)) { // Check if this is a collection (member pattern) - if (content.includes('')) { - let members: Record[] = []; - let memberRegex = /([\s\S]*?)<\/member>/g; + if (/^\s*]*)?>/.test(content)) { + let members: any[] = []; + let memberRegex = /]*)?>([\s\S]*?)<\/member>/g; let memberMatch: RegExpExecArray | null; while ((memberMatch = memberRegex.exec(content)) !== null) { - let memberObj: Record = {}; - parseElement(memberMatch[1]!, memberObj); - members.push(memberObj); + let memberContent = memberMatch[1]!; + if (/<\w+>/.test(memberContent)) { + let memberObj: Record = {}; + parseElement(memberContent, memberObj); + members.push(memberObj); + } else { + members.push(decodeXmlEntities(memberContent.trim())); + } } target[tagName] = members; - } else if (content.includes('')) { + } else if (/^\s*]*)?>/.test(content)) { // Handle map entries (key/value pairs) let entries: Record = {}; - let entryRegex = /([\s\S]*?)<\/entry>/g; + let entryRegex = /]*)?>([\s\S]*?)<\/entry>/g; let entryMatch: RegExpExecArray | null; while ((entryMatch = entryRegex.exec(content)) !== null) { - let keyMatch = /([\s\S]*?)<\/key>/.exec(entryMatch[1]!); - let valueMatch = /([\s\S]*?)<\/value>/.exec(entryMatch[1]!); + let keyMatch = /]*)?>([\s\S]*?)<\/key>/.exec(entryMatch[1]!); + let valueMatch = /]*)?>([\s\S]*?)<\/value>/.exec(entryMatch[1]!); if (keyMatch && valueMatch) { - entries[keyMatch[1]!] = valueMatch[1]!; + entries[decodeXmlEntities(keyMatch[1]!.trim())] = decodeXmlEntities( + valueMatch[1]!.trim() + ); } } target[tagName] = entries; @@ -47,7 +66,7 @@ let parseElement = (xml: string, target: Record): void => { target[tagName] = child; } } else { - target[tagName] = content.trim(); + target[tagName] = decodeXmlEntities(content.trim()); } } }; diff --git a/integrations/aws-sns/src/tools/check-sms-opt-out.ts b/integrations/aws-sns/src/tools/check-sms-opt-out.ts new file mode 100644 index 0000000000..85a6aa2577 --- /dev/null +++ b/integrations/aws-sns/src/tools/check-sms-opt-out.ts @@ -0,0 +1,46 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SnsClient } from '../lib/client'; +import { spec } from '../spec'; + +export let checkSmsOptOut = SlateTool.create(spec, { + name: 'Check SMS Opt Out', + key: 'check_sms_opt_out', + description: `Check whether a phone number has opted out of receiving SMS messages from this AWS account. SNS cannot send SMS messages to opted-out phone numbers.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + phoneNumber: z.string().describe('Phone number in E.164 format') + }) + ) + .output( + z.object({ + phoneNumber: z.string().describe('Phone number that was checked'), + isOptedOut: z + .boolean() + .describe('Whether the phone number is opted out of SNS SMS delivery') + }) + ) + .handleInvocation(async ctx => { + let client = new SnsClient({ + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken, + region: ctx.config.region + }); + + let isOptedOut = await client.checkIfPhoneNumberIsOptedOut(ctx.input.phoneNumber); + + return { + output: { + phoneNumber: ctx.input.phoneNumber, + isOptedOut + }, + message: `Phone number \`${ctx.input.phoneNumber}\` is ${isOptedOut ? 'opted out' : 'not opted out'}` + }; + }) + .build(); diff --git a/integrations/aws-sns/src/tools/create-topic.ts b/integrations/aws-sns/src/tools/create-topic.ts index 537e8961ba..b6a347f62e 100644 --- a/integrations/aws-sns/src/tools/create-topic.ts +++ b/integrations/aws-sns/src/tools/create-topic.ts @@ -32,10 +32,18 @@ export let createTopic = SlateTool.create(spec, { .boolean() .optional() .describe('Enable content-based deduplication for FIFO topics'), + fifoThroughputScope: z + .enum(['Topic', 'MessageGroup']) + .optional() + .describe('FIFO throughput and deduplication scope'), tracingConfig: z .enum(['PassThrough', 'Active']) .optional() .describe('X-Ray tracing mode'), + archivePolicy: z + .string() + .optional() + .describe('JSON message retention policy for FIFO topics'), tags: z .record(z.string(), z.string()) .optional() @@ -64,7 +72,9 @@ export let createTopic = SlateTool.create(spec, { kmsMasterKeyId: ctx.input.kmsMasterKeyId, fifoTopic: ctx.input.fifoTopic, contentBasedDeduplication: ctx.input.contentBasedDeduplication, - tracingConfig: ctx.input.tracingConfig + fifoThroughputScope: ctx.input.fifoThroughputScope, + tracingConfig: ctx.input.tracingConfig, + archivePolicy: ctx.input.archivePolicy }, ctx.input.tags as Record | undefined ); diff --git a/integrations/aws-sns/src/tools/get-sms-status.ts b/integrations/aws-sns/src/tools/get-sms-status.ts new file mode 100644 index 0000000000..afa3203174 --- /dev/null +++ b/integrations/aws-sns/src/tools/get-sms-status.ts @@ -0,0 +1,77 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SnsClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getSmsStatus = SlateTool.create(spec, { + name: 'Get SMS Status', + key: 'get_sms_status', + description: `Retrieve SNS SMS account settings, SMS sandbox status, and the current page of phone numbers that are opted out of SMS delivery.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + attributeNames: z + .array( + z.enum([ + 'MonthlySpendLimit', + 'DeliveryStatusIAMRole', + 'DeliveryStatusSuccessSamplingRate', + 'DefaultSenderID', + 'DefaultSMSType', + 'UsageReportS3Bucket' + ]) + ) + .optional() + .describe('Specific SMS attribute names to retrieve. Omit to return all settings.'), + optedOutNextToken: z + .string() + .optional() + .describe('Pagination token for opted-out phone numbers') + }) + ) + .output( + z.object({ + attributes: z + .record(z.string(), z.string()) + .describe('Current SNS SMS account settings'), + isInSandbox: z + .boolean() + .describe('Whether the AWS account is in the SNS SMS sandbox in this region'), + optedOutPhoneNumbers: z + .array(z.string()) + .describe('Phone numbers opted out of SMS delivery from this AWS account'), + optedOutNextToken: z + .string() + .optional() + .describe('Token for the next opted-out phone number page') + }) + ) + .handleInvocation(async ctx => { + let client = new SnsClient({ + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken, + region: ctx.config.region + }); + + let [attributes, sandbox, optedOut] = await Promise.all([ + client.getSMSAttributes(ctx.input.attributeNames), + client.getSMSSandboxAccountStatus(), + client.listPhoneNumbersOptedOut(ctx.input.optedOutNextToken) + ]); + + return { + output: { + attributes, + isInSandbox: sandbox.isInSandbox, + optedOutPhoneNumbers: optedOut.phoneNumbers, + optedOutNextToken: optedOut.nextToken + }, + message: `Retrieved SMS settings and ${optedOut.phoneNumbers.length} opted-out phone number(s)` + }; + }) + .build(); diff --git a/integrations/aws-sns/src/tools/get-subscription.ts b/integrations/aws-sns/src/tools/get-subscription.ts new file mode 100644 index 0000000000..3ddea90b4a --- /dev/null +++ b/integrations/aws-sns/src/tools/get-subscription.ts @@ -0,0 +1,88 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SnsClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getSubscription = SlateTool.create(spec, { + name: 'Get Subscription', + key: 'get_subscription', + description: `Retrieve all attributes for an SNS subscription, including topic, owner, filter policy, delivery policy, raw delivery mode, pending confirmation state, redrive policy, and FIFO replay status when present.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + subscriptionArn: z.string().describe('ARN of the subscription to retrieve') + }) + ) + .output( + z.object({ + subscriptionArn: z.string().describe('ARN of the subscription'), + topicArn: z.string().optional().describe('Topic ARN for this subscription'), + owner: z.string().optional().describe('AWS account ID of the subscription owner'), + confirmationWasAuthenticated: z + .string() + .optional() + .describe('Whether confirmation was authenticated'), + pendingConfirmation: z + .string() + .optional() + .describe('Whether the subscription is still pending confirmation'), + filterPolicy: z.string().optional().describe('JSON filter policy'), + filterPolicyScope: z + .string() + .optional() + .describe('Whether filtering applies to message attributes or body'), + rawMessageDelivery: z + .string() + .optional() + .describe('Whether raw message delivery is enabled'), + deliveryPolicy: z.string().optional().describe('JSON delivery retry policy'), + effectiveDeliveryPolicy: z + .string() + .optional() + .describe('Effective delivery retry policy'), + redrivePolicy: z.string().optional().describe('JSON dead-letter queue policy'), + replayPolicy: z.string().optional().describe('JSON FIFO replay policy'), + replayStatus: z.string().optional().describe('FIFO replay status'), + subscriptionRoleArn: z + .string() + .optional() + .describe('IAM role ARN for Firehose subscriptions'), + attributes: z.record(z.string(), z.string()).describe('Raw SNS subscription attributes') + }) + ) + .handleInvocation(async ctx => { + let client = new SnsClient({ + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken, + region: ctx.config.region + }); + + let attrs = await client.getSubscriptionAttributes(ctx.input.subscriptionArn); + + return { + output: { + subscriptionArn: attrs.SubscriptionArn || ctx.input.subscriptionArn, + topicArn: attrs.TopicArn || undefined, + owner: attrs.Owner || undefined, + confirmationWasAuthenticated: attrs.ConfirmationWasAuthenticated || undefined, + pendingConfirmation: attrs.PendingConfirmation || undefined, + filterPolicy: attrs.FilterPolicy || undefined, + filterPolicyScope: attrs.FilterPolicyScope || undefined, + rawMessageDelivery: attrs.RawMessageDelivery || undefined, + deliveryPolicy: attrs.DeliveryPolicy || undefined, + effectiveDeliveryPolicy: attrs.EffectiveDeliveryPolicy || undefined, + redrivePolicy: attrs.RedrivePolicy || undefined, + replayPolicy: attrs.ReplayPolicy || undefined, + replayStatus: attrs.ReplayStatus || undefined, + subscriptionRoleArn: attrs.SubscriptionRoleArn || undefined, + attributes: attrs + }, + message: `Retrieved subscription \`${ctx.input.subscriptionArn}\`` + }; + }) + .build(); diff --git a/integrations/aws-sns/src/tools/get-topic.ts b/integrations/aws-sns/src/tools/get-topic.ts index 8df7fff17c..2fda257c17 100644 --- a/integrations/aws-sns/src/tools/get-topic.ts +++ b/integrations/aws-sns/src/tools/get-topic.ts @@ -34,6 +34,7 @@ export let getTopic = SlateTool.create(spec, { .string() .optional() .describe('Whether content-based deduplication is enabled'), + fifoThroughputScope: z.string().optional().describe('FIFO throughput scope'), subscriptionsConfirmed: z .string() .optional() @@ -41,6 +42,7 @@ export let getTopic = SlateTool.create(spec, { subscriptionsPending: z.string().optional().describe('Number of pending subscriptions'), subscriptionsDeleted: z.string().optional().describe('Number of deleted subscriptions'), tracingConfig: z.string().optional().describe('X-Ray tracing configuration'), + archivePolicy: z.string().optional().describe('JSON FIFO archive policy'), tags: z.record(z.string(), z.string()).optional().describe('Tags attached to the topic') }) ) @@ -68,10 +70,12 @@ export let getTopic = SlateTool.create(spec, { kmsMasterKeyId: attrs.KmsMasterKeyId || undefined, fifoTopic: attrs.FifoTopic || undefined, contentBasedDeduplication: attrs.ContentBasedDeduplication || undefined, + fifoThroughputScope: attrs.FifoThroughputScope || undefined, subscriptionsConfirmed: attrs.SubscriptionsConfirmed || undefined, subscriptionsPending: attrs.SubscriptionsPending || undefined, subscriptionsDeleted: attrs.SubscriptionsDeleted || undefined, tracingConfig: attrs.TracingConfig || undefined, + archivePolicy: attrs.ArchivePolicy || undefined, tags: Object.keys(tags).length > 0 ? tags : undefined }, message: `Retrieved topic **${topicName}** with ${attrs.SubscriptionsConfirmed || 0} confirmed subscriptions` diff --git a/integrations/aws-sns/src/tools/index.ts b/integrations/aws-sns/src/tools/index.ts index 15755bd764..40e107353d 100644 --- a/integrations/aws-sns/src/tools/index.ts +++ b/integrations/aws-sns/src/tools/index.ts @@ -1,12 +1,18 @@ +export * from './check-sms-opt-out'; export * from './confirm-subscription'; export * from './create-topic'; export * from './delete-topic'; +export * from './get-sms-status'; +export * from './get-subscription'; export * from './get-topic'; +export * from './list-origination-numbers'; export * from './list-subscriptions'; export * from './list-topics'; +export * from './publish-batch'; export * from './publish-message'; export * from './send-sms'; export * from './subscribe-to-topic'; export * from './unsubscribe-from-topic'; +export * from './update-sms-settings'; export * from './update-subscription'; export * from './update-topic'; diff --git a/integrations/aws-sns/src/tools/list-origination-numbers.ts b/integrations/aws-sns/src/tools/list-origination-numbers.ts new file mode 100644 index 0000000000..0b01c48876 --- /dev/null +++ b/integrations/aws-sns/src/tools/list-origination-numbers.ts @@ -0,0 +1,70 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SnsClient } from '../lib/client'; +import { spec } from '../spec'; + +let originationNumberSchema = z.object({ + createdAt: z.string().optional().describe('Creation timestamp returned by SNS'), + iso2CountryCode: z.string().optional().describe('ISO 3166-1 alpha-2 country code'), + numberCapabilities: z + .array(z.string()) + .describe('Capabilities for the number, such as SMS or VOICE'), + phoneNumber: z.string().describe('Origination phone number'), + routeType: z + .string() + .optional() + .describe('Route type, such as Promotional or Transactional'), + status: z.string().optional().describe('SNS origination number status') +}); + +export let listOriginationNumbers = SlateTool.create(spec, { + name: 'List Origination Numbers', + key: 'list_origination_numbers', + description: `List dedicated SNS SMS origination numbers and their metadata for the configured AWS account and region.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + maxResults: z + .number() + .int() + .min(1) + .max(30) + .optional() + .describe('Maximum number of origination numbers to return, 1-30'), + nextToken: z.string().optional().describe('Pagination token from a previous request') + }) + ) + .output( + z.object({ + phoneNumbers: z + .array(originationNumberSchema) + .describe('Dedicated SNS SMS origination numbers'), + nextToken: z + .string() + .optional() + .describe('Token for retrieving the next page of results') + }) + ) + .handleInvocation(async ctx => { + let client = new SnsClient({ + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken, + region: ctx.config.region + }); + + let result = await client.listOriginationNumbers({ + maxResults: ctx.input.maxResults, + nextToken: ctx.input.nextToken + }); + + return { + output: result, + message: `Found **${result.phoneNumbers.length}** SNS origination number(s)${result.nextToken ? ' (more available)' : ''}` + }; + }) + .build(); diff --git a/integrations/aws-sns/src/tools/publish-batch.ts b/integrations/aws-sns/src/tools/publish-batch.ts new file mode 100644 index 0000000000..b257eb374f --- /dev/null +++ b/integrations/aws-sns/src/tools/publish-batch.ts @@ -0,0 +1,108 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SnsClient } from '../lib/client'; +import { snsServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let messageAttributesSchema = z + .record( + z.string(), + z.object({ + dataType: z.string().describe('Data type: String, Number, or Binary'), + stringValue: z.string().optional().describe('String or Number value'), + binaryValue: z.string().optional().describe('Base64-encoded binary value') + }) + ) + .optional() + .describe('Custom message attributes for filtering and metadata'); + +export let publishBatch = SlateTool.create(spec, { + name: 'Publish Batch', + key: 'publish_batch', + description: `Publish up to 10 messages to a single SNS topic in one request. SNS reports success or failure for each individual batch entry, so inspect the failed array even when the request succeeds.`, + instructions: [ + 'Use unique entry IDs within the batch.', + 'For FIFO topics, each entry must include messageGroupId and either messageDeduplicationId or a topic with content-based deduplication.' + ], + constraints: [ + 'A batch can contain at most 10 entries.', + 'The total batch payload and each individual message are limited to 256 KB.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + topicArn: z.string().describe('ARN of the topic to publish to'), + entries: z + .array( + z.object({ + id: z.string().describe('Unique entry ID within this batch, max 80 characters'), + message: z.string().describe('Message body for this entry'), + subject: z.string().optional().describe('Subject line for email notifications'), + messageStructure: z + .enum(['json']) + .optional() + .describe('Set to "json" to send protocol-specific messages'), + messageGroupId: z.string().optional().describe('Message group ID for FIFO topics'), + messageDeduplicationId: z + .string() + .optional() + .describe('Deduplication ID for FIFO topics'), + messageAttributes: messageAttributesSchema + }) + ) + .min(1) + .max(10) + .describe('Messages to publish in this batch') + }) + ) + .output( + z.object({ + successful: z + .array( + z.object({ + id: z.string().describe('Entry ID from the request'), + messageId: z.string().describe('Message ID assigned by SNS'), + sequenceNumber: z.string().optional().describe('FIFO sequence number') + }) + ) + .describe('Entries that SNS accepted'), + failed: z + .array( + z.object({ + id: z.string().describe('Entry ID from the request'), + code: z.string().describe('SNS batch error code'), + message: z.string().optional().describe('SNS batch error message'), + senderFault: z.boolean().describe('Whether the failure was caused by the request') + }) + ) + .describe('Entries that SNS rejected') + }) + ) + .handleInvocation(async ctx => { + let ids = new Set(); + for (let entry of ctx.input.entries) { + if (ids.has(entry.id)) { + throw snsServiceError(`Batch entry IDs must be unique. Duplicate ID: ${entry.id}`); + } + ids.add(entry.id); + } + + let client = new SnsClient({ + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken, + region: ctx.config.region + }); + + let result = await client.publishBatch(ctx.input.topicArn, ctx.input.entries); + + return { + output: result, + message: `Published batch to \`${ctx.input.topicArn}\`: ${result.successful.length} accepted, ${result.failed.length} failed` + }; + }) + .build(); diff --git a/integrations/aws-sns/src/tools/publish-message.ts b/integrations/aws-sns/src/tools/publish-message.ts index a45067c552..d902cbb08e 100644 --- a/integrations/aws-sns/src/tools/publish-message.ts +++ b/integrations/aws-sns/src/tools/publish-message.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SnsClient } from '../lib/client'; +import { snsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let publishMessage = SlateTool.create(spec, { @@ -74,6 +75,13 @@ export let publishMessage = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + let destinations = [ctx.input.topicArn, ctx.input.targetArn, ctx.input.phoneNumber].filter( + value => value !== undefined && value.length > 0 + ); + if (destinations.length !== 1) { + throw snsServiceError('Provide exactly one of topicArn, targetArn, or phoneNumber.'); + } + let client = new SnsClient({ accessKeyId: ctx.auth.accessKeyId, secretAccessKey: ctx.auth.secretAccessKey, diff --git a/integrations/aws-sns/src/tools/subscribe-to-topic.ts b/integrations/aws-sns/src/tools/subscribe-to-topic.ts index c1d233fe06..f0e694805c 100644 --- a/integrations/aws-sns/src/tools/subscribe-to-topic.ts +++ b/integrations/aws-sns/src/tools/subscribe-to-topic.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SnsClient } from '../lib/client'; +import { snsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let subscribeToTopic = SlateTool.create(spec, { @@ -53,6 +54,10 @@ export let subscribeToTopic = SlateTool.create(spec, { .string() .optional() .describe('JSON dead-letter queue policy for undeliverable messages'), + replayPolicy: z + .string() + .optional() + .describe('JSON FIFO replay policy for replaying archived messages'), subscriptionRoleArn: z .string() .optional() @@ -69,6 +74,10 @@ export let subscribeToTopic = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.protocol === 'firehose' && !ctx.input.subscriptionRoleArn) { + throw snsServiceError('subscriptionRoleArn is required for Firehose subscriptions.'); + } + let client = new SnsClient({ accessKeyId: ctx.auth.accessKeyId, secretAccessKey: ctx.auth.secretAccessKey, @@ -83,6 +92,7 @@ export let subscribeToTopic = SlateTool.create(spec, { if (ctx.input.rawMessageDelivery !== undefined) attributes.RawMessageDelivery = String(ctx.input.rawMessageDelivery); if (ctx.input.redrivePolicy) attributes.RedrivePolicy = ctx.input.redrivePolicy; + if (ctx.input.replayPolicy) attributes.ReplayPolicy = ctx.input.replayPolicy; if (ctx.input.subscriptionRoleArn) attributes.SubscriptionRoleArn = ctx.input.subscriptionRoleArn; diff --git a/integrations/aws-sns/src/tools/update-sms-settings.ts b/integrations/aws-sns/src/tools/update-sms-settings.ts new file mode 100644 index 0000000000..2b227fd119 --- /dev/null +++ b/integrations/aws-sns/src/tools/update-sms-settings.ts @@ -0,0 +1,92 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SnsClient } from '../lib/client'; +import { snsServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let smsTypeSchema = z.enum(['Promotional', 'Transactional']); + +export let updateSmsSettings = SlateTool.create(spec, { + name: 'Update SMS Settings', + key: 'update_sms_settings', + description: `Update default SNS SMS account settings such as monthly spend limit, default sender ID, default SMS type, delivery status logging, and usage report bucket.`, + instructions: [ + 'Only provide settings you want to change.', + 'These settings apply at the AWS account and region level for SNS SMS sending.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + monthlySpendLimit: z.string().optional().describe('Monthly SMS spend limit in USD'), + deliveryStatusIAMRole: z + .string() + .optional() + .describe('IAM role ARN that lets SNS write SMS delivery logs to CloudWatch Logs'), + deliveryStatusSuccessSamplingRate: z + .string() + .optional() + .describe('0-100 percentage of successful SMS deliveries to log'), + defaultSenderID: z + .string() + .optional() + .describe('Default SMS sender ID, 1-11 alphanumeric characters where supported'), + defaultSMSType: smsTypeSchema + .optional() + .describe('Default SMS message type for cost/reliability optimization'), + usageReportS3Bucket: z + .string() + .optional() + .describe('S3 bucket name for daily SNS SMS usage reports') + }) + ) + .output( + z.object({ + updatedAttributes: z.array(z.string()).describe('SMS settings changed by this request') + }) + ) + .handleInvocation(async ctx => { + let attributes: Record = {}; + if (ctx.input.monthlySpendLimit !== undefined) { + attributes.MonthlySpendLimit = ctx.input.monthlySpendLimit; + } + if (ctx.input.deliveryStatusIAMRole !== undefined) { + attributes.DeliveryStatusIAMRole = ctx.input.deliveryStatusIAMRole; + } + if (ctx.input.deliveryStatusSuccessSamplingRate !== undefined) { + attributes.DeliveryStatusSuccessSamplingRate = + ctx.input.deliveryStatusSuccessSamplingRate; + } + if (ctx.input.defaultSenderID !== undefined) { + attributes.DefaultSenderID = ctx.input.defaultSenderID; + } + if (ctx.input.defaultSMSType !== undefined) { + attributes.DefaultSMSType = ctx.input.defaultSMSType; + } + if (ctx.input.usageReportS3Bucket !== undefined) { + attributes.UsageReportS3Bucket = ctx.input.usageReportS3Bucket; + } + + let updatedAttributes = Object.keys(attributes); + if (updatedAttributes.length === 0) { + throw snsServiceError('Provide at least one SMS setting to update.'); + } + + let client = new SnsClient({ + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken, + region: ctx.config.region + }); + + await client.setSMSAttributes(attributes); + + return { + output: { updatedAttributes }, + message: `Updated SMS settings: ${updatedAttributes.join(', ')}` + }; + }) + .build(); diff --git a/integrations/aws-sns/src/tools/update-subscription.ts b/integrations/aws-sns/src/tools/update-subscription.ts index b6f48d07bf..a795f8f2ba 100644 --- a/integrations/aws-sns/src/tools/update-subscription.ts +++ b/integrations/aws-sns/src/tools/update-subscription.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SnsClient } from '../lib/client'; +import { snsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateSubscription = SlateTool.create(spec, { @@ -34,7 +35,11 @@ export let updateSubscription = SlateTool.create(spec, { redrivePolicy: z .string() .optional() - .describe('JSON dead-letter queue policy for undeliverable messages') + .describe('JSON dead-letter queue policy for undeliverable messages'), + replayPolicy: z + .string() + .optional() + .describe('JSON FIFO replay policy for replaying archived messages') }) ) .output( @@ -74,6 +79,13 @@ export let updateSubscription = SlateTool.create(spec, { if (ctx.input.redrivePolicy !== undefined) { updates.push({ name: 'RedrivePolicy', value: ctx.input.redrivePolicy }); } + if (ctx.input.replayPolicy !== undefined) { + updates.push({ name: 'ReplayPolicy', value: ctx.input.replayPolicy }); + } + + if (updates.length === 0) { + throw snsServiceError('Provide at least one subscription attribute to update.'); + } for (let attr of updates) { await client.setSubscriptionAttributes(ctx.input.subscriptionArn, attr.name, attr.value); diff --git a/integrations/aws-sns/src/tools/update-topic.ts b/integrations/aws-sns/src/tools/update-topic.ts index 52af51709a..6fdaaf3d07 100644 --- a/integrations/aws-sns/src/tools/update-topic.ts +++ b/integrations/aws-sns/src/tools/update-topic.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SnsClient } from '../lib/client'; +import { snsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateTopic = SlateTool.create(spec, { @@ -33,6 +34,10 @@ export let updateTopic = SlateTool.create(spec, { .boolean() .optional() .describe('Enable content-based deduplication (FIFO topics only)'), + fifoThroughputScope: z + .enum(['Topic', 'MessageGroup']) + .optional() + .describe('FIFO throughput and deduplication scope'), archivePolicy: z .string() .optional() @@ -87,6 +92,9 @@ export let updateTopic = SlateTool.create(spec, { value: String(ctx.input.contentBasedDeduplication) }); } + if (ctx.input.fifoThroughputScope !== undefined) { + attrUpdates.push({ name: 'FifoThroughputScope', value: ctx.input.fifoThroughputScope }); + } if (ctx.input.archivePolicy !== undefined) { attrUpdates.push({ name: 'ArchivePolicy', value: ctx.input.archivePolicy }); } @@ -109,6 +117,10 @@ export let updateTopic = SlateTool.create(spec, { updatedAttributes.push('Tags (removed)'); } + if (updatedAttributes.length === 0) { + throw snsServiceError('Provide at least one topic attribute or tag change.'); + } + return { output: { topicArn: ctx.input.topicArn, diff --git a/integrations/aws-sqs/README.md b/integrations/aws-sqs/README.md index 04afc8fe44..f39a62296c 100644 --- a/integrations/aws-sqs/README.md +++ b/integrations/aws-sqs/README.md @@ -6,7 +6,7 @@ Create, configure, list, and delete message queues (standard and FIFO). Send mes ### Change Message Visibility -Change the visibility timeout of a received message. Use this to extend the processing window when you need more time, or to make the message immediately available again by setting the timeout to 0. +Change the visibility timeout of one or more received messages. Use this to extend the processing window when you need more time, or to make messages immediately available again by setting the timeout to 0. ### Create Queue @@ -24,6 +24,10 @@ Permanently delete an SQS queue and all its messages. The deletion process takes Look up the URL of an SQS queue by its name. Useful when you know the queue name but need the full URL for other operations. Can also look up queues owned by other AWS accounts. +### List Dead-Letter Source Queues + +List source queues whose RedrivePolicy targets a specified dead-letter queue. Useful for auditing DLQ wiring and cleanup before redrive operations. + ### List Queues List SQS queues in the configured AWS region. Optionally filter by queue name prefix and paginate through results. @@ -34,7 +38,7 @@ Start, list, or cancel message move tasks. Message move tasks are used to move m ### Manage Queue -Get or update SQS queue attributes, and manage queue tags. Use this to inspect queue configuration, modify settings like visibility timeout, configure dead-letter queues, enable encryption, or manage cost allocation tags. +Get or update SQS queue attributes, manage queue tags, and add or remove generated queue permissions. Use this to inspect queue configuration, modify settings like visibility timeout, configure dead-letter queues, enable encryption, manage cost allocation tags, or share queue access with AWS accounts. ### Purge Queue @@ -50,7 +54,7 @@ Send up to 10 messages to an SQS queue in a single batch request. Each message c ### Send Message -Send a message to an SQS queue. Supports optional delay, custom message attributes, and FIFO queue parameters (message group ID and deduplication ID). +Send a message to an SQS queue. Supports optional delay, custom message attributes, and FIFO queue parameters (message group ID and deduplication ID). Message bodies can be up to 1 MiB. ## License diff --git a/integrations/aws-sqs/docs/SPEC.md b/integrations/aws-sqs/docs/SPEC.md index bab190ef0c..239a021598 100644 --- a/integrations/aws-sqs/docs/SPEC.md +++ b/integrations/aws-sqs/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Amazon Simple Queue Service (Amazon SQS) is a hosted queue service that lets you integrate and decouple distributed software systems and components. It stores messages on multiple servers for durability, supporting standard queues with at-least-once delivery and FIFO queues with exactly-once processing. Users can create unlimited queues with unlimited messages in any AWS region, with message payloads up to 256KB in any text format. +Amazon Simple Queue Service (Amazon SQS) is a hosted queue service that lets you integrate and decouple distributed software systems and components. It stores messages on multiple servers for durability, supporting standard queues with at-least-once delivery and FIFO queues with exactly-once processing. Users can create unlimited queues with unlimited messages in any AWS region, with message payloads up to 1 MiB in any supported text format. ## Authentication @@ -28,11 +28,11 @@ You control access in AWS by creating policies and attaching them to AWS identit ### Queue Management -Create, configure, list, and delete message queues. Amazon SQS supports two types of queues: standard queues and FIFO queues. Standard queues provide maximum throughput with at-least-once delivery. FIFO queues deliver messages exactly once, and the order in which messages are sent and received is strictly preserved. Key configurable attributes include visibility timeout, message retention period, delay seconds, and maximum message size. You cannot rename a queue or convert between standard and FIFO types after creation. +Create, configure, list, and delete message queues. Amazon SQS supports two types of queues: standard queues and FIFO queues. Standard queues provide maximum throughput with at-least-once delivery. FIFO queues deliver messages exactly once, and the order in which messages are sent and received is strictly preserved. Key configurable attributes include visibility timeout, message retention period, delay seconds, maximum message size, high-throughput FIFO settings, and encryption. You cannot rename a queue or convert between standard and FIFO types after creation. ### Sending Messages -Send messages to a queue with an optional delay. Messages can include a body (up to 256KB), custom message attributes (metadata), and for FIFO queues, a message group ID and deduplication ID. To send messages larger than 256KB, you can use the Amazon SQS Extended Client Library for Java, which uses S3 to store the message payload. +Send messages to a queue with an optional delay. Messages can include a body (up to 1 MiB), custom message attributes (metadata), and for FIFO queues, a message group ID and deduplication ID. For larger payload workflows, use an extended-client pattern that stores payloads outside SQS, such as in Amazon S3. ### Receiving and Deleting Messages @@ -40,7 +40,7 @@ Receive messages from a queue using short polling or long polling. Long polling ### Visibility Timeout Management -Change the visibility timeout of individual messages after they have been received. This allows consumers to extend or shorten the processing window for specific messages. The default visibility timeout for a message is 30 seconds. The minimum is 0 seconds. The maximum is 12 hours. +Change the visibility timeout of individual messages or batches of up to 10 messages after they have been received. This allows consumers to extend or shorten the processing window for specific messages. The default visibility timeout for a message is 30 seconds. The minimum is 0 seconds. The maximum is 12 hours. ### Dead-Letter Queues @@ -52,7 +52,7 @@ Securely share Amazon SQS queues anonymously or with specific AWS accounts. Queu ### Message Move Tasks -Start, list, and cancel message move tasks. This is used to move messages between queues, commonly from a dead-letter queue back to a source queue. +Start, list, and cancel message move tasks. This is used to move messages between queues, commonly from a dead-letter queue back to a source queue. The integration also lists source queues associated with a dead-letter queue to support DLQ audits. ### Server-Side Encryption diff --git a/integrations/aws-sqs/package.json b/integrations/aws-sqs/package.json index 857e1cc96c..5ec47b4412 100644 --- a/integrations/aws-sqs/package.json +++ b/integrations/aws-sqs/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/aws-sqs/src/index.ts b/integrations/aws-sqs/src/index.ts index 0f0996e936..3b6c268547 100644 --- a/integrations/aws-sqs/src/index.ts +++ b/integrations/aws-sqs/src/index.ts @@ -6,6 +6,7 @@ import { deleteMessage, deleteQueue, getQueueUrl, + listDeadLetterSourceQueues, listQueues, manageMessageMoveTask, manageQueue, @@ -23,6 +24,7 @@ export let provider = Slate.create({ deleteQueue, listQueues, getQueueUrl, + listDeadLetterSourceQueues, manageQueue, sendMessage, sendMessageBatch, diff --git a/integrations/aws-sqs/src/lib/client.ts b/integrations/aws-sqs/src/lib/client.ts index c73a1e033a..64bd841c98 100644 --- a/integrations/aws-sqs/src/lib/client.ts +++ b/integrations/aws-sqs/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { sqsApiError } from './errors'; import { type AwsCredentials, signRequest } from './signing'; export interface SqsClientConfig { @@ -58,6 +59,12 @@ export interface QueueAttributes { [key: string]: string; } +export interface ChangeMessageVisibilityBatchEntry { + entryId: string; + receiptHandle: string; + visibilityTimeout: number; +} + export class SqsClient { private region: string; private credentials: AwsCredentials; @@ -88,9 +95,13 @@ export class SqsClient { body: jsonBody }); - let ax = createAxios({ baseURL: endpoint }); - let response = await ax.post('/', jsonBody, { headers }); - return response.data as T; + try { + let ax = createAxios({ baseURL: endpoint }); + let response = await ax.post('/', jsonBody, { headers }); + return response.data as T; + } catch (error) { + throw sqsApiError(error, action); + } } async createQueue(params: CreateQueueParams): Promise<{ queueUrl: string }> { @@ -174,9 +185,12 @@ export class SqsClient { }); } - async sendMessage( - params: SendMessageParams - ): Promise<{ messageId: string; md5OfMessageBody: string; sequenceNumber?: string }> { + async sendMessage(params: SendMessageParams): Promise<{ + messageId: string; + md5OfMessageBody: string; + md5OfMessageAttributes?: string; + sequenceNumber?: string; + }> { let body: Record = { QueueUrl: params.queueUrl, MessageBody: params.messageBody @@ -202,11 +216,13 @@ export class SqsClient { let result = await this.request<{ MessageId: string; MD5OfMessageBody: string; + MD5OfMessageAttributes?: string; SequenceNumber?: string; }>('SendMessage', body); return { messageId: result.MessageId, md5OfMessageBody: result.MD5OfMessageBody, + md5OfMessageAttributes: result.MD5OfMessageAttributes, sequenceNumber: result.SequenceNumber }; } @@ -219,6 +235,7 @@ export class SqsClient { messageId: string; sqsMessageId: string; md5OfMessageBody: string; + md5OfMessageAttributes?: string; sequenceNumber?: string; }[]; failed: { messageId: string; senderFault: boolean; code: string; message?: string }[]; @@ -255,6 +272,7 @@ export class SqsClient { Id: string; MessageId: string; MD5OfMessageBody: string; + MD5OfMessageAttributes?: string; SequenceNumber?: string; }[]; Failed?: { Id: string; SenderFault: boolean; Code: string; Message?: string }[]; @@ -265,6 +283,7 @@ export class SqsClient { messageId: s.Id, sqsMessageId: s.MessageId, md5OfMessageBody: s.MD5OfMessageBody, + md5OfMessageAttributes: s.MD5OfMessageAttributes, sequenceNumber: s.SequenceNumber })), failed: (result.Failed ?? []).map(f => ({ @@ -375,6 +394,36 @@ export class SqsClient { }); } + async changeMessageVisibilityBatch( + queueUrl: string, + entries: ChangeMessageVisibilityBatchEntry[] + ): Promise<{ + successful: string[]; + failed: { entryId: string; code: string; message?: string; senderFault: boolean }[]; + }> { + let result = await this.request<{ + Successful?: { Id: string }[]; + Failed?: { Id: string; Code: string; Message?: string; SenderFault: boolean }[]; + }>('ChangeMessageVisibilityBatch', { + QueueUrl: queueUrl, + Entries: entries.map(e => ({ + Id: e.entryId, + ReceiptHandle: e.receiptHandle, + VisibilityTimeout: e.visibilityTimeout + })) + }); + + return { + successful: (result.Successful ?? []).map(s => s.Id), + failed: (result.Failed ?? []).map(f => ({ + entryId: f.Id, + code: f.Code, + message: f.Message, + senderFault: f.SenderFault + })) + }; + } + async purgeQueue(queueUrl: string): Promise { await this.request>('PurgeQueue', { QueueUrl: queueUrl }); } diff --git a/integrations/aws-sqs/src/lib/errors.ts b/integrations/aws-sqs/src/lib/errors.ts new file mode 100644 index 0000000000..aedf20e0da --- /dev/null +++ b/integrations/aws-sqs/src/lib/errors.ts @@ -0,0 +1,107 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushString = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let extractSqsMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let messages: string[] = []; + + if (isRecord(data)) { + for (let key of ['message', 'Message', 'error', 'Error', 'code', 'Code', '__type']) { + pushString(messages, data[key]); + } + } else { + pushString(messages, data); + } + + if (isRecord(error)) { + for (let key of ['message', 'Message', 'name', 'Code', 'code', '__type']) { + pushString(messages, error[key]); + } + } + + if (error instanceof Error) { + pushString(messages, error.message); + } + + return messages.length > 0 ? [...new Set(messages)].join(' - ') : 'Unknown error'; +}; + +let extractSqsStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + let metadata = isRecord(error.$metadata) ? error.$metadata : undefined; + let status = + response?.status ?? metadata?.httpStatusCode ?? error.statusCode ?? error.status; + + return typeof status === 'number' || typeof status === 'string' ? status : undefined; +}; + +let extractSqsCode = (error: unknown) => { + if (!isRecord(error)) return undefined; + + if (typeof error.Code === 'string') return error.Code; + if (typeof error.code === 'string' && !error.code.startsWith('upstream.')) { + return error.code; + } + if (typeof error.name === 'string' && error.name !== 'Error') return error.name; + + let response = error.response as ErrorResponse | undefined; + let data = response?.data; + if (isRecord(data)) { + if (typeof data.Code === 'string') return data.Code; + if (typeof data.code === 'string') return data.code; + if (typeof data.__type === 'string') return data.__type.split('#').at(-1); + } + + return undefined; +}; + +export let sqsServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let sqsApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = extractSqsStatus(error); + let code = extractSqsCode(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + let codeLabel = code ? `${code} - ` : ''; + let serviceError = sqsServiceError( + `Amazon SQS API ${operation} failed: ${statusLabel}${codeLabel}${extractSqsMessage(error)}` + ); + + serviceError.data.reason = 'aws_sqs_api_error'; + serviceError.data.upstreamStatus = status; + serviceError.data.upstreamCode = code; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/aws-sqs/src/tools.schema.test.ts b/integrations/aws-sqs/src/tools.schema.test.ts new file mode 100644 index 0000000000..273a450680 --- /dev/null +++ b/integrations/aws-sqs/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('AWS SQS tool input schemas', provider.actions); diff --git a/integrations/aws-sqs/src/tools/change-message-visibility.ts b/integrations/aws-sqs/src/tools/change-message-visibility.ts index 18efda7e02..298b98a715 100644 --- a/integrations/aws-sqs/src/tools/change-message-visibility.ts +++ b/integrations/aws-sqs/src/tools/change-message-visibility.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SqsClient } from '../lib/client'; +import { sqsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let changeMessageVisibility = SlateTool.create(spec, { @@ -9,9 +10,13 @@ export let changeMessageVisibility = SlateTool.create(spec, { description: `Change the visibility timeout of a received message. Use this to extend the processing window when you need more time, or to make the message immediately available again by setting the timeout to 0.`, instructions: [ 'Use the receiptHandle from the most recent ReceiveMessage response.', - 'Set visibilityTimeout to 0 to make the message immediately available for other consumers.' + 'Set visibilityTimeout to 0 to make the message immediately available for other consumers.', + 'For batch updates, provide "entries" instead of "receiptHandle" and "visibilityTimeout".' + ], + constraints: [ + 'Visibility timeout range: 0 to 43200 seconds (12 hours).', + 'Batch visibility updates can include up to 10 messages.' ], - constraints: ['Visibility timeout range: 0 to 43200 seconds (12 hours).'], tags: { destructive: false, readOnly: false @@ -20,13 +25,48 @@ export let changeMessageVisibility = SlateTool.create(spec, { .input( z.object({ queueUrl: z.string().describe('Full URL of the SQS queue'), - receiptHandle: z.string().describe('Receipt handle of the message'), - visibilityTimeout: z.number().describe('New visibility timeout in seconds (0-43200)') + receiptHandle: z + .string() + .optional() + .describe('Receipt handle of the single message to update'), + visibilityTimeout: z + .number() + .optional() + .describe('New visibility timeout in seconds (0-43200) for a single message'), + entries: z + .array( + z.object({ + entryId: z.string().describe('Unique identifier for this batch entry'), + receiptHandle: z.string().describe('Receipt handle of the message'), + visibilityTimeout: z + .number() + .describe('New visibility timeout in seconds (0-43200)') + }) + ) + .optional() + .describe( + 'Batch visibility changes for up to 10 messages. Use this instead of receiptHandle and visibilityTimeout.' + ) }) ) .output( z.object({ - updated: z.boolean().describe('Whether the visibility timeout was updated') + updated: z.boolean().describe('Whether the visibility timeout update succeeded'), + successful: z + .array(z.string()) + .optional() + .describe('Entry IDs that were successfully updated (batch mode)'), + failed: z + .array( + z.object({ + entryId: z.string().describe('Entry ID that failed'), + code: z.string().describe('Error code'), + failureMessage: z.string().optional().describe('Error description'), + senderFault: z.boolean().describe('Whether the error was caused by the sender') + }) + ) + .optional() + .describe('Entries that failed to update (batch mode)') }) ) .handleInvocation(async ctx => { @@ -39,6 +79,35 @@ export let changeMessageVisibility = SlateTool.create(spec, { } }); + if (ctx.input.entries && ctx.input.entries.length > 0) { + let result = await client.changeMessageVisibilityBatch( + ctx.input.queueUrl, + ctx.input.entries + ); + let successCount = result.successful.length; + let failCount = result.failed.length; + + return { + output: { + updated: failCount === 0, + successful: result.successful, + failed: result.failed.map(f => ({ + entryId: f.entryId, + code: f.code, + failureMessage: f.message, + senderFault: f.senderFault + })) + }, + message: `Batch visibility update: **${successCount}** succeeded, **${failCount}** failed` + }; + } + + if (!ctx.input.receiptHandle || ctx.input.visibilityTimeout === undefined) { + throw sqsServiceError( + 'Either "receiptHandle" plus "visibilityTimeout" for a single message or "entries" for batch visibility updates must be provided.' + ); + } + await client.changeMessageVisibility( ctx.input.queueUrl, ctx.input.receiptHandle, diff --git a/integrations/aws-sqs/src/tools/create-queue.ts b/integrations/aws-sqs/src/tools/create-queue.ts index 7dd91f6910..7bbb9b85c4 100644 --- a/integrations/aws-sqs/src/tools/create-queue.ts +++ b/integrations/aws-sqs/src/tools/create-queue.ts @@ -56,6 +56,14 @@ Returns the URL of the newly created queue. If a queue with the same name and id .string() .optional() .describe('Set to "true" to enable content-based deduplication (FIFO only)'), + deduplicationScope: z + .string() + .optional() + .describe('FIFO high-throughput deduplication scope: "messageGroup" or "queue"'), + fifoThroughputLimit: z + .string() + .optional() + .describe('FIFO throughput quota mode: "perQueue" or "perMessageGroupId"'), kmsMasterKeyId: z .string() .optional() @@ -113,6 +121,8 @@ Returns the URL of the newly created queue. If a queue with the same name and id ReceiveMessageWaitTimeSeconds: ctx.input.attributes.receiveMessageWaitTimeSeconds, FifoQueue: ctx.input.attributes.fifoQueue, ContentBasedDeduplication: ctx.input.attributes.contentBasedDeduplication, + DeduplicationScope: ctx.input.attributes.deduplicationScope, + FifoThroughputLimit: ctx.input.attributes.fifoThroughputLimit, KmsMasterKeyId: ctx.input.attributes.kmsMasterKeyId, KmsDataKeyReusePeriodSeconds: ctx.input.attributes.kmsDataKeyReusePeriodSeconds, SqsManagedSseEnabled: ctx.input.attributes.sqsManagedSseEnabled, diff --git a/integrations/aws-sqs/src/tools/delete-message.ts b/integrations/aws-sqs/src/tools/delete-message.ts index d0416ec765..023f7e1e72 100644 --- a/integrations/aws-sqs/src/tools/delete-message.ts +++ b/integrations/aws-sqs/src/tools/delete-message.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SqsClient } from '../lib/client'; +import { sqsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let deleteMessage = SlateTool.create(spec, { @@ -88,7 +89,7 @@ export let deleteMessage = SlateTool.create(spec, { } if (!ctx.input.receiptHandle) { - throw new Error( + throw sqsServiceError( 'Either "receiptHandle" for single deletion or "entries" for batch deletion must be provided.' ); } diff --git a/integrations/aws-sqs/src/tools/index.ts b/integrations/aws-sqs/src/tools/index.ts index 7189619f71..a3c3fd0e3a 100644 --- a/integrations/aws-sqs/src/tools/index.ts +++ b/integrations/aws-sqs/src/tools/index.ts @@ -3,6 +3,7 @@ export * from './create-queue'; export * from './delete-message'; export * from './delete-queue'; export * from './get-queue-url'; +export * from './list-dead-letter-source-queues'; export * from './list-queues'; export * from './manage-message-move-task'; export * from './manage-queue'; diff --git a/integrations/aws-sqs/src/tools/list-dead-letter-source-queues.ts b/integrations/aws-sqs/src/tools/list-dead-letter-source-queues.ts new file mode 100644 index 0000000000..f02a556d93 --- /dev/null +++ b/integrations/aws-sqs/src/tools/list-dead-letter-source-queues.ts @@ -0,0 +1,61 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SqsClient } from '../lib/client'; +import { spec } from '../spec'; + +export let listDeadLetterSourceQueues = SlateTool.create(spec, { + name: 'List Dead-Letter Source Queues', + key: 'list_dead_letter_source_queues', + description: `List source queues whose RedrivePolicy uses the specified dead-letter queue. Use this to audit which queues can move failed messages into a DLQ.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + queueUrl: z.string().describe('Full URL of the dead-letter SQS queue'), + maxResults: z + .number() + .optional() + .describe('Maximum number of source queues to return (1-1000)'), + nextToken: z.string().optional().describe('Pagination token from a previous request') + }) + ) + .output( + z.object({ + queueUrls: z.array(z.string()).describe('Source queue URLs that target this DLQ'), + nextToken: z + .string() + .optional() + .describe('Pagination token for the next page of results') + }) + ) + .handleInvocation(async ctx => { + let client = new SqsClient({ + region: ctx.config.region, + credentials: { + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken + } + }); + + let result = await client.listDeadLetterSourceQueues( + ctx.input.queueUrl, + ctx.input.maxResults, + ctx.input.nextToken + ); + + let countMsg = + result.queueUrls.length === 0 + ? 'No source queues found for this dead-letter queue' + : `Found **${result.queueUrls.length}** source queue(s) for this dead-letter queue`; + let paginationMsg = result.nextToken ? ' (more results available)' : ''; + + return { + output: result, + message: `${countMsg}${paginationMsg}` + }; + }) + .build(); diff --git a/integrations/aws-sqs/src/tools/manage-message-move-task.ts b/integrations/aws-sqs/src/tools/manage-message-move-task.ts index 54fd480724..2de52571b2 100644 --- a/integrations/aws-sqs/src/tools/manage-message-move-task.ts +++ b/integrations/aws-sqs/src/tools/manage-message-move-task.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SqsClient } from '../lib/client'; +import { sqsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageMessageMoveTask = SlateTool.create(spec, { @@ -86,7 +87,7 @@ export let manageMessageMoveTask = SlateTool.create(spec, { if (ctx.input.action === 'start') { if (!ctx.input.sourceArn) { - throw new Error('"sourceArn" is required for "start" action'); + throw sqsServiceError('"sourceArn" is required for "start" action'); } let result = await client.startMessageMoveTask( ctx.input.sourceArn, @@ -101,7 +102,7 @@ export let manageMessageMoveTask = SlateTool.create(spec, { if (ctx.input.action === 'list') { if (!ctx.input.sourceArn) { - throw new Error('"sourceArn" is required for "list" action'); + throw sqsServiceError('"sourceArn" is required for "list" action'); } let result = await client.listMessageMoveTasks( ctx.input.sourceArn, @@ -118,7 +119,7 @@ export let manageMessageMoveTask = SlateTool.create(spec, { if (ctx.input.action === 'cancel') { if (!ctx.input.taskHandle) { - throw new Error('"taskHandle" is required for "cancel" action'); + throw sqsServiceError('"taskHandle" is required for "cancel" action'); } let result = await client.cancelMessageMoveTask(ctx.input.taskHandle); return { @@ -127,6 +128,6 @@ export let manageMessageMoveTask = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw sqsServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/aws-sqs/src/tools/manage-queue.ts b/integrations/aws-sqs/src/tools/manage-queue.ts index 445f2735f9..26c0b5b677 100644 --- a/integrations/aws-sqs/src/tools/manage-queue.ts +++ b/integrations/aws-sqs/src/tools/manage-queue.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SqsClient } from '../lib/client'; +import { sqsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageQueue = SlateTool.create(spec, { @@ -11,6 +12,7 @@ export let manageQueue = SlateTool.create(spec, { 'To get queue attributes, provide "queueUrl" and optionally "attributeNames".', 'To update attributes, provide "queueUrl" and "setAttributes".', 'To manage tags, provide "addTags" to add/update or "removeTagKeys" to remove tags.', + 'To manage generated queue permissions, provide "addPermission" or "removePermissionLabel".', 'Changes typically propagate within 60 seconds.' ], tags: { @@ -69,7 +71,15 @@ export let manageQueue = SlateTool.create(spec, { contentBasedDeduplication: z .string() .optional() - .describe('Enable/disable content-based deduplication for FIFO ("true"/"false")') + .describe('Enable/disable content-based deduplication for FIFO ("true"/"false")'), + deduplicationScope: z + .string() + .optional() + .describe('FIFO high-throughput deduplication scope: "messageGroup" or "queue"'), + fifoThroughputLimit: z + .string() + .optional() + .describe('FIFO throughput quota mode: "perQueue" or "perMessageGroupId"') }) .optional() .describe('Attributes to update on the queue'), @@ -80,7 +90,29 @@ export let manageQueue = SlateTool.create(spec, { removeTagKeys: z .array(z.string()) .optional() - .describe('Tag keys to remove from the queue') + .describe('Tag keys to remove from the queue'), + addPermission: z + .object({ + label: z + .string() + .describe( + 'Unique permission label, up to 80 alphanumeric/hyphen/underscore chars' + ), + awsAccountIds: z + .array(z.string()) + .describe('AWS account IDs that receive the permission'), + actions: z + .array(z.string()) + .describe('SQS action names to allow, such as "SendMessage" or "*"') + }) + .optional() + .describe( + 'Generated queue permission statement to add with AddPermission. Supports AWS account principals only.' + ), + removePermissionLabel: z + .string() + .optional() + .describe('Permission label to remove with RemovePermission') }) ) .output( @@ -93,7 +125,13 @@ export let manageQueue = SlateTool.create(spec, { .record(z.string(), z.string()) .optional() .describe('Current queue tags (returned when tags are modified)'), - updated: z.boolean().describe('Whether any attributes or tags were updated') + permissionsUpdated: z + .boolean() + .optional() + .describe('Whether queue permission statements were added or removed'), + updated: z + .boolean() + .describe('Whether any attributes, tags, or permissions were updated') }) ) .handleInvocation(async ctx => { @@ -124,7 +162,9 @@ export let manageQueue = SlateTool.create(spec, { KmsMasterKeyId: ctx.input.setAttributes.kmsMasterKeyId, KmsDataKeyReusePeriodSeconds: ctx.input.setAttributes.kmsDataKeyReusePeriodSeconds, SqsManagedSseEnabled: ctx.input.setAttributes.sqsManagedSseEnabled, - ContentBasedDeduplication: ctx.input.setAttributes.contentBasedDeduplication + ContentBasedDeduplication: ctx.input.setAttributes.contentBasedDeduplication, + DeduplicationScope: ctx.input.setAttributes.deduplicationScope, + FifoThroughputLimit: ctx.input.setAttributes.fifoThroughputLimit }; for (let [key, val] of Object.entries(attrMap)) { @@ -154,6 +194,35 @@ export let manageQueue = SlateTool.create(spec, { messages.push(`Removed **${ctx.input.removeTagKeys.length}** tag(s)`); } + let permissionsUpdated = false; + if (ctx.input.addPermission) { + if (ctx.input.addPermission.awsAccountIds.length === 0) { + throw sqsServiceError( + 'addPermission.awsAccountIds must include at least one AWS account ID.' + ); + } + if (ctx.input.addPermission.actions.length === 0) { + throw sqsServiceError('addPermission.actions must include at least one SQS action.'); + } + + await client.addPermission( + ctx.input.queueUrl, + ctx.input.addPermission.label, + ctx.input.addPermission.awsAccountIds, + ctx.input.addPermission.actions + ); + updated = true; + permissionsUpdated = true; + messages.push(`Added permission **${ctx.input.addPermission.label}**`); + } + + if (ctx.input.removePermissionLabel) { + await client.removePermission(ctx.input.queueUrl, ctx.input.removePermissionLabel); + updated = true; + permissionsUpdated = true; + messages.push(`Removed permission **${ctx.input.removePermissionLabel}**`); + } + // Get attributes let attributes = await client.getQueueAttributes( ctx.input.queueUrl, @@ -174,6 +243,7 @@ export let manageQueue = SlateTool.create(spec, { output: { attributes, tags, + permissionsUpdated: permissionsUpdated || undefined, updated }, message: messages.join('. ') diff --git a/integrations/aws-sqs/src/tools/send-message-batch.ts b/integrations/aws-sqs/src/tools/send-message-batch.ts index e07c8cfdfc..5c0f7545fd 100644 --- a/integrations/aws-sqs/src/tools/send-message-batch.ts +++ b/integrations/aws-sqs/src/tools/send-message-batch.ts @@ -9,7 +9,7 @@ export let sendMessageBatch = SlateTool.create(spec, { description: `Send up to 10 messages to an SQS queue in a single batch request. Each message can have its own body, delay, and attributes. Returns results for both successful and failed entries.`, constraints: [ 'Maximum 10 messages per batch.', - 'Total payload size for the batch must not exceed 256 KB.', + 'Total payload size for the batch must not exceed 1 MiB.', 'Each entry requires a unique ID within the batch.' ], tags: { @@ -59,6 +59,10 @@ export let sendMessageBatch = SlateTool.create(spec, { entryId: z.string().describe('Batch entry ID that succeeded'), sqsMessageId: z.string().describe('SQS-assigned message ID'), md5OfMessageBody: z.string().describe('MD5 digest of the message body'), + md5OfMessageAttributes: z + .string() + .optional() + .describe('MD5 digest of the message attributes when attributes were sent'), sequenceNumber: z.string().optional().describe('Sequence number for FIFO queues') }) ) @@ -106,6 +110,7 @@ export let sendMessageBatch = SlateTool.create(spec, { entryId: s.messageId, sqsMessageId: s.sqsMessageId, md5OfMessageBody: s.md5OfMessageBody, + md5OfMessageAttributes: s.md5OfMessageAttributes, sequenceNumber: s.sequenceNumber })), failed: result.failed.map(f => ({ diff --git a/integrations/aws-sqs/src/tools/send-message.ts b/integrations/aws-sqs/src/tools/send-message.ts index 1da508b3eb..086520ec8b 100644 --- a/integrations/aws-sqs/src/tools/send-message.ts +++ b/integrations/aws-sqs/src/tools/send-message.ts @@ -11,7 +11,7 @@ export let sendMessage = SlateTool.create(spec, { 'For FIFO queues, messageGroupId is required.', 'For FIFO queues without content-based deduplication enabled, messageDeduplicationId is also required.' ], - constraints: ['Message body size limit is 256 KB.', 'Delay range is 0-900 seconds.'], + constraints: ['Message body size limit is 1 MiB.', 'Delay range is 0-900 seconds.'], tags: { destructive: false, readOnly: false @@ -53,6 +53,10 @@ export let sendMessage = SlateTool.create(spec, { md5OfMessageBody: z .string() .describe('MD5 digest of the message body for integrity verification'), + md5OfMessageAttributes: z + .string() + .optional() + .describe('MD5 digest of the message attributes when attributes were sent'), sequenceNumber: z.string().optional().describe('Sequence number for FIFO queues') }) ) diff --git a/integrations/aws-sqs/vitest.config.ts b/integrations/aws-sqs/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/aws-sqs/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/aws-transcribe/README.md b/integrations/aws-transcribe/README.md index 7ff92c0eca..813bb1197d 100644 --- a/integrations/aws-transcribe/README.md +++ b/integrations/aws-transcribe/README.md @@ -1,9 +1,17 @@ # Aws Transcribe -Transcribe speech to text from audio and video files or real-time audio streams. Perform batch transcription of media files stored in S3 and streaming transcription of live audio. Automatically identify languages, apply custom vocabularies and language models, filter unwanted words, identify speakers (diarization), and transcribe multi-channel audio. Redact personally identifiable information (PII) from transcripts. Generate subtitles in WebVTT and SRT formats. Detect toxic speech content. Analyze call center audio to extract sentiment, call categories, and AI-powered summaries. Transcribe medical dictation and patient-clinician conversations. Generate clinical notes from healthcare conversations using AWS HealthScribe. Monitor transcription job state changes, language identification events, and vocabulary updates via EventBridge. +Transcribe speech to text from audio and video files stored in S3. Start and inspect standard, Call Analytics, and medical batch transcription jobs. Automatically identify languages, apply custom vocabularies and language models, filter unwanted words, identify speakers (diarization), and transcribe multi-channel audio. Redact personally identifiable information (PII) from transcripts and Call Analytics source audio. Generate subtitles in WebVTT and SRT formats. Detect toxic speech content. Manage Call Analytics categories, standard vocabularies, vocabulary filters, and medical vocabularies. Monitor transcription job state changes through polling triggers. ## Tools +### Delete Call Analytics Job + +Delete a Call Analytics job and its metadata. This does not delete transcript output stored in S3. + +### Delete Medical Transcription Job + +Delete a medical transcription job and its metadata. This does not delete transcript output stored in S3. + ### Delete Transcription Job Delete a transcription job and its associated metadata. The job must be in a terminal state (COMPLETED or FAILED) to be deleted. This does not delete the transcript output from S3. @@ -12,18 +20,38 @@ Delete a transcription job and its associated metadata. The job must be in a ter Retrieve the status and details of a Call Analytics job including transcript URIs, channel definitions, completion time, and failure reason. Use this to check if a call analytics job has completed and to get the results location. +### Get Medical Transcription Job + +Retrieve status and details for a medical transcription job, including transcript URI, specialty, type, and failure details. + ### Get Transcription Job Retrieve the status and details of a transcription job including its transcript output URI, language, settings, and completion time. Use this to check if a job has completed and to get the transcript location. +### List Call Analytics Jobs + +List Call Analytics jobs in your AWS account. Filter by job status or job name and paginate through results. + ### List Language Models List custom language models in your AWS account. Filter by status or name to find specific models. Custom language models improve transcription accuracy for specific domains by training on your text data. +### List Medical Transcription Jobs + +List medical transcription jobs in your AWS account. Filter by status or job name and paginate through results. + ### List Transcription Jobs List transcription jobs in your AWS account. Filter by status or job name to find specific jobs. Returns summaries with job names, statuses, creation times, and language codes. Supports pagination for large result sets. +### Manage Call Analytics Category + +Create, update, get, delete, or list Call Analytics categories. Categories define rule-based labels that AWS applies to Call Analytics jobs created after the category exists. + +### Manage Medical Vocabulary + +Create, update, get, delete, or list custom medical vocabularies for Amazon Transcribe Medical. Medical vocabularies use a vocabulary table file stored in S3 or an accessible URI. + ### Manage Vocabulary Filter Create, update, get, delete, or list vocabulary filters. Vocabulary filters specify words to remove, mask, or tag in transcripts — commonly used for removing profanity or unwanted words. Provide filter words as a list or via an S3 file. diff --git a/integrations/aws-transcribe/docs/SPEC.md b/integrations/aws-transcribe/docs/SPEC.md index f5d926f73f..46a2d68535 100644 --- a/integrations/aws-transcribe/docs/SPEC.md +++ b/integrations/aws-transcribe/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Amazon Transcribe is a fully managed automatic speech recognition (ASR) service that converts speech to text. It supports both batch transcription of media files stored in Amazon S3 and real-time streaming transcription of audio. It offers three main types of batch transcription: Standard, Medical, and Call Analytics. +Amazon Transcribe is a fully managed automatic speech recognition (ASR) service that converts speech to text. This integration focuses on the practical batch API surface for media files stored in Amazon S3: Standard, Medical, and Call Analytics transcription jobs, plus the vocabularies, filters, categories, and language model metadata those workflows need. ## Authentication @@ -27,7 +27,7 @@ Transcribe pre-recorded audio or video files stored in Amazon S3. Supported form ### Streaming Transcription -Process live audio for real-time transcription by sending audio over a secure connection and receiving text in response. Streaming supports Standard, Medical, Call Analytics, and HealthScribe transcription types. Streaming connections can remain open for up to four hours. +Amazon Transcribe also supports real-time streaming APIs. This integration does not expose streaming tools because they require bidirectional streaming transport rather than the batch JSON API used by the current tool surface. ### Automatic Language Identification @@ -75,11 +75,11 @@ Amazon Transcribe Medical enables medical speech-to-text capabilities for transc ### AWS HealthScribe -HealthScribe transcriptions are designed to automatically create clinical notes from patient-clinician conversations using generative AI. +HealthScribe APIs are outside the current high-value batch transcription scope for this integration. ## Events -AWS Transcribe supports event-driven notifications through Amazon EventBridge. +AWS Transcribe supports event-driven notifications through Amazon EventBridge. This integration exposes a polling trigger for terminal transcription job states rather than provisioning EventBridge rules. ### Transcription Job State Changes diff --git a/integrations/aws-transcribe/package.json b/integrations/aws-transcribe/package.json index 593d4b3ef9..e6eae66d9e 100644 --- a/integrations/aws-transcribe/package.json +++ b/integrations/aws-transcribe/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/aws-transcribe/src/index.ts b/integrations/aws-transcribe/src/index.ts index 599b534b25..6209884df4 100644 --- a/integrations/aws-transcribe/src/index.ts +++ b/integrations/aws-transcribe/src/index.ts @@ -1,11 +1,18 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + deleteCallAnalyticsJob, + deleteMedicalTranscriptionJob, deleteTranscriptionJob, getCallAnalyticsJob, + getMedicalTranscriptionJob, getTranscriptionJob, + listCallAnalyticsJobs, listLanguageModels, + listMedicalTranscriptionJobs, listTranscriptionJobs, + manageCallAnalyticsCategory, + manageMedicalVocabulary, manageVocabulary, manageVocabularyFilter, startCallAnalyticsJob, @@ -23,9 +30,16 @@ export let provider = Slate.create({ deleteTranscriptionJob.build(), startCallAnalyticsJob.build(), getCallAnalyticsJob.build(), + listCallAnalyticsJobs.build(), + deleteCallAnalyticsJob.build(), startMedicalTranscriptionJob.build(), + getMedicalTranscriptionJob.build(), + listMedicalTranscriptionJobs.build(), + deleteMedicalTranscriptionJob.build(), + manageCallAnalyticsCategory.build(), manageVocabulary.build(), manageVocabularyFilter.build(), + manageMedicalVocabulary.build(), listLanguageModels.build() ], triggers: [inboundWebhook, transcriptionJobStateChange.build()] diff --git a/integrations/aws-transcribe/src/lib/client.ts b/integrations/aws-transcribe/src/lib/client.ts index 3e23026bd9..0397c93a63 100644 --- a/integrations/aws-transcribe/src/lib/client.ts +++ b/integrations/aws-transcribe/src/lib/client.ts @@ -1,11 +1,91 @@ import { createAxios } from 'slates'; import { type AwsCredentials, signRequest } from './aws-signer'; +import { transcribeApiError } from './errors'; export interface TranscribeClientConfig { credentials: AwsCredentials; region: string; } +let mapLanguageIdSettings = (settings: Record) => + Object.fromEntries( + Object.entries(settings).map(([languageCode, setting]) => [ + languageCode, + { + ...(setting.languageModelName ? { LanguageModelName: setting.languageModelName } : {}), + ...(setting.vocabularyName ? { VocabularyName: setting.vocabularyName } : {}), + ...(setting.vocabularyFilterName + ? { VocabularyFilterName: setting.vocabularyFilterName } + : {}) + } + ]) + ); + +let mapTimeRange = (range: TimeRange | undefined) => { + if (!range) { + return undefined; + } + + return { + ...(range.first !== undefined ? { First: range.first } : {}), + ...(range.last !== undefined ? { Last: range.last } : {}) + }; +}; + +let mapCallAnalyticsRule = (rule: CallAnalyticsCategoryRule): Record => { + let base = { + ...(rule.absoluteTimeRange + ? { AbsoluteTimeRange: mapTimeRange(rule.absoluteTimeRange) } + : {}), + ...(rule.relativeTimeRange + ? { RelativeTimeRange: mapTimeRange(rule.relativeTimeRange) } + : {}), + ...(rule.negate !== undefined ? { Negate: rule.negate } : {}), + ...(rule.participantRole ? { ParticipantRole: rule.participantRole } : {}) + }; + + if (rule.ruleType === 'transcript') { + return { + TranscriptFilter: { + ...base, + Targets: rule.targets, + TranscriptFilterType: rule.transcriptFilterType || 'EXACT' + } + }; + } + + if (rule.ruleType === 'sentiment') { + return { + SentimentFilter: { + ...base, + Sentiments: rule.sentiments + } + }; + } + + if (rule.ruleType === 'interruption') { + return { + InterruptionFilter: { + ...base, + ...(rule.threshold !== undefined ? { Threshold: rule.threshold } : {}) + } + }; + } + + return { + NonTalkTimeFilter: { + ...(rule.absoluteTimeRange + ? { AbsoluteTimeRange: mapTimeRange(rule.absoluteTimeRange) } + : {}), + ...(rule.relativeTimeRange + ? { RelativeTimeRange: mapTimeRange(rule.relativeTimeRange) } + : {}), + ...(rule.negate !== undefined ? { Negate: rule.negate } : {}), + ...(rule.threshold !== undefined ? { Threshold: rule.threshold } : {}) + } + }; +}; + export class TranscribeClient { private credentials: AwsCredentials; private region: string; @@ -37,22 +117,31 @@ export class TranscribeClient { service: 'transcribe' }); - let response = await ax.post(url, body, { - headers: { - ...headers, - ...sigHeaders - } - }); + try { + let response = await ax.post(url, body, { + headers: { + ...headers, + ...sigHeaders + } + }); - return response.data; + return response.data; + } catch (error) { + throw transcribeApiError(error, target); + } } // ---- Transcription Jobs ---- async startTranscriptionJob(params: StartTranscriptionJobParams): Promise { + let media: Record = { MediaFileUri: params.mediaFileUri }; + if (params.redactedMediaFileUri) { + media.RedactedMediaFileUri = params.redactedMediaFileUri; + } + let payload: Record = { TranscriptionJobName: params.jobName, - Media: { MediaFileUri: params.mediaFileUri } + Media: media }; if (params.languageCode) payload.LanguageCode = params.languageCode; @@ -67,6 +156,10 @@ export class TranscribeClient { if (params.outputKey) payload.OutputKey = params.outputKey; if (params.outputEncryptionKmsKeyId) payload.OutputEncryptionKMSKeyId = params.outputEncryptionKmsKeyId; + if (params.kmsEncryptionContext) + payload.KMSEncryptionContext = params.kmsEncryptionContext; + if (params.languageIdSettings) + payload.LanguageIdSettings = mapLanguageIdSettings(params.languageIdSettings); if (params.settings) { let settings: Record = {}; @@ -154,9 +247,14 @@ export class TranscribeClient { // ---- Call Analytics Jobs ---- async startCallAnalyticsJob(params: StartCallAnalyticsJobParams): Promise { + let media: Record = { MediaFileUri: params.mediaFileUri }; + if (params.redactedMediaFileUri) { + media.RedactedMediaFileUri = params.redactedMediaFileUri; + } + let payload: Record = { CallAnalyticsJobName: params.jobName, - Media: { MediaFileUri: params.mediaFileUri } + Media: media }; if (params.dataAccessRoleArn) payload.DataAccessRoleArn = params.dataAccessRoleArn; @@ -183,6 +281,10 @@ export class TranscribeClient { settings.LanguageModelName = params.settings.languageModelName; if (params.settings.languageOptions) settings.LanguageOptions = params.settings.languageOptions; + if (params.settings.languageIdSettings) + settings.LanguageIdSettings = mapLanguageIdSettings( + params.settings.languageIdSettings + ); if (params.settings.summarization !== undefined) { settings.Summarization = { GenerateAbstractiveSummary: params.settings.summarization }; } @@ -227,6 +329,48 @@ export class TranscribeClient { }); } + // ---- Call Analytics Categories ---- + + async createCallAnalyticsCategory(params: CreateCallAnalyticsCategoryParams): Promise { + let payload: Record = { + CategoryName: params.categoryName, + Rules: params.rules.map(mapCallAnalyticsRule) + }; + if (params.inputType) payload.InputType = params.inputType; + if (params.tags) { + payload.Tags = params.tags.map(t => ({ Key: t.key, Value: t.value })); + } + return this.request('CreateCallAnalyticsCategory', payload); + } + + async updateCallAnalyticsCategory(params: UpdateCallAnalyticsCategoryParams): Promise { + let payload: Record = { + CategoryName: params.categoryName, + Rules: params.rules.map(mapCallAnalyticsRule) + }; + if (params.inputType) payload.InputType = params.inputType; + return this.request('UpdateCallAnalyticsCategory', payload); + } + + async getCallAnalyticsCategory(categoryName: string): Promise { + return this.request('GetCallAnalyticsCategory', { + CategoryName: categoryName + }); + } + + async deleteCallAnalyticsCategory(categoryName: string): Promise { + return this.request('DeleteCallAnalyticsCategory', { + CategoryName: categoryName + }); + } + + async listCallAnalyticsCategories(params?: ListCallAnalyticsCategoriesParams): Promise { + let payload: Record = {}; + if (params?.maxResults) payload.MaxResults = params.maxResults; + if (params?.nextToken) payload.NextToken = params.nextToken; + return this.request('ListCallAnalyticsCategories', payload); + } + // ---- Medical Transcription Jobs ---- async startMedicalTranscriptionJob( @@ -244,6 +388,8 @@ export class TranscribeClient { if (params.outputKey) payload.OutputKey = params.outputKey; if (params.outputEncryptionKmsKeyId) payload.OutputEncryptionKMSKeyId = params.outputEncryptionKmsKeyId; + if (params.kmsEncryptionContext) + payload.KMSEncryptionContext = params.kmsEncryptionContext; if (params.mediaFormat) payload.MediaFormat = params.mediaFormat; if (params.mediaSampleRateHertz) payload.MediaSampleRateHertz = params.mediaSampleRateHertz; @@ -345,6 +491,49 @@ export class TranscribeClient { return this.request('ListVocabularies', payload); } + // ---- Medical Vocabularies ---- + + async createMedicalVocabulary(params: CreateMedicalVocabularyParams): Promise { + let payload: Record = { + VocabularyName: params.vocabularyName, + LanguageCode: params.languageCode, + VocabularyFileUri: params.vocabularyFileUri + }; + if (params.tags) { + payload.Tags = params.tags.map(t => ({ Key: t.key, Value: t.value })); + } + return this.request('CreateMedicalVocabulary', payload); + } + + async getMedicalVocabulary(vocabularyName: string): Promise { + return this.request('GetMedicalVocabulary', { + VocabularyName: vocabularyName + }); + } + + async updateMedicalVocabulary(params: UpdateMedicalVocabularyParams): Promise { + return this.request('UpdateMedicalVocabulary', { + VocabularyName: params.vocabularyName, + LanguageCode: params.languageCode, + VocabularyFileUri: params.vocabularyFileUri + }); + } + + async deleteMedicalVocabulary(vocabularyName: string): Promise { + return this.request('DeleteMedicalVocabulary', { + VocabularyName: vocabularyName + }); + } + + async listMedicalVocabularies(params?: ListMedicalVocabulariesParams): Promise { + let payload: Record = {}; + if (params?.stateEquals) payload.StateEquals = params.stateEquals; + if (params?.nameContains) payload.NameContains = params.nameContains; + if (params?.maxResults) payload.MaxResults = params.maxResults; + if (params?.nextToken) payload.NextToken = params.nextToken; + return this.request('ListMedicalVocabularies', payload); + } + // ---- Vocabulary Filters ---- async createVocabularyFilter(params: CreateVocabularyFilterParams): Promise { @@ -422,15 +611,18 @@ export class TranscribeClient { export interface StartTranscriptionJobParams { jobName: string; mediaFileUri: string; + redactedMediaFileUri?: string; languageCode?: string; identifyLanguage?: boolean; identifyMultipleLanguages?: boolean; + languageIdSettings?: Record; languageOptions?: string[]; mediaFormat?: string; mediaSampleRateHertz?: number; outputBucketName?: string; outputKey?: string; outputEncryptionKmsKeyId?: string; + kmsEncryptionContext?: Record; settings?: { vocabularyName?: string; showSpeakerLabels?: boolean; @@ -471,6 +663,7 @@ export interface ListTranscriptionJobsParams { export interface StartCallAnalyticsJobParams { jobName: string; mediaFileUri: string; + redactedMediaFileUri?: string; dataAccessRoleArn?: string; outputLocation?: string; outputEncryptionKmsKeyId?: string; @@ -484,6 +677,7 @@ export interface StartCallAnalyticsJobParams { vocabularyFilterMethod?: string; languageModelName?: string; languageOptions?: string[]; + languageIdSettings?: Record; summarization?: boolean; contentRedaction?: { redactionType: string; @@ -501,6 +695,53 @@ export interface ListCallAnalyticsJobsParams { nextToken?: string; } +export interface TimeRange { + first?: number; + last?: number; +} + +export interface LanguageIdSetting { + languageModelName?: string; + vocabularyName?: string; + vocabularyFilterName?: string; +} + +export type CallAnalyticsRuleType = + | 'transcript' + | 'sentiment' + | 'interruption' + | 'non_talk_time'; + +export interface CallAnalyticsCategoryRule { + ruleType: CallAnalyticsRuleType; + targets?: string[]; + transcriptFilterType?: 'EXACT'; + sentiments?: Array<'POSITIVE' | 'NEGATIVE' | 'NEUTRAL' | 'MIXED'>; + participantRole?: 'AGENT' | 'CUSTOMER'; + negate?: boolean; + threshold?: number; + absoluteTimeRange?: TimeRange; + relativeTimeRange?: TimeRange; +} + +export interface CreateCallAnalyticsCategoryParams { + categoryName: string; + inputType?: string; + rules: CallAnalyticsCategoryRule[]; + tags?: Array<{ key: string; value: string }>; +} + +export interface UpdateCallAnalyticsCategoryParams { + categoryName: string; + inputType?: string; + rules: CallAnalyticsCategoryRule[]; +} + +export interface ListCallAnalyticsCategoriesParams { + maxResults?: number; + nextToken?: string; +} + export interface StartMedicalTranscriptionJobParams { jobName: string; mediaFileUri: string; @@ -510,6 +751,7 @@ export interface StartMedicalTranscriptionJobParams { outputBucketName: string; outputKey?: string; outputEncryptionKmsKeyId?: string; + kmsEncryptionContext?: Record; mediaFormat?: string; mediaSampleRateHertz?: number; settings?: { @@ -553,6 +795,26 @@ export interface ListVocabulariesParams { nextToken?: string; } +export interface CreateMedicalVocabularyParams { + vocabularyName: string; + languageCode: string; + vocabularyFileUri: string; + tags?: Array<{ key: string; value: string }>; +} + +export interface UpdateMedicalVocabularyParams { + vocabularyName: string; + languageCode: string; + vocabularyFileUri: string; +} + +export interface ListMedicalVocabulariesParams { + stateEquals?: string; + nameContains?: string; + maxResults?: number; + nextToken?: string; +} + export interface CreateVocabularyFilterParams { vocabularyFilterName: string; languageCode: string; diff --git a/integrations/aws-transcribe/src/lib/errors.ts b/integrations/aws-transcribe/src/lib/errors.ts new file mode 100644 index 0000000000..45680d28ca --- /dev/null +++ b/integrations/aws-transcribe/src/lib/errors.ts @@ -0,0 +1,111 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractTranscribeMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + for (let key of ['message', 'Message', 'error', 'Error', 'code', 'Code', '__type']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (isRecord(error)) { + for (let key of ['message', 'Message', 'name', 'code', 'Code', '__type']) { + addDetail(details, error[key]); + } + } + + if (error instanceof Error) { + addDetail(details, error.message); + } + + return details.length > 0 ? details.join(' - ') : 'Unknown error'; +}; + +let extractTranscribeStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + let status = response?.status ?? error.statusCode ?? error.status; + + return typeof status === 'number' || typeof status === 'string' ? status : undefined; +}; + +let extractTranscribeCode = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + if (typeof error.Code === 'string') return error.Code; + if (typeof error.code === 'string' && !error.code.startsWith('upstream.')) { + return error.code; + } + if (typeof error.name === 'string' && error.name !== 'Error') return error.name; + + let response = error.response as ErrorResponse | undefined; + let data = response?.data; + if (isRecord(data)) { + if (typeof data.Code === 'string') return data.Code; + if (typeof data.code === 'string') return data.code; + if (typeof data.__type === 'string') return data.__type.split('#').at(-1); + } + + return undefined; +}; + +export let transcribeServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let transcribeApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = extractTranscribeStatus(error); + let code = extractTranscribeCode(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + let codeLabel = code ? `${code} - ` : ''; + let serviceError = transcribeServiceError( + `Amazon Transcribe API ${operation} failed: ${statusLabel}${codeLabel}${extractTranscribeMessage(error)}` + ); + + serviceError.data.reason = 'aws_transcribe_api_error'; + serviceError.data.upstreamStatus = status; + serviceError.data.upstreamCode = code; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/aws-transcribe/src/tools.schema.test.ts b/integrations/aws-transcribe/src/tools.schema.test.ts new file mode 100644 index 0000000000..ac4ca7f3ba --- /dev/null +++ b/integrations/aws-transcribe/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('AWS Transcribe tool input schemas', provider.actions); diff --git a/integrations/aws-transcribe/src/tools/common.ts b/integrations/aws-transcribe/src/tools/common.ts new file mode 100644 index 0000000000..2aaae87fc6 --- /dev/null +++ b/integrations/aws-transcribe/src/tools/common.ts @@ -0,0 +1,249 @@ +import { z } from 'zod'; +import { transcribeServiceError } from '../lib/errors'; + +export let tagSchema = z.object({ + key: z.string().describe('Tag key'), + value: z.string().describe('Tag value') +}); + +export let mediaFormatSchema = z.enum([ + 'mp3', + 'mp4', + 'wav', + 'flac', + 'ogg', + 'amr', + 'webm', + 'm4a' +]); + +export let kmsEncryptionContextSchema = z + .record(z.string(), z.string()) + .optional() + .describe('Optional AWS KMS encryption context key-value pairs'); + +export let languageIdSettingSchema = z.object({ + languageModelName: z + .string() + .optional() + .describe('Custom language model for this detected language'), + vocabularyName: z + .string() + .optional() + .describe('Custom vocabulary for this detected language'), + vocabularyFilterName: z + .string() + .optional() + .describe('Custom vocabulary filter for this detected language') +}); + +export let languageIdSettingsSchema = z + .record(z.string(), languageIdSettingSchema) + .optional() + .describe( + 'Automatic language identification settings keyed by language code. Use with languageOptions.' + ); + +export let timeRangeSchema = z.object({ + first: z + .number() + .optional() + .describe( + 'Beginning of the range, in milliseconds for absolute ranges or percent for relative ranges' + ), + last: z + .number() + .optional() + .describe( + 'End of the range, in milliseconds for absolute ranges or percent for relative ranges' + ) +}); + +export let callAnalyticsRuleSchema = z.object({ + ruleType: z + .enum(['transcript', 'sentiment', 'interruption', 'non_talk_time']) + .describe('Call Analytics category rule type'), + targets: z + .array(z.string()) + .optional() + .describe('Transcript phrases to match. Required when ruleType is transcript.'), + transcriptFilterType: z + .enum(['EXACT']) + .optional() + .describe('Transcript match type. AWS currently supports EXACT.'), + sentiments: z + .array(z.enum(['POSITIVE', 'NEGATIVE', 'NEUTRAL', 'MIXED'])) + .optional() + .describe('Sentiments to match. Required when ruleType is sentiment.'), + participantRole: z + .enum(['AGENT', 'CUSTOMER']) + .optional() + .describe('Participant role to evaluate. Omit to evaluate both participants.'), + negate: z + .boolean() + .optional() + .describe('Whether the rule should match absence instead of presence'), + threshold: z + .number() + .optional() + .describe('Milliseconds threshold for interruption or non-talk-time rules'), + absoluteTimeRange: timeRangeSchema + .optional() + .describe('Absolute time range to evaluate, in milliseconds'), + relativeTimeRange: timeRangeSchema + .optional() + .describe('Relative time range to evaluate, in percent') +}); + +export let requireString = (value: unknown, message: string): string => { + if (typeof value !== 'string' || value.trim().length === 0) { + throw transcribeServiceError(message); + } + + return value; +}; + +export let requireArray = (value: T[] | undefined, message: string): T[] => { + if (!Array.isArray(value) || value.length === 0) { + throw transcribeServiceError(message); + } + + return value; +}; + +export let ensureExactlyOne = (checks: [string, boolean][], message: string) => { + let present = checks.filter(([, value]) => value).map(([label]) => label); + if (present.length !== 1) { + throw transcribeServiceError(`${message} Received: ${present.join(', ') || 'none'}.`); + } +}; + +export let ensureNotBoth = ( + first: unknown, + firstLabel: string, + second: unknown, + secondLabel: string +) => { + if (first !== undefined && second !== undefined) { + throw transcribeServiceError(`Provide either ${firstLabel} or ${secondLabel}, not both.`); + } +}; + +export let validateLanguageIdSettings = ( + languageIdSettings: Record | undefined, + languageOptions: string[] | undefined, + options?: { allowLanguageModel?: boolean } +) => { + if (!languageIdSettings) { + return; + } + + let entries = Object.entries(languageIdSettings); + if (entries.length < 2 || entries.length > 5) { + throw transcribeServiceError( + 'languageIdSettings must include between 2 and 5 language codes.' + ); + } + + if (!languageOptions?.length) { + throw transcribeServiceError( + 'languageOptions is required when languageIdSettings is provided.' + ); + } + + for (let [languageCode, value] of entries) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw transcribeServiceError(`languageIdSettings.${languageCode} must be an object.`); + } + + let setting = value as { + languageModelName?: string; + vocabularyName?: string; + vocabularyFilterName?: string; + }; + + if ( + !setting.languageModelName && + !setting.vocabularyName && + !setting.vocabularyFilterName + ) { + throw transcribeServiceError( + `languageIdSettings.${languageCode} must include a languageModelName, vocabularyName, or vocabularyFilterName.` + ); + } + + if (options?.allowLanguageModel === false && setting.languageModelName) { + throw transcribeServiceError( + 'languageIdSettings.languageModelName is not supported with identifyMultipleLanguages.' + ); + } + } +}; + +export let validateVocabularyFilterMethod = ( + vocabularyFilterName: string | undefined, + vocabularyFilterMethod: string | undefined +) => { + if (vocabularyFilterName && !vocabularyFilterMethod) { + throw transcribeServiceError( + 'vocabularyFilterMethod is required when vocabularyFilterName is provided.' + ); + } +}; + +export let validateVocabularySource = ( + phrases: string[] | undefined, + fileUri: string | undefined, + fileLabel = 'vocabularyFileUri' +) => { + ensureExactlyOne( + [ + ['phrases/words', Array.isArray(phrases) && phrases.length > 0], + [fileLabel, typeof fileUri === 'string' && fileUri.trim().length > 0] + ], + `Provide exactly one source: phrases/words or ${fileLabel}.` + ); +}; + +export let validateCallAnalyticsRules = ( + rules: z.infer[] | undefined +) => { + let providedRules = requireArray( + rules, + 'At least one Call Analytics category rule is required.' + ); + + if (providedRules.length > 20) { + throw transcribeServiceError('A Call Analytics category can include at most 20 rules.'); + } + + for (let [index, rule] of providedRules.entries()) { + ensureNotBoth( + rule.absoluteTimeRange, + `rules[${index}].absoluteTimeRange`, + rule.relativeTimeRange, + `rules[${index}].relativeTimeRange` + ); + + if (rule.ruleType === 'transcript') { + requireArray(rule.targets, `rules[${index}].targets is required for transcript rules.`); + continue; + } + + if (rule.ruleType === 'sentiment') { + requireArray( + rule.sentiments, + `rules[${index}].sentiments is required for sentiment rules.` + ); + continue; + } + + if (rule.ruleType === 'non_talk_time' && rule.participantRole) { + throw transcribeServiceError( + `rules[${index}].participantRole is not supported for non_talk_time rules.` + ); + } + } + + return providedRules; +}; diff --git a/integrations/aws-transcribe/src/tools/delete-call-analytics-job.ts b/integrations/aws-transcribe/src/tools/delete-call-analytics-job.ts new file mode 100644 index 0000000000..e4dfc51190 --- /dev/null +++ b/integrations/aws-transcribe/src/tools/delete-call-analytics-job.ts @@ -0,0 +1,45 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { TranscribeClient } from '../lib/client'; +import { spec } from '../spec'; + +export let deleteCallAnalyticsJob = SlateTool.create(spec, { + name: 'Delete Call Analytics Job', + key: 'delete_call_analytics_job', + description: + 'Delete a Call Analytics job and its metadata. This does not delete transcript output stored in S3.', + tags: { + destructive: true + } +}) + .input( + z.object({ + jobName: z.string().describe('Name of the Call Analytics job to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean().describe('Whether the job was deleted'), + jobName: z.string().describe('Name of the deleted Call Analytics job') + }) + ) + .handleInvocation(async ctx => { + let client = new TranscribeClient({ + credentials: { + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken + }, + region: ctx.config.region + }); + + await client.deleteCallAnalyticsJob(ctx.input.jobName); + + return { + output: { + deleted: true, + jobName: ctx.input.jobName + }, + message: `Deleted Call Analytics job **${ctx.input.jobName}**.` + }; + }); diff --git a/integrations/aws-transcribe/src/tools/delete-medical-transcription-job.ts b/integrations/aws-transcribe/src/tools/delete-medical-transcription-job.ts new file mode 100644 index 0000000000..82713d1407 --- /dev/null +++ b/integrations/aws-transcribe/src/tools/delete-medical-transcription-job.ts @@ -0,0 +1,45 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { TranscribeClient } from '../lib/client'; +import { spec } from '../spec'; + +export let deleteMedicalTranscriptionJob = SlateTool.create(spec, { + name: 'Delete Medical Transcription Job', + key: 'delete_medical_transcription_job', + description: + 'Delete a medical transcription job and its metadata. This does not delete transcript output stored in S3.', + tags: { + destructive: true + } +}) + .input( + z.object({ + jobName: z.string().describe('Name of the medical transcription job to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean().describe('Whether the job was deleted'), + jobName: z.string().describe('Name of the deleted medical transcription job') + }) + ) + .handleInvocation(async ctx => { + let client = new TranscribeClient({ + credentials: { + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken + }, + region: ctx.config.region + }); + + await client.deleteMedicalTranscriptionJob(ctx.input.jobName); + + return { + output: { + deleted: true, + jobName: ctx.input.jobName + }, + message: `Deleted medical transcription job **${ctx.input.jobName}**.` + }; + }); diff --git a/integrations/aws-transcribe/src/tools/get-medical-transcription-job.ts b/integrations/aws-transcribe/src/tools/get-medical-transcription-job.ts new file mode 100644 index 0000000000..5704c7fefa --- /dev/null +++ b/integrations/aws-transcribe/src/tools/get-medical-transcription-job.ts @@ -0,0 +1,80 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { TranscribeClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getMedicalTranscriptionJob = SlateTool.create(spec, { + name: 'Get Medical Transcription Job', + key: 'get_medical_transcription_job', + description: + 'Retrieve status and details for a medical transcription job, including transcript URI, specialty, type, and failure details.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + jobName: z.string().describe('Name of the medical transcription job to retrieve') + }) + ) + .output( + z.object({ + jobName: z.string().describe('Name of the medical transcription job'), + jobStatus: z.string().describe('Current status of the job'), + languageCode: z.string().optional().describe('Language code'), + mediaFileUri: z.string().optional().describe('S3 URI of the input media file'), + transcriptFileUri: z.string().optional().describe('S3 URI of the transcript output'), + mediaFormat: z.string().optional().describe('Format of the input media'), + mediaSampleRateHertz: z.number().optional().describe('Sample rate of the input audio'), + specialty: z.string().optional().describe('Medical specialty'), + type: z.string().optional().describe('Medical transcription type'), + creationTime: z.number().optional().describe('Unix timestamp when created'), + startTime: z.number().optional().describe('Unix timestamp when started'), + completionTime: z.number().optional().describe('Unix timestamp when completed'), + failureReason: z.string().optional().describe('Failure reason, if any'), + contentIdentificationType: z + .string() + .optional() + .describe('Content identification type such as PHI') + }) + ) + .handleInvocation(async ctx => { + let client = new TranscribeClient({ + credentials: { + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken + }, + region: ctx.config.region + }); + + let result = await client.getMedicalTranscriptionJob(ctx.input.jobName); + let job = result.MedicalTranscriptionJob; + + let statusMsg = + job.TranscriptionJobStatus === 'COMPLETED' + ? `completed. Transcript: ${job.Transcript?.TranscriptFileUri}` + : job.TranscriptionJobStatus === 'FAILED' + ? `failed: ${job.FailureReason}` + : job.TranscriptionJobStatus; + + return { + output: { + jobName: job.MedicalTranscriptionJobName, + jobStatus: job.TranscriptionJobStatus, + languageCode: job.LanguageCode, + mediaFileUri: job.Media?.MediaFileUri, + transcriptFileUri: job.Transcript?.TranscriptFileUri, + mediaFormat: job.MediaFormat, + mediaSampleRateHertz: job.MediaSampleRateHertz, + specialty: job.Specialty, + type: job.Type, + creationTime: job.CreationTime, + startTime: job.StartTime, + completionTime: job.CompletionTime, + failureReason: job.FailureReason, + contentIdentificationType: job.ContentIdentificationType + }, + message: `Medical transcription job **${job.MedicalTranscriptionJobName}** is **${statusMsg}**.` + }; + }); diff --git a/integrations/aws-transcribe/src/tools/index.ts b/integrations/aws-transcribe/src/tools/index.ts index 98c78b6d46..d6c60f6d5a 100644 --- a/integrations/aws-transcribe/src/tools/index.ts +++ b/integrations/aws-transcribe/src/tools/index.ts @@ -1,8 +1,15 @@ +export * from './delete-call-analytics-job'; +export * from './delete-medical-transcription-job'; export * from './delete-transcription-job'; export * from './get-call-analytics-job'; +export * from './get-medical-transcription-job'; export * from './get-transcription-job'; +export * from './list-call-analytics-jobs'; export * from './list-language-models'; +export * from './list-medical-transcription-jobs'; export * from './list-transcription-jobs'; +export * from './manage-call-analytics-category'; +export * from './manage-medical-vocabulary'; export * from './manage-vocabulary'; export * from './manage-vocabulary-filter'; export * from './start-call-analytics-job'; diff --git a/integrations/aws-transcribe/src/tools/list-call-analytics-jobs.ts b/integrations/aws-transcribe/src/tools/list-call-analytics-jobs.ts new file mode 100644 index 0000000000..aa17bbd7a5 --- /dev/null +++ b/integrations/aws-transcribe/src/tools/list-call-analytics-jobs.ts @@ -0,0 +1,75 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { TranscribeClient } from '../lib/client'; +import { spec } from '../spec'; + +export let listCallAnalyticsJobs = SlateTool.create(spec, { + name: 'List Call Analytics Jobs', + key: 'list_call_analytics_jobs', + description: + 'List Call Analytics jobs in your AWS account. Filter by job status or job name and paginate through results.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + status: z + .enum(['QUEUED', 'IN_PROGRESS', 'FAILED', 'COMPLETED']) + .optional() + .describe('Filter by job status'), + jobNameContains: z + .string() + .optional() + .describe('Filter jobs whose name contains this string'), + maxResults: z.number().optional().describe('Maximum number of results (1-100)'), + nextToken: z.string().optional().describe('Pagination token from a previous response') + }) + ) + .output( + z.object({ + jobs: z + .array( + z.object({ + jobName: z.string().describe('Name of the Call Analytics job'), + jobStatus: z.string().describe('Current status of the job'), + languageCode: z.string().optional().describe('Language code'), + creationTime: z.number().optional().describe('Unix timestamp when created'), + startTime: z.number().optional().describe('Unix timestamp when started'), + completionTime: z.number().optional().describe('Unix timestamp when completed'), + failureReason: z.string().optional().describe('Failure reason, if any') + }) + ) + .describe('List of Call Analytics job summaries'), + nextToken: z.string().optional().describe('Pagination token for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new TranscribeClient({ + credentials: { + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken + }, + region: ctx.config.region + }); + + let result = await client.listCallAnalyticsJobs(ctx.input); + let jobs = (result.CallAnalyticsJobSummaries || []).map((job: any) => ({ + jobName: job.CallAnalyticsJobName, + jobStatus: job.CallAnalyticsJobStatus, + languageCode: job.LanguageCode, + creationTime: job.CreationTime, + startTime: job.StartTime, + completionTime: job.CompletionTime, + failureReason: job.FailureReason + })); + + return { + output: { + jobs, + nextToken: result.NextToken + }, + message: `Found **${jobs.length}** Call Analytics job(s).${result.NextToken ? ' More results are available with pagination.' : ''}` + }; + }); diff --git a/integrations/aws-transcribe/src/tools/list-medical-transcription-jobs.ts b/integrations/aws-transcribe/src/tools/list-medical-transcription-jobs.ts new file mode 100644 index 0000000000..a3e10411df --- /dev/null +++ b/integrations/aws-transcribe/src/tools/list-medical-transcription-jobs.ts @@ -0,0 +1,81 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { TranscribeClient } from '../lib/client'; +import { spec } from '../spec'; + +export let listMedicalTranscriptionJobs = SlateTool.create(spec, { + name: 'List Medical Transcription Jobs', + key: 'list_medical_transcription_jobs', + description: + 'List medical transcription jobs in your AWS account. Filter by status or job name and paginate through results.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + status: z + .enum(['QUEUED', 'IN_PROGRESS', 'FAILED', 'COMPLETED']) + .optional() + .describe('Filter by job status'), + jobNameContains: z + .string() + .optional() + .describe('Filter jobs whose name contains this string'), + maxResults: z.number().optional().describe('Maximum number of results (1-100)'), + nextToken: z.string().optional().describe('Pagination token from a previous response') + }) + ) + .output( + z.object({ + jobs: z + .array( + z.object({ + jobName: z.string().describe('Name of the medical transcription job'), + jobStatus: z.string().describe('Current status of the job'), + languageCode: z.string().optional().describe('Language code'), + specialty: z.string().optional().describe('Medical specialty'), + type: z.string().optional().describe('Medical transcription type'), + creationTime: z.number().optional().describe('Unix timestamp when created'), + startTime: z.number().optional().describe('Unix timestamp when started'), + completionTime: z.number().optional().describe('Unix timestamp when completed'), + failureReason: z.string().optional().describe('Failure reason, if any'), + outputLocationType: z.string().optional().describe('Where output is stored') + }) + ) + .describe('List of medical transcription job summaries'), + nextToken: z.string().optional().describe('Pagination token for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new TranscribeClient({ + credentials: { + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken + }, + region: ctx.config.region + }); + + let result = await client.listMedicalTranscriptionJobs(ctx.input); + let jobs = (result.MedicalTranscriptionJobSummaries || []).map((job: any) => ({ + jobName: job.MedicalTranscriptionJobName, + jobStatus: job.TranscriptionJobStatus, + languageCode: job.LanguageCode, + specialty: job.Specialty, + type: job.Type, + creationTime: job.CreationTime, + startTime: job.StartTime, + completionTime: job.CompletionTime, + failureReason: job.FailureReason, + outputLocationType: job.OutputLocationType + })); + + return { + output: { + jobs, + nextToken: result.NextToken + }, + message: `Found **${jobs.length}** medical transcription job(s).${result.NextToken ? ' More results are available with pagination.' : ''}` + }; + }); diff --git a/integrations/aws-transcribe/src/tools/manage-call-analytics-category.ts b/integrations/aws-transcribe/src/tools/manage-call-analytics-category.ts new file mode 100644 index 0000000000..f74f722e7a --- /dev/null +++ b/integrations/aws-transcribe/src/tools/manage-call-analytics-category.ts @@ -0,0 +1,187 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { TranscribeClient } from '../lib/client'; +import { spec } from '../spec'; +import { + callAnalyticsRuleSchema, + requireString, + tagSchema, + validateCallAnalyticsRules +} from './common'; + +let categoryOutputSchema = z.object({ + categoryName: z.string().describe('Name of the Call Analytics category'), + inputType: z.string().optional().describe('Category input type'), + createTime: z.number().optional().describe('Unix timestamp when created'), + lastUpdateTime: z.number().optional().describe('Unix timestamp when last updated'), + rules: z + .array(z.record(z.string(), z.any())) + .optional() + .describe('AWS Call Analytics category rules'), + tags: z.array(tagSchema).optional().describe('Tags attached to the category') +}); + +let mapCategory = (category: any) => ({ + categoryName: category.CategoryName, + inputType: category.InputType, + createTime: category.CreateTime, + lastUpdateTime: category.LastUpdateTime, + rules: category.Rules, + tags: (category.Tags || []).map((tag: any) => ({ + key: tag.Key, + value: tag.Value + })) +}); + +export let manageCallAnalyticsCategory = SlateTool.create(spec, { + name: 'Manage Call Analytics Category', + key: 'manage_call_analytics_category', + description: + 'Create, update, get, delete, or list Call Analytics categories. Categories define rule-based labels that AWS applies to Call Analytics jobs created after the category exists.', + instructions: [ + 'For create and update, provide 1-20 rules. Each rule uses ruleType to select one AWS rule member.', + 'Categories are applied only to Call Analytics jobs created after the category exists.', + 'For get and delete, only categoryName is required. For list, all parameters are optional.' + ], + tags: { + destructive: false + } +}) + .input( + z.object({ + action: z + .enum(['create', 'update', 'get', 'delete', 'list']) + .describe('Action to perform on the Call Analytics category'), + categoryName: z + .string() + .optional() + .describe('Category name. Required for create, update, get, and delete.'), + inputType: z + .enum(['POST_CALL', 'REAL_TIME']) + .optional() + .describe('Whether this category applies to post-call or real-time analytics'), + rules: z + .array(callAnalyticsRuleSchema) + .optional() + .describe('Rules for create and update. Each rule must match its ruleType.'), + tags: z.array(tagSchema).optional().describe('Tags for the category on create'), + maxResults: z.number().optional().describe('Max results to return for list (1-100)'), + nextToken: z.string().optional().describe('Pagination token for list') + }) + ) + .output( + z.object({ + category: categoryOutputSchema.optional().describe('Category details'), + categories: z + .array(categoryOutputSchema) + .optional() + .describe('Category details returned by list'), + categoryName: z.string().optional().describe('Category name'), + deleted: z.boolean().optional().describe('Whether the category was deleted'), + nextToken: z.string().optional().describe('Pagination token for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new TranscribeClient({ + credentials: { + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken + }, + region: ctx.config.region + }); + + let { action } = ctx.input; + + if (action === 'create') { + let categoryName = requireString( + ctx.input.categoryName, + 'categoryName is required for create.' + ); + let rules = validateCallAnalyticsRules(ctx.input.rules); + let result = await client.createCallAnalyticsCategory({ + categoryName, + inputType: ctx.input.inputType, + rules, + tags: ctx.input.tags + }); + let category = mapCategory(result.CategoryProperties); + + return { + output: { + category, + categoryName: category.categoryName + }, + message: `Created Call Analytics category **${category.categoryName}**.` + }; + } + + if (action === 'update') { + let categoryName = requireString( + ctx.input.categoryName, + 'categoryName is required for update.' + ); + let rules = validateCallAnalyticsRules(ctx.input.rules); + let result = await client.updateCallAnalyticsCategory({ + categoryName, + inputType: ctx.input.inputType, + rules + }); + let category = mapCategory(result.CategoryProperties); + + return { + output: { + category, + categoryName: category.categoryName + }, + message: `Updated Call Analytics category **${category.categoryName}**.` + }; + } + + if (action === 'get') { + let categoryName = requireString( + ctx.input.categoryName, + 'categoryName is required for get.' + ); + let result = await client.getCallAnalyticsCategory(categoryName); + let category = mapCategory(result.CategoryProperties); + + return { + output: { + category, + categoryName: category.categoryName + }, + message: `Retrieved Call Analytics category **${category.categoryName}**.` + }; + } + + if (action === 'delete') { + let categoryName = requireString( + ctx.input.categoryName, + 'categoryName is required for delete.' + ); + await client.deleteCallAnalyticsCategory(categoryName); + + return { + output: { + categoryName, + deleted: true + }, + message: `Deleted Call Analytics category **${categoryName}**.` + }; + } + + let result = await client.listCallAnalyticsCategories({ + maxResults: ctx.input.maxResults, + nextToken: ctx.input.nextToken + }); + let categories = (result.Categories || []).map(mapCategory); + + return { + output: { + categories, + nextToken: result.NextToken + }, + message: `Found **${categories.length}** Call Analytics categor${categories.length === 1 ? 'y' : 'ies'}.` + }; + }); diff --git a/integrations/aws-transcribe/src/tools/manage-medical-vocabulary.ts b/integrations/aws-transcribe/src/tools/manage-medical-vocabulary.ts new file mode 100644 index 0000000000..29625cb017 --- /dev/null +++ b/integrations/aws-transcribe/src/tools/manage-medical-vocabulary.ts @@ -0,0 +1,210 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { TranscribeClient } from '../lib/client'; +import { spec } from '../spec'; +import { requireString, tagSchema } from './common'; + +export let manageMedicalVocabulary = SlateTool.create(spec, { + name: 'Manage Medical Vocabulary', + key: 'manage_medical_vocabulary', + description: + 'Create, update, get, delete, or list custom medical vocabularies for Amazon Transcribe Medical. Medical vocabularies use a vocabulary table file stored in S3 or an accessible URI.', + instructions: [ + 'For create and update, provide vocabularyName, languageCode, and vocabularyFileUri.', + 'Amazon Transcribe Medical currently supports en-US for medical vocabularies.', + 'For get and delete, only vocabularyName is required. For list, all parameters are optional.' + ], + tags: { + destructive: false + } +}) + .input( + z.object({ + action: z + .enum(['create', 'update', 'get', 'delete', 'list']) + .describe('Action to perform on the medical vocabulary'), + vocabularyName: z + .string() + .optional() + .describe('Medical vocabulary name. Required for create, update, get, and delete.'), + languageCode: z + .enum(['en-US']) + .optional() + .describe('Language code. AWS Transcribe Medical currently supports en-US.'), + vocabularyFileUri: z + .string() + .optional() + .describe('S3 or HTTPS URI of the medical vocabulary table file'), + tags: z + .array(tagSchema) + .optional() + .describe('Tags for the medical vocabulary on create'), + stateEquals: z + .enum(['PENDING', 'READY', 'FAILED']) + .optional() + .describe('Filter by vocabulary state for list'), + nameContains: z.string().optional().describe('Filter by name for list'), + maxResults: z.number().optional().describe('Max results to return for list (1-100)'), + nextToken: z.string().optional().describe('Pagination token for list') + }) + ) + .output( + z.object({ + vocabularyName: z.string().optional().describe('Name of the medical vocabulary'), + vocabularyState: z + .string() + .optional() + .describe('State of the vocabulary (PENDING, READY, FAILED)'), + languageCode: z.string().optional().describe('Language code'), + lastModifiedTime: z.number().optional().describe('Unix timestamp of last modification'), + downloadUri: z + .string() + .optional() + .describe('URI for downloading the medical vocabulary table'), + failureReason: z.string().optional().describe('Failure reason if state is FAILED'), + deleted: z.boolean().optional().describe('Whether the medical vocabulary was deleted'), + vocabularies: z + .array( + z.object({ + vocabularyName: z.string().describe('Medical vocabulary name'), + vocabularyState: z.string().describe('Vocabulary state'), + languageCode: z.string().describe('Language code'), + lastModifiedTime: z.number().optional().describe('Last modification time') + }) + ) + .optional() + .describe('List of medical vocabularies'), + nextToken: z.string().optional().describe('Pagination token for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new TranscribeClient({ + credentials: { + accessKeyId: ctx.auth.accessKeyId, + secretAccessKey: ctx.auth.secretAccessKey, + sessionToken: ctx.auth.sessionToken + }, + region: ctx.config.region + }); + + let { action } = ctx.input; + + if (action === 'create') { + let vocabularyName = requireString( + ctx.input.vocabularyName, + 'vocabularyName is required for create.' + ); + let languageCode = requireString( + ctx.input.languageCode, + 'languageCode is required for create.' + ); + let vocabularyFileUri = requireString( + ctx.input.vocabularyFileUri, + 'vocabularyFileUri is required for create.' + ); + let result = await client.createMedicalVocabulary({ + vocabularyName, + languageCode, + vocabularyFileUri, + tags: ctx.input.tags + }); + + return { + output: { + vocabularyName: result.VocabularyName, + vocabularyState: result.VocabularyState, + languageCode: result.LanguageCode, + lastModifiedTime: result.LastModifiedTime, + failureReason: result.FailureReason + }, + message: `Created medical vocabulary **${result.VocabularyName}** with state **${result.VocabularyState}**.` + }; + } + + if (action === 'update') { + let vocabularyName = requireString( + ctx.input.vocabularyName, + 'vocabularyName is required for update.' + ); + let languageCode = requireString( + ctx.input.languageCode, + 'languageCode is required for update.' + ); + let vocabularyFileUri = requireString( + ctx.input.vocabularyFileUri, + 'vocabularyFileUri is required for update.' + ); + let result = await client.updateMedicalVocabulary({ + vocabularyName, + languageCode, + vocabularyFileUri + }); + + return { + output: { + vocabularyName: result.VocabularyName, + vocabularyState: result.VocabularyState, + languageCode: result.LanguageCode, + lastModifiedTime: result.LastModifiedTime + }, + message: `Updated medical vocabulary **${result.VocabularyName}** with state **${result.VocabularyState}**.` + }; + } + + if (action === 'get') { + let vocabularyName = requireString( + ctx.input.vocabularyName, + 'vocabularyName is required for get.' + ); + let result = await client.getMedicalVocabulary(vocabularyName); + + return { + output: { + vocabularyName: result.VocabularyName, + vocabularyState: result.VocabularyState, + languageCode: result.LanguageCode, + lastModifiedTime: result.LastModifiedTime, + downloadUri: result.DownloadUri, + failureReason: result.FailureReason + }, + message: `Medical vocabulary **${result.VocabularyName}** is in state **${result.VocabularyState}**.` + }; + } + + if (action === 'delete') { + let vocabularyName = requireString( + ctx.input.vocabularyName, + 'vocabularyName is required for delete.' + ); + await client.deleteMedicalVocabulary(vocabularyName); + + return { + output: { + vocabularyName, + deleted: true + }, + message: `Deleted medical vocabulary **${vocabularyName}**.` + }; + } + + let result = await client.listMedicalVocabularies({ + stateEquals: ctx.input.stateEquals, + nameContains: ctx.input.nameContains, + maxResults: ctx.input.maxResults, + nextToken: ctx.input.nextToken + }); + let vocabularies = (result.Vocabularies || []).map((vocabulary: any) => ({ + vocabularyName: vocabulary.VocabularyName, + vocabularyState: vocabulary.VocabularyState, + languageCode: vocabulary.LanguageCode, + lastModifiedTime: vocabulary.LastModifiedTime + })); + + return { + output: { + vocabularies, + nextToken: result.NextToken + }, + message: `Found **${vocabularies.length}** medical vocabular${vocabularies.length === 1 ? 'y' : 'ies'}.` + }; + }); diff --git a/integrations/aws-transcribe/src/tools/manage-vocabulary-filter.ts b/integrations/aws-transcribe/src/tools/manage-vocabulary-filter.ts index 16c103fd2c..c433f51347 100644 --- a/integrations/aws-transcribe/src/tools/manage-vocabulary-filter.ts +++ b/integrations/aws-transcribe/src/tools/manage-vocabulary-filter.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { TranscribeClient } from '../lib/client'; import { spec } from '../spec'; +import { requireString, tagSchema, validateVocabularySource } from './common'; export let manageVocabularyFilter = SlateTool.create(spec, { name: 'Manage Vocabulary Filter', @@ -37,15 +38,7 @@ export let manageVocabularyFilter = SlateTool.create(spec, { .string() .optional() .describe('S3 URI of a text file containing filter words'), - tags: z - .array( - z.object({ - key: z.string().describe('Tag key'), - value: z.string().describe('Tag value') - }) - ) - .optional() - .describe('Tags for the filter (create only)'), + tags: z.array(tagSchema).optional().describe('Tags for the filter (create only)'), nameContains: z.string().optional().describe('Filter by name (list only)'), maxResults: z.number().optional().describe('Max results to return (list only, 1-100)'), nextToken: z.string().optional().describe('Pagination token (list only)') @@ -87,9 +80,23 @@ export let manageVocabularyFilter = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { + let vocabularyFilterName = requireString( + ctx.input.vocabularyFilterName, + 'vocabularyFilterName is required for create.' + ); + let languageCode = requireString( + ctx.input.languageCode, + 'languageCode is required for create.' + ); + validateVocabularySource( + ctx.input.words, + ctx.input.vocabularyFilterFileUri, + 'vocabularyFilterFileUri' + ); + let result = await client.createVocabularyFilter({ - vocabularyFilterName: ctx.input.vocabularyFilterName!, - languageCode: ctx.input.languageCode!, + vocabularyFilterName, + languageCode, words: ctx.input.words, vocabularyFilterFileUri: ctx.input.vocabularyFilterFileUri, tags: ctx.input.tags @@ -105,8 +112,18 @@ export let manageVocabularyFilter = SlateTool.create(spec, { } if (action === 'update') { + let vocabularyFilterName = requireString( + ctx.input.vocabularyFilterName, + 'vocabularyFilterName is required for update.' + ); + validateVocabularySource( + ctx.input.words, + ctx.input.vocabularyFilterFileUri, + 'vocabularyFilterFileUri' + ); + let result = await client.updateVocabularyFilter({ - vocabularyFilterName: ctx.input.vocabularyFilterName!, + vocabularyFilterName, words: ctx.input.words, vocabularyFilterFileUri: ctx.input.vocabularyFilterFileUri }); @@ -121,7 +138,11 @@ export let manageVocabularyFilter = SlateTool.create(spec, { } if (action === 'get') { - let result = await client.getVocabularyFilter(ctx.input.vocabularyFilterName!); + let vocabularyFilterName = requireString( + ctx.input.vocabularyFilterName, + 'vocabularyFilterName is required for get.' + ); + let result = await client.getVocabularyFilter(vocabularyFilterName); return { output: { vocabularyFilterName: result.VocabularyFilterName, @@ -134,13 +155,17 @@ export let manageVocabularyFilter = SlateTool.create(spec, { } if (action === 'delete') { - await client.deleteVocabularyFilter(ctx.input.vocabularyFilterName!); + let vocabularyFilterName = requireString( + ctx.input.vocabularyFilterName, + 'vocabularyFilterName is required for delete.' + ); + await client.deleteVocabularyFilter(vocabularyFilterName); return { output: { - vocabularyFilterName: ctx.input.vocabularyFilterName, + vocabularyFilterName, deleted: true }, - message: `Deleted vocabulary filter **${ctx.input.vocabularyFilterName}**.` + message: `Deleted vocabulary filter **${vocabularyFilterName}**.` }; } diff --git a/integrations/aws-transcribe/src/tools/manage-vocabulary.ts b/integrations/aws-transcribe/src/tools/manage-vocabulary.ts index bd53817c68..0ffe89bd10 100644 --- a/integrations/aws-transcribe/src/tools/manage-vocabulary.ts +++ b/integrations/aws-transcribe/src/tools/manage-vocabulary.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { TranscribeClient } from '../lib/client'; import { spec } from '../spec'; +import { requireString, tagSchema, validateVocabularySource } from './common'; export let manageVocabulary = SlateTool.create(spec, { name: 'Manage Vocabulary', @@ -37,15 +38,7 @@ export let manageVocabulary = SlateTool.create(spec, { .string() .optional() .describe('S3 URI of a text file containing vocabulary terms'), - tags: z - .array( - z.object({ - key: z.string().describe('Tag key'), - value: z.string().describe('Tag value') - }) - ) - .optional() - .describe('Tags for the vocabulary (create only)'), + tags: z.array(tagSchema).optional().describe('Tags for the vocabulary (create only)'), stateEquals: z .enum(['PENDING', 'READY', 'FAILED']) .optional() @@ -97,9 +90,19 @@ export let manageVocabulary = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { + let vocabularyName = requireString( + ctx.input.vocabularyName, + 'vocabularyName is required for create.' + ); + let languageCode = requireString( + ctx.input.languageCode, + 'languageCode is required for create.' + ); + validateVocabularySource(ctx.input.phrases, ctx.input.vocabularyFileUri); + let result = await client.createVocabulary({ - vocabularyName: ctx.input.vocabularyName!, - languageCode: ctx.input.languageCode!, + vocabularyName, + languageCode, phrases: ctx.input.phrases, vocabularyFileUri: ctx.input.vocabularyFileUri, tags: ctx.input.tags @@ -117,9 +120,19 @@ export let manageVocabulary = SlateTool.create(spec, { } if (action === 'update') { + let vocabularyName = requireString( + ctx.input.vocabularyName, + 'vocabularyName is required for update.' + ); + let languageCode = requireString( + ctx.input.languageCode, + 'languageCode is required for update.' + ); + validateVocabularySource(ctx.input.phrases, ctx.input.vocabularyFileUri); + let result = await client.updateVocabulary({ - vocabularyName: ctx.input.vocabularyName!, - languageCode: ctx.input.languageCode!, + vocabularyName, + languageCode, phrases: ctx.input.phrases, vocabularyFileUri: ctx.input.vocabularyFileUri }); @@ -135,7 +148,11 @@ export let manageVocabulary = SlateTool.create(spec, { } if (action === 'get') { - let result = await client.getVocabulary(ctx.input.vocabularyName!); + let vocabularyName = requireString( + ctx.input.vocabularyName, + 'vocabularyName is required for get.' + ); + let result = await client.getVocabulary(vocabularyName); return { output: { vocabularyName: result.VocabularyName, @@ -150,13 +167,17 @@ export let manageVocabulary = SlateTool.create(spec, { } if (action === 'delete') { - await client.deleteVocabulary(ctx.input.vocabularyName!); + let vocabularyName = requireString( + ctx.input.vocabularyName, + 'vocabularyName is required for delete.' + ); + await client.deleteVocabulary(vocabularyName); return { output: { - vocabularyName: ctx.input.vocabularyName, + vocabularyName, deleted: true }, - message: `Deleted vocabulary **${ctx.input.vocabularyName}**.` + message: `Deleted vocabulary **${vocabularyName}**.` }; } diff --git a/integrations/aws-transcribe/src/tools/start-call-analytics-job.ts b/integrations/aws-transcribe/src/tools/start-call-analytics-job.ts index 4a1600329e..92d289daf0 100644 --- a/integrations/aws-transcribe/src/tools/start-call-analytics-job.ts +++ b/integrations/aws-transcribe/src/tools/start-call-analytics-job.ts @@ -2,6 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { TranscribeClient } from '../lib/client'; import { spec } from '../spec'; +import { + languageIdSettingsSchema, + tagSchema, + validateLanguageIdSettings, + validateVocabularyFilterMethod +} from './common'; export let startCallAnalyticsJob = SlateTool.create(spec, { name: 'Start Call Analytics Job', @@ -26,6 +32,12 @@ export let startCallAnalyticsJob = SlateTool.create(spec, { .string() .describe('Unique name for the call analytics job (1-200 chars, no spaces)'), mediaFileUri: z.string().describe('S3 URI of the call audio file'), + redactedMediaFileUri: z + .string() + .optional() + .describe( + 'S3 URI where AWS should write redacted source audio when using PII redaction' + ), dataAccessRoleArn: z .string() .optional() @@ -59,6 +71,7 @@ export let startCallAnalyticsJob = SlateTool.create(spec, { .array(z.string()) .optional() .describe('Expected language codes for auto-identification'), + languageIdSettings: languageIdSettingsSchema, summarization: z.boolean().optional().describe('Enable AI-generated call summary'), contentRedaction: z .object({ @@ -76,15 +89,7 @@ export let startCallAnalyticsJob = SlateTool.create(spec, { }) .optional() .describe('Call analytics settings'), - tags: z - .array( - z.object({ - key: z.string().describe('Tag key'), - value: z.string().describe('Tag value') - }) - ) - .optional() - .describe('Tags to associate with the job') + tags: z.array(tagSchema).optional().describe('Tags to associate with the job') }) ) .output( @@ -99,6 +104,15 @@ export let startCallAnalyticsJob = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + validateLanguageIdSettings( + ctx.input.settings?.languageIdSettings, + ctx.input.settings?.languageOptions + ); + validateVocabularyFilterMethod( + ctx.input.settings?.vocabularyFilterName, + ctx.input.settings?.vocabularyFilterMethod + ); + let client = new TranscribeClient({ credentials: { accessKeyId: ctx.auth.accessKeyId, diff --git a/integrations/aws-transcribe/src/tools/start-medical-transcription-job.ts b/integrations/aws-transcribe/src/tools/start-medical-transcription-job.ts index 372e8a48f1..94fd8817b7 100644 --- a/integrations/aws-transcribe/src/tools/start-medical-transcription-job.ts +++ b/integrations/aws-transcribe/src/tools/start-medical-transcription-job.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { TranscribeClient } from '../lib/client'; import { spec } from '../spec'; +import { kmsEncryptionContextSchema, mediaFormatSchema, tagSchema } from './common'; export let startMedicalTranscriptionJob = SlateTool.create(spec, { name: 'Start Medical Transcription Job', @@ -28,9 +29,9 @@ export let startMedicalTranscriptionJob = SlateTool.create(spec, { .describe('Unique name for the medical transcription job (1-200 chars, no spaces)'), mediaFileUri: z.string().describe('S3 URI of the medical audio file'), languageCode: z - .string() + .enum(['en-US']) .default('en-US') - .describe('Language code (currently only en-US supported)'), + .describe('Language code. AWS Transcribe Medical currently supports en-US.'), specialty: z.enum(['PRIMARYCARE']).default('PRIMARYCARE').describe('Medical specialty'), type: z .enum(['CONVERSATION', 'DICTATION']) @@ -41,10 +42,8 @@ export let startMedicalTranscriptionJob = SlateTool.create(spec, { .string() .optional() .describe('KMS key ID for encrypting the output'), - mediaFormat: z - .enum(['mp3', 'mp4', 'wav', 'flac', 'ogg', 'amr', 'webm']) - .optional() - .describe('Format of the media file'), + kmsEncryptionContext: kmsEncryptionContextSchema, + mediaFormat: mediaFormatSchema.optional().describe('Format of the media file'), mediaSampleRateHertz: z.number().optional().describe('Sample rate of the audio in Hz'), settings: z .object({ @@ -73,15 +72,7 @@ export let startMedicalTranscriptionJob = SlateTool.create(spec, { .enum(['PHI']) .optional() .describe('Identify protected health information (PHI)'), - tags: z - .array( - z.object({ - key: z.string().describe('Tag key'), - value: z.string().describe('Tag value') - }) - ) - .optional() - .describe('Tags to associate with the job') + tags: z.array(tagSchema).optional().describe('Tags to associate with the job') }) ) .output( diff --git a/integrations/aws-transcribe/src/tools/start-transcription-job.ts b/integrations/aws-transcribe/src/tools/start-transcription-job.ts index c5759939b2..7559461136 100644 --- a/integrations/aws-transcribe/src/tools/start-transcription-job.ts +++ b/integrations/aws-transcribe/src/tools/start-transcription-job.ts @@ -1,12 +1,17 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { TranscribeClient } from '../lib/client'; +import { transcribeServiceError } from '../lib/errors'; import { spec } from '../spec'; - -let tagSchema = z.object({ - key: z.string().describe('Tag key'), - value: z.string().describe('Tag value') -}); +import { + ensureExactlyOne, + kmsEncryptionContextSchema, + languageIdSettingsSchema, + mediaFormatSchema, + tagSchema, + validateLanguageIdSettings, + validateVocabularyFilterMethod +} from './common'; export let startTranscriptionJob = SlateTool.create(spec, { name: 'Start Transcription Job', @@ -49,14 +54,14 @@ export let startTranscriptionJob = SlateTool.create(spec, { .boolean() .optional() .describe('Enable automatic multi-language identification'), + languageIdSettings: languageIdSettingsSchema, languageOptions: z .array(z.string()) .optional() .describe( 'List of expected language codes to improve language identification accuracy' ), - mediaFormat: z - .enum(['mp3', 'mp4', 'wav', 'flac', 'ogg', 'amr', 'webm']) + mediaFormat: mediaFormatSchema .optional() .describe('Format of the media file. Usually auto-detected from file extension.'), mediaSampleRateHertz: z @@ -75,6 +80,7 @@ export let startTranscriptionJob = SlateTool.create(spec, { .string() .optional() .describe('KMS key ID for encrypting the output'), + kmsEncryptionContext: kmsEncryptionContextSchema, settings: z .object({ vocabularyName: z.string().optional().describe('Name of a custom vocabulary to use'), @@ -146,9 +152,9 @@ export let startTranscriptionJob = SlateTool.create(spec, { .optional() .describe('Subtitle generation settings'), toxicityDetection: z - .array(z.string()) + .array(z.enum(['ALL'])) .optional() - .describe('Toxicity categories to detect (e.g., ALL)'), + .describe('Toxicity categories to detect. AWS currently accepts ALL.'), jobExecutionSettings: z .object({ allowDeferredExecution: z @@ -173,6 +179,33 @@ export let startTranscriptionJob = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + ensureExactlyOne( + [ + ['languageCode', typeof ctx.input.languageCode === 'string'], + ['identifyLanguage', ctx.input.identifyLanguage === true], + ['identifyMultipleLanguages', ctx.input.identifyMultipleLanguages === true] + ], + 'Provide exactly one language selection: languageCode, identifyLanguage, or identifyMultipleLanguages.' + ); + + if ( + ctx.input.languageIdSettings && + !ctx.input.identifyLanguage && + !ctx.input.identifyMultipleLanguages + ) { + throw transcribeServiceError( + 'languageIdSettings can only be used with identifyLanguage or identifyMultipleLanguages.' + ); + } + + validateLanguageIdSettings(ctx.input.languageIdSettings, ctx.input.languageOptions, { + allowLanguageModel: ctx.input.identifyMultipleLanguages !== true + }); + validateVocabularyFilterMethod( + ctx.input.settings?.vocabularyFilterName, + ctx.input.settings?.vocabularyFilterMethod + ); + let client = new TranscribeClient({ credentials: { accessKeyId: ctx.auth.accessKeyId, diff --git a/integrations/aws-transcribe/vitest.config.ts b/integrations/aws-transcribe/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/aws-transcribe/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/azure-speech/README.md b/integrations/azure-speech/README.md index daed6920ba..ffcebd478c 100644 --- a/integrations/azure-speech/README.md +++ b/integrations/azure-speech/README.md @@ -1,6 +1,6 @@ # Azure Speech -Transcribe audio to text using real-time, fast, or batch transcription modes with speaker diarization and language identification. Convert text to synthesized speech using neural, custom, or personal voices with SSML control over pronunciation and prosody. Generate photorealistic avatar videos from text. Translate speech across multiple languages. Verify and identify speakers by voice characteristics. Assess pronunciation accuracy, fluency, completeness, and prosody for language learning. Supports custom speech models trained on domain-specific data and LLM-enhanced transcription for captions, meeting summaries, and call center assistance. +Transcribe short audio, fast single files, or batch audio URLs with Azure AI Speech. Convert text or SSML to neural speech audio attachments, list available voices and base speech models, assess pronunciation on short audio, and manage text-independent speaker profiles for enrollment, verification, and identification. ## Tools @@ -12,6 +12,14 @@ Submits a batch transcription job to process one or more audio files asynchronou Deletes a batch transcription job and its associated result data. Use this to clean up completed transcriptions after retrieving their results, or to cancel transcriptions that are no longer needed. +### Enroll Speaker Profile + +Adds a voice enrollment sample to a text-independent speaker verification or identification profile. Use this after creating a profile and before verifying or identifying speakers. + +### Fast Transcribe Audio + +Synchronously transcribes one audio file with Azure Speech fast transcription. Use this for quick file transcription with predictable latency when the audio is too large for short-audio recognition or when phrase/channel/diarization detail is needed. + ### Get Batch Transcription Retrieves the status and details of a batch transcription job. When the transcription is complete, also fetches the result files including transcription output and report. Use this to check progress of a previously submitted batch transcription and to retrieve the final results. @@ -42,7 +50,7 @@ Performs real-time speech-to-text recognition on short audio (up to 60 seconds). ### Synthesize Speech -Converts text into natural-sounding synthesized speech audio using Azure neural voices. Provide either plain text (which will be wrapped in SSML automatically) or custom SSML for fine-grained control over pronunciation, prosody, speaking styles, pauses, and other speech characteristics. Returns the synthesized audio as a base64-encoded string. +Converts text into natural-sounding synthesized speech audio using Azure neural voices. Provide either plain text (which will be wrapped in SSML automatically) or custom SSML for fine-grained control over pronunciation, prosody, speaking styles, pauses, and other speech characteristics. Returns the synthesized audio as a Slate attachment. ### Verify Speaker diff --git a/integrations/azure-speech/docs/SPEC.md b/integrations/azure-speech/docs/SPEC.md index 191280b476..83d92eb422 100644 --- a/integrations/azure-speech/docs/SPEC.md +++ b/integrations/azure-speech/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Azure Speech (now part of Azure AI Foundry Tools) is a cloud-based speech processing service from Microsoft. It provides speech-to-text, text-to-speech, speech translation, and other capabilities. It also offers APIs for speaker recognition. +Azure Speech (now part of Azure AI Foundry Tools) is a cloud-based speech processing service from Microsoft. This integration covers short-audio recognition, fast transcription, batch transcription, text-to-speech, pronunciation assessment, and text-independent speaker recognition. It intentionally avoids SDK-only, deprecated, or niche surfaces such as REST speech translation and avatar video generation. ## Authentication @@ -34,11 +34,12 @@ The OAuth scope for obtaining the Entra token is `https://cognitiveservices.azur ### Speech-to-Text -Supports both real-time and batch transcription, providing versatile solutions for converting audio streams into text. +Supports short-audio recognition, fast transcription, and batch transcription for converting audio into text. - **Real-time transcription**: Instant transcription with intermediate results for live audio inputs. -- **Fast transcription**: Fastest synchronous output for situations with predictable latency. Suitable for quick file transcription, captions, and editing workflows. +- **Fast transcription**: Synchronous output for single audio files with predictable latency. Suitable for quick file transcription, captions, and editing workflows. - **Batch transcription**: Efficient processing for large volumes of prerecorded audio. Transcribes audio files as a batch from multiple URLs or an Azure container. +- **REST API version**: Fast and batch transcription use the generally available Speech-to-text REST API version `2025-10-15`. - **Diarization**: Distinguishes and separates different speakers in an audio recording, useful for transcribing conversations, meetings, or any multi-speaker content. The service can identify up to 35 different speakers. - **Custom Speech**: Allows evaluating and improving the accuracy of speech recognition for specific applications and products. You can upload training data with custom vocabulary or domain-specific terms. - **Language Identification**: Identifies languages spoken in audio by comparing against a list of supported languages. Can be used standalone, with speech-to-text recognition, or with speech translation. @@ -53,19 +54,12 @@ Converts text into synthesized speech and provides a list of supported voices fo - **Personal Voice**: Create a voice that sounds like a specific person using a short audio sample, requiring user consent. The resulting speaker profile ID can be used for synthesis. - **Multiple output formats**: Supports 48-kHz, 24-kHz, 16-kHz, and 8-kHz audio outputs in various codecs and container formats. -### Text-to-Speech Avatar - -Converts text into a digital video of a photorealistic human speaking with a natural-sounding voice. The video can be synthesized asynchronously or in real time. - -- Choose from a range of standard avatars or create custom ones. -- Language support is the same as for text-to-speech. - ### Speech Translation Enables real-time, multilingual translation of speech to your applications, tools, and devices. Supports speech-to-speech and speech-to-text translation. - Configure source and target languages. -- Available via the Speech SDK (not supported via REST API for short audio). +- Available via the Speech SDK, not this REST-focused integration. ### Speaker Recognition @@ -90,4 +84,4 @@ Delivers improved quality, deep contextual understanding, multilingual support, ## Events -The provider does not support events. Azure Speech does not offer webhooks or purpose-built polling mechanisms for event subscriptions. Batch transcription jobs can be monitored by checking their status, but there is no native webhook or event notification system. +Azure Speech batch transcription jobs can be monitored by polling their status. The Speech-to-text REST API also has webhook operations for batch/custom-speech resources, but this integration currently exposes polling rather than webhook lifecycle management. diff --git a/integrations/azure-speech/package.json b/integrations/azure-speech/package.json index 0b57b41866..6eff2a4a72 100644 --- a/integrations/azure-speech/package.json +++ b/integrations/azure-speech/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/azure-speech/slate.json b/integrations/azure-speech/slate.json index ce74160508..377f2c4d22 100644 --- a/integrations/azure-speech/slate.json +++ b/integrations/azure-speech/slate.json @@ -1,21 +1,16 @@ { "name": "@microsoft/azure-speech", - "description": "Transcribe audio to text using real-time, fast, or batch transcription modes with speaker diarization and language identification. Convert text to synthesized speech using neural, custom, or personal voices with SSML control over pronunciation and prosody. Generate photorealistic avatar videos from text. Translate speech across multiple languages. Verify and identify speakers by voice characteristics. Assess pronunciation accuracy, fluency, completeness, and prosody for language learning. Supports custom speech models trained on domain-specific data and LLM-enhanced transcription for captions, meeting summaries, and call center assistance.", - "categories": [ - "apis-and-http-requests", - "language-translation", - "speech-recognition-and-synthesis" - ], + "description": "Transcribe short audio, fast single files, or batch audio URLs with Azure AI Speech. Convert text or SSML to neural speech audio attachments, list available voices and base speech models, assess pronunciation on short audio, and manage text-independent speaker profiles for enrollment, verification, and identification.", + "categories": ["apis-and-http-requests", "speech-recognition-and-synthesis"], "skills": [ "transcribe audio to text", "synthesize speech from text", + "fast transcribe audio files", "batch transcribe audio files", "identify and diarize speakers", - "translate speech across languages", "verify speaker identity", "assess pronunciation quality", - "generate avatar video", - "create custom voice models", + "enroll speaker profiles", "identify spoken language" ], "logoUrl": "https://provider-logos.metorial-cdn.com/azure-speech.png" diff --git a/integrations/azure-speech/src/index.ts b/integrations/azure-speech/src/index.ts index 5b4636f938..0419ef0de3 100644 --- a/integrations/azure-speech/src/index.ts +++ b/integrations/azure-speech/src/index.ts @@ -3,6 +3,8 @@ import { spec } from './spec'; import { createBatchTranscription, deleteBatchTranscription, + enrollSpeakerProfile, + fastTranscribeAudio, getBatchTranscription, identifySpeaker, listBatchTranscriptions, @@ -19,6 +21,7 @@ export let provider = Slate.create({ spec, tools: [ synthesizeSpeech, + fastTranscribeAudio, listVoices, recognizeSpeech, createBatchTranscription, @@ -27,6 +30,7 @@ export let provider = Slate.create({ deleteBatchTranscription, listSpeechModels, manageSpeakerProfile, + enrollSpeakerProfile, verifySpeaker, identifySpeaker ], diff --git a/integrations/azure-speech/src/lib/audio.ts b/integrations/azure-speech/src/lib/audio.ts new file mode 100644 index 0000000000..d4f0687deb --- /dev/null +++ b/integrations/azure-speech/src/lib/audio.ts @@ -0,0 +1,30 @@ +import { azureSpeechServiceError } from './errors'; + +export let decodeBase64Content = (contentBase64: string, fieldName: string) => { + let source = contentBase64.trim(); + let dataUrlMatch = /^data:([^;,]+)?;base64,(.*)$/i.exec(source); + let normalized = (dataUrlMatch?.[2] ?? source).replace(/\s/g, ''); + + if (!normalized) { + throw azureSpeechServiceError(`${fieldName} must contain base64-encoded content.`); + } + + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 === 1) { + throw azureSpeechServiceError(`${fieldName} must be valid base64-encoded content.`); + } + + let bytes = Buffer.from(normalized, 'base64'); + if (bytes.length === 0) { + throw azureSpeechServiceError(`${fieldName} must contain at least one byte.`); + } + + return bytes; +}; + +export let encodeBase64Content = (content: ArrayBuffer | Buffer | Uint8Array) => { + if (content instanceof ArrayBuffer) { + return Buffer.from(new Uint8Array(content)).toString('base64'); + } + + return Buffer.from(content).toString('base64'); +}; diff --git a/integrations/azure-speech/src/lib/client.ts b/integrations/azure-speech/src/lib/client.ts index 0f569b5648..6fd443be84 100644 --- a/integrations/azure-speech/src/lib/client.ts +++ b/integrations/azure-speech/src/lib/client.ts @@ -1,4 +1,9 @@ import { createAxios } from 'slates'; +import { decodeBase64Content, encodeBase64Content } from './audio'; +import { azureSpeechApiError } from './errors'; + +const SPEECH_TO_TEXT_API_VERSION = '2025-10-15'; +const SPEAKER_RECOGNITION_API_VERSION = '2021-09-05'; export interface ClientConfig { token: string; @@ -68,19 +73,25 @@ export class SpeechToTextClient { ? 'True' : 'False' }); - reqHeaders['Pronunciation-Assessment'] = btoa(pronJson); + reqHeaders['Pronunciation-Assessment'] = Buffer.from(pronJson, 'utf8').toString( + 'base64' + ); } - let response = await axios.post( - '/speech/recognition/conversation/cognitiveservices/v1', - params.audioData, - { - params: queryParams, - headers: reqHeaders - } - ); + try { + let response = await axios.post( + '/speech/recognition/conversation/cognitiveservices/v1', + decodeBase64Content(params.audioData, 'audioBase64'), + { + params: queryParams, + headers: reqHeaders + } + ); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'recognize speech'); + } } async createBatchTranscription(params: { @@ -88,12 +99,14 @@ export class SpeechToTextClient { locale: string; contentUrls?: string[]; contentContainerUrl?: string; + destinationContainerUrl?: string; timeToLiveHours?: number; wordLevelTimestampsEnabled?: boolean; - diarizationEnabled?: boolean; + displayFormWordLevelTimestampsEnabled?: boolean; + channels?: number[]; diarization?: { - minCount?: number; - maxCount?: number; + enabled?: boolean; + maxSpeakers?: number; }; punctuationMode?: string; profanityFilterMode?: string; @@ -114,15 +127,19 @@ export class SpeechToTextClient { if (params.wordLevelTimestampsEnabled !== undefined) { properties.wordLevelTimestampsEnabled = params.wordLevelTimestampsEnabled; } - if (params.diarizationEnabled !== undefined) { - properties.diarizationEnabled = params.diarizationEnabled; + if (params.displayFormWordLevelTimestampsEnabled !== undefined) { + properties.displayFormWordLevelTimestampsEnabled = + params.displayFormWordLevelTimestampsEnabled; + } + if (params.channels) { + properties.channels = params.channels; } if (params.diarization) { properties.diarization = { - speakers: { - minCount: params.diarization.minCount ?? 2, - maxCount: params.diarization.maxCount ?? 10 - } + enabled: params.diarization.enabled ?? true, + ...(params.diarization.maxSpeakers !== undefined + ? { maxSpeakers: params.diarization.maxSpeakers } + : {}) }; } if (params.punctuationMode) { @@ -143,19 +160,26 @@ export class SpeechToTextClient { if (params.contentUrls) body.contentUrls = params.contentUrls; if (params.contentContainerUrl) body.contentContainerUrl = params.contentContainerUrl; + if (params.destinationContainerUrl) { + properties.destinationContainerUrl = params.destinationContainerUrl; + } if (params.model) { body.model = { self: params.model }; } - let response = await axios.post('/speechtotext/transcriptions:submit', body, { - params: { 'api-version': '2024-11-15' }, - headers: { - ...this.headers, - 'Content-Type': 'application/json' - } - }); + try { + let response = await axios.post('/speechtotext/transcriptions:submit', body, { + params: { 'api-version': SPEECH_TO_TEXT_API_VERSION }, + headers: { + ...this.headers, + 'Content-Type': 'application/json' + } + }); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'create batch transcription'); + } } async getTranscription(transcriptionId: string) { @@ -163,12 +187,16 @@ export class SpeechToTextClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.get(`/speechtotext/transcriptions/${transcriptionId}`, { - params: { 'api-version': '2024-11-15' }, - headers: this.headers - }); + try { + let response = await axios.get(`/speechtotext/transcriptions/${transcriptionId}`, { + params: { 'api-version': SPEECH_TO_TEXT_API_VERSION }, + headers: this.headers + }); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'get batch transcription'); + } } async listTranscriptions(params?: { skip?: number; top?: number }) { @@ -176,16 +204,20 @@ export class SpeechToTextClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.get('/speechtotext/transcriptions', { - params: { - 'api-version': '2024-11-15', - ...(params?.skip !== undefined ? { skip: params.skip } : {}), - ...(params?.top !== undefined ? { top: params.top } : {}) - }, - headers: this.headers - }); + try { + let response = await axios.get('/speechtotext/transcriptions', { + params: { + 'api-version': SPEECH_TO_TEXT_API_VERSION, + ...(params?.skip !== undefined ? { skip: params.skip } : {}), + ...(params?.top !== undefined ? { top: params.top } : {}) + }, + headers: this.headers + }); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'list batch transcriptions'); + } } async getTranscriptionFiles(transcriptionId: string) { @@ -193,12 +225,16 @@ export class SpeechToTextClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.get(`/speechtotext/transcriptions/${transcriptionId}/files`, { - params: { 'api-version': '2024-11-15' }, - headers: this.headers - }); + try { + let response = await axios.get(`/speechtotext/transcriptions/${transcriptionId}/files`, { + params: { 'api-version': SPEECH_TO_TEXT_API_VERSION }, + headers: this.headers + }); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'list batch transcription files'); + } } async deleteTranscription(transcriptionId: string) { @@ -206,10 +242,14 @@ export class SpeechToTextClient { baseURL: this.cognitiveBaseUrl }); - await axios.delete(`/speechtotext/transcriptions/${transcriptionId}`, { - params: { 'api-version': '2024-11-15' }, - headers: this.headers - }); + try { + await axios.delete(`/speechtotext/transcriptions/${transcriptionId}`, { + params: { 'api-version': SPEECH_TO_TEXT_API_VERSION }, + headers: this.headers + }); + } catch (error) { + throw azureSpeechApiError(error, 'delete batch transcription'); + } } async listBaseModels(params?: { skip?: number; top?: number }) { @@ -217,16 +257,78 @@ export class SpeechToTextClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.get('/speechtotext/models/base', { - params: { - 'api-version': '2024-11-15', - ...(params?.skip !== undefined ? { skip: params.skip } : {}), - ...(params?.top !== undefined ? { top: params.top } : {}) - }, - headers: this.headers + try { + let response = await axios.get('/speechtotext/models/base', { + params: { + 'api-version': SPEECH_TO_TEXT_API_VERSION, + ...(params?.skip !== undefined ? { skip: params.skip } : {}), + ...(params?.top !== undefined ? { top: params.top } : {}) + }, + headers: this.headers + }); + + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'list speech models'); + } + } + + async fastTranscribeAudio(params: { + audioBase64: string; + fileName?: string; + contentType?: string; + locales?: string[]; + channels?: number[]; + diarization?: { + enabled?: boolean; + maxSpeakers?: number; + }; + profanityFilterMode?: 'None' | 'Masked' | 'Removed' | 'Tags'; + }) { + let axios = createAxios({ + baseURL: this.cognitiveBaseUrl }); - return response.data; + let definition: Record = {}; + if (params.locales !== undefined) { + definition.locales = params.locales; + } + if (params.channels) { + definition.channels = params.channels; + } + if (params.diarization) { + definition.diarization = { + enabled: params.diarization.enabled ?? true, + ...(params.diarization.maxSpeakers !== undefined + ? { maxSpeakers: params.diarization.maxSpeakers } + : {}) + }; + } + if (params.profanityFilterMode) { + definition.profanityFilterMode = params.profanityFilterMode; + } + + let audioBytes = decodeBase64Content(params.audioBase64, 'audioBase64'); + let formData = new FormData(); + formData.append( + 'audio', + new Blob([new Uint8Array(audioBytes)], { + type: params.contentType ?? 'audio/wav' + }), + params.fileName ?? 'audio.wav' + ); + formData.append('definition', JSON.stringify(definition)); + + try { + let response = await axios.post('/speechtotext/transcriptions:transcribe', formData, { + params: { 'api-version': SPEECH_TO_TEXT_API_VERSION }, + headers: this.headers + }); + + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'fast transcribe audio'); + } } } @@ -252,11 +354,15 @@ export class TextToSpeechClient { baseURL: this.ttsBaseUrl }); - let response = await axios.get('/cognitiveservices/voices/list', { - headers: this.headers - }); + try { + let response = await axios.get('/cognitiveservices/voices/list', { + headers: this.headers + }); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'list voices'); + } } async synthesizeSpeech(params: { ssml: string; outputFormat: string }) { @@ -264,27 +370,27 @@ export class TextToSpeechClient { baseURL: this.ttsBaseUrl }); - let response = await axios.post('/cognitiveservices/v1', params.ssml, { - headers: { - ...this.headers, - 'Content-Type': 'application/ssml+xml', - 'X-Microsoft-OutputFormat': params.outputFormat, - 'User-Agent': 'SlatesAzureSpeech/1.0' - }, - responseType: 'arraybuffer' - }); + try { + let response = await axios.post('/cognitiveservices/v1', params.ssml, { + headers: { + ...this.headers, + 'Content-Type': 'application/ssml+xml', + 'X-Microsoft-OutputFormat': params.outputFormat, + 'User-Agent': 'SlatesAzureSpeech/1.0' + }, + responseType: 'arraybuffer' + }); - let audioBytes = new Uint8Array(response.data); - let binaryString = ''; - for (let i = 0; i < audioBytes.length; i++) { - binaryString += String.fromCharCode(audioBytes[i]!); - } - let audioBase64 = btoa(binaryString); + let audioBytes = Buffer.from(response.data); - return { - audioBase64, - contentType: String(response.headers?.['content-type'] ?? 'audio/wav') - }; + return { + contentBase64: encodeBase64Content(audioBytes), + contentType: String(response.headers?.['content-type'] ?? 'audio/wav'), + byteLength: audioBytes.byteLength + }; + } catch (error) { + throw azureSpeechApiError(error, 'synthesize speech'); + } } } @@ -310,19 +416,23 @@ export class SpeakerRecognitionClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.post( - '/speaker-recognition/verification/text-independent/profiles', - { locale }, - { - params: { 'api-version': '2021-09-05' }, - headers: { - ...this.headers, - 'Content-Type': 'application/json' + try { + let response = await axios.post( + '/speaker-recognition/verification/text-independent/profiles', + { locale }, + { + params: { 'api-version': SPEAKER_RECOGNITION_API_VERSION }, + headers: { + ...this.headers, + 'Content-Type': 'application/json' + } } - } - ); + ); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'create verification speaker profile'); + } } async createIdentificationProfile(locale: string) { @@ -330,19 +440,23 @@ export class SpeakerRecognitionClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.post( - '/speaker-recognition/identification/text-independent/profiles', - { locale }, - { - params: { 'api-version': '2021-09-05' }, - headers: { - ...this.headers, - 'Content-Type': 'application/json' + try { + let response = await axios.post( + '/speaker-recognition/identification/text-independent/profiles', + { locale }, + { + params: { 'api-version': SPEAKER_RECOGNITION_API_VERSION }, + headers: { + ...this.headers, + 'Content-Type': 'application/json' + } } - } - ); + ); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'create identification speaker profile'); + } } async deleteProfile(profileType: 'verification' | 'identification', profileId: string) { @@ -350,13 +464,17 @@ export class SpeakerRecognitionClient { baseURL: this.cognitiveBaseUrl }); - await axios.delete( - `/speaker-recognition/${profileType}/text-independent/profiles/${profileId}`, - { - params: { 'api-version': '2021-09-05' }, - headers: this.headers - } - ); + try { + await axios.delete( + `/speaker-recognition/${profileType}/text-independent/profiles/${profileId}`, + { + params: { 'api-version': SPEAKER_RECOGNITION_API_VERSION }, + headers: this.headers + } + ); + } catch (error) { + throw azureSpeechApiError(error, 'delete speaker profile'); + } } async getProfile(profileType: 'verification' | 'identification', profileId: string) { @@ -364,15 +482,19 @@ export class SpeakerRecognitionClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.get( - `/speaker-recognition/${profileType}/text-independent/profiles/${profileId}`, - { - params: { 'api-version': '2021-09-05' }, - headers: this.headers - } - ); + try { + let response = await axios.get( + `/speaker-recognition/${profileType}/text-independent/profiles/${profileId}`, + { + params: { 'api-version': SPEAKER_RECOGNITION_API_VERSION }, + headers: this.headers + } + ); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'get speaker profile'); + } } async listProfiles(profileType: 'verification' | 'identification') { @@ -380,15 +502,19 @@ export class SpeakerRecognitionClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.get( - `/speaker-recognition/${profileType}/text-independent/profiles`, - { - params: { 'api-version': '2021-09-05' }, - headers: this.headers - } - ); + try { + let response = await axios.get( + `/speaker-recognition/${profileType}/text-independent/profiles`, + { + params: { 'api-version': SPEAKER_RECOGNITION_API_VERSION }, + headers: this.headers + } + ); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'list speaker profiles'); + } } async enrollProfile( @@ -400,19 +526,23 @@ export class SpeakerRecognitionClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.post( - `/speaker-recognition/${profileType}/text-independent/profiles/${profileId}/enrollments`, - audioData, - { - params: { 'api-version': '2021-09-05' }, - headers: { - ...this.headers, - 'Content-Type': 'audio/wav' + try { + let response = await axios.post( + `/speaker-recognition/${profileType}/text-independent/profiles/${profileId}/enrollments`, + decodeBase64Content(audioData, 'audioBase64'), + { + params: { 'api-version': SPEAKER_RECOGNITION_API_VERSION }, + headers: { + ...this.headers, + 'Content-Type': 'audio/wav' + } } - } - ); + ); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'enroll speaker profile'); + } } async verifySpeaker(profileId: string, audioData: string) { @@ -420,19 +550,23 @@ export class SpeakerRecognitionClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.post( - `/speaker-recognition/verification/text-independent/profiles/${profileId}:verify`, - audioData, - { - params: { 'api-version': '2021-09-05' }, - headers: { - ...this.headers, - 'Content-Type': 'audio/wav' + try { + let response = await axios.post( + `/speaker-recognition/verification/text-independent/profiles/${profileId}:verify`, + decodeBase64Content(audioData, 'audioBase64'), + { + params: { 'api-version': SPEAKER_RECOGNITION_API_VERSION }, + headers: { + ...this.headers, + 'Content-Type': 'audio/wav' + } } - } - ); + ); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'verify speaker'); + } } async identifySpeaker(profileIds: string[], audioData: string) { @@ -440,21 +574,25 @@ export class SpeakerRecognitionClient { baseURL: this.cognitiveBaseUrl }); - let response = await axios.post( - '/speaker-recognition/identification/text-independent/profiles:identifySingleSpeaker', - audioData, - { - params: { - 'api-version': '2021-09-05', - profileIds: profileIds.join(',') - }, - headers: { - ...this.headers, - 'Content-Type': 'audio/wav' + try { + let response = await axios.post( + '/speaker-recognition/identification/text-independent/profiles:identifySingleSpeaker', + decodeBase64Content(audioData, 'audioBase64'), + { + params: { + 'api-version': SPEAKER_RECOGNITION_API_VERSION, + profileIds: profileIds.join(',') + }, + headers: { + ...this.headers, + 'Content-Type': 'audio/wav' + } } - } - ); + ); - return response.data; + return response.data; + } catch (error) { + throw azureSpeechApiError(error, 'identify speaker'); + } } } diff --git a/integrations/azure-speech/src/lib/errors.ts b/integrations/azure-speech/src/lib/errors.ts new file mode 100644 index 0000000000..b87e920d26 --- /dev/null +++ b/integrations/azure-speech/src/lib/errors.ts @@ -0,0 +1,91 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractAzureSpeechMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + let upstreamError = isRecord(data.error) ? data.error : undefined; + let innerError = isRecord(data.innerError) ? data.innerError : undefined; + + for (let key of ['message', 'code', 'target']) { + addDetail(details, upstreamError?.[key]); + addDetail(details, innerError?.[key]); + addDetail(details, data[key]); + } + + addDetail(details, data.error_description); + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getAzureSpeechErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let azureSpeechServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let azureSpeechApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getAzureSpeechErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = azureSpeechServiceError( + `Azure Speech API ${operation} failed: ${statusLabel}${extractAzureSpeechMessage(error)}` + ); + serviceError.data.reason = 'azure_speech_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/azure-speech/src/tools/create-batch-transcription.ts b/integrations/azure-speech/src/tools/create-batch-transcription.ts index 79cdcd3c0f..21c160ed3c 100644 --- a/integrations/azure-speech/src/tools/create-batch-transcription.ts +++ b/integrations/azure-speech/src/tools/create-batch-transcription.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SpeechToTextClient } from '../lib/client'; +import { azureSpeechServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createBatchTranscription = SlateTool.create(spec, { @@ -39,6 +40,12 @@ The job runs asynchronously — use the **Get Batch Transcription** tool to chec .describe( 'Azure Blob Storage container URL containing audio files. Mutually exclusive with contentUrls.' ), + destinationContainerUrl: z + .string() + .optional() + .describe( + 'Optional Azure Blob Storage container URL where transcription result files should be written.' + ), timeToLiveHours: z .number() .optional() @@ -47,18 +54,22 @@ The job runs asynchronously — use the **Get Batch Transcription** tool to chec .boolean() .optional() .describe('Include word-level timestamps in results'), - diarizationEnabled: z + displayFormWordLevelTimestampsEnabled: z .boolean() .optional() - .describe('Enable speaker diarization for 2 speakers'), - diarizationMinSpeakers: z - .number() + .describe('Include word-level timestamps for the display-form transcript'), + channels: z + .array(z.number()) .optional() - .describe('Minimum number of speakers for diarization (for 3+ speakers)'), + .describe('Zero-based audio channels to transcribe separately, such as [0, 1]'), + diarizationEnabled: z + .boolean() + .optional() + .describe('Enable speaker diarization for single-channel audio'), diarizationMaxSpeakers: z .number() .optional() - .describe('Maximum number of speakers for diarization (max 36)'), + .describe('Maximum expected speakers for diarization (2-35)'), punctuationMode: z .enum(['None', 'Dictated', 'Automatic', 'DictatedAndAutomatic']) .optional() @@ -76,7 +87,11 @@ The job runs asynchronously — use the **Get Batch Transcription** tool to chec languageIdentificationLocales: z .array(z.string()) .optional() - .describe('Candidate locales for language identification (2-10 locales)') + .describe('Candidate locales for language identification (2-10 locales)'), + languageIdentificationMode: z + .enum(['Continuous', 'Single']) + .optional() + .describe('Language identification mode. Defaults to Continuous.') }) ) .output( @@ -94,8 +109,45 @@ The job runs asynchronously — use the **Get Batch Transcription** tool to chec .handleInvocation(async ctx => { let input = ctx.input; - if (!input.contentUrls && !input.contentContainerUrl) { - throw new Error('Either contentUrls or contentContainerUrl must be provided.'); + if (!input.contentUrls?.length && !input.contentContainerUrl) { + throw azureSpeechServiceError( + 'Either contentUrls or contentContainerUrl must be provided.' + ); + } + + if (input.contentUrls?.length && input.contentContainerUrl) { + throw azureSpeechServiceError( + 'contentUrls and contentContainerUrl are mutually exclusive.' + ); + } + + if (input.contentUrls && input.contentUrls.length > 1000) { + throw azureSpeechServiceError('contentUrls can include at most 1000 audio URLs.'); + } + + if ( + input.timeToLiveHours !== undefined && + (input.timeToLiveHours < 6 || input.timeToLiveHours > 744) + ) { + throw azureSpeechServiceError('timeToLiveHours must be between 6 and 744.'); + } + + if ( + input.diarizationMaxSpeakers !== undefined && + (input.diarizationMaxSpeakers < 2 || input.diarizationMaxSpeakers > 35) + ) { + throw azureSpeechServiceError('diarizationMaxSpeakers must be between 2 and 35.'); + } + + if ( + input.languageIdentificationLocales && + (input.languageIdentificationLocales.length < 2 || + (input.languageIdentificationMode !== 'Single' && + input.languageIdentificationLocales.length > 10)) + ) { + throw azureSpeechServiceError( + 'languageIdentificationLocales must include 2-10 locales unless languageIdentificationMode is "Single".' + ); } let client = new SpeechToTextClient({ @@ -104,17 +156,17 @@ The job runs asynchronously — use the **Get Batch Transcription** tool to chec }); let diarization = - input.diarizationMinSpeakers || input.diarizationMaxSpeakers + input.diarizationEnabled !== undefined || input.diarizationMaxSpeakers !== undefined ? { - minCount: input.diarizationMinSpeakers, - maxCount: input.diarizationMaxSpeakers + enabled: input.diarizationEnabled ?? true, + maxSpeakers: input.diarizationMaxSpeakers } : undefined; let languageIdentification = input.languageIdentificationLocales ? { candidateLocales: input.languageIdentificationLocales, - mode: 'Continuous' as const + mode: input.languageIdentificationMode ?? 'Continuous' } : undefined; @@ -125,9 +177,11 @@ The job runs asynchronously — use the **Get Batch Transcription** tool to chec locale: input.locale, contentUrls: input.contentUrls, contentContainerUrl: input.contentContainerUrl, + destinationContainerUrl: input.destinationContainerUrl, timeToLiveHours: input.timeToLiveHours, wordLevelTimestampsEnabled: input.wordLevelTimestampsEnabled, - diarizationEnabled: input.diarizationEnabled, + displayFormWordLevelTimestampsEnabled: input.displayFormWordLevelTimestampsEnabled, + channels: input.channels, diarization, punctuationMode: input.punctuationMode, profanityFilterMode: input.profanityFilterMode, diff --git a/integrations/azure-speech/src/tools/enroll-speaker-profile.ts b/integrations/azure-speech/src/tools/enroll-speaker-profile.ts new file mode 100644 index 0000000000..98af718265 --- /dev/null +++ b/integrations/azure-speech/src/tools/enroll-speaker-profile.ts @@ -0,0 +1,88 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SpeakerRecognitionClient } from '../lib/client'; +import { spec } from '../spec'; + +let speakerProfileSchema = z.object({ + profileId: z.string().describe('Unique speaker profile identifier'), + locale: z.string().optional().describe('Locale of the profile'), + enrollmentStatus: z + .string() + .optional() + .describe('Enrollment status after adding the audio sample'), + enrollmentsCount: z.number().optional().describe('Number of enrollments'), + enrollmentsSpeechLength: z + .number() + .optional() + .describe('Total speech length of enrollments in seconds'), + remainingEnrollmentsSpeechLength: z + .number() + .optional() + .describe('Remaining speech length needed for enrollment'), + createdAt: z.string().optional().describe('ISO 8601 creation timestamp') +}); + +export let enrollSpeakerProfile = SlateTool.create(spec, { + name: 'Enroll Speaker Profile', + key: 'enroll_speaker_profile', + description: `Adds a voice enrollment sample to a text-independent speaker verification or identification profile. Use this after creating a profile and before verifying or identifying speakers.`, + instructions: [ + 'Create the profile first with Manage Speaker Profile.', + 'Repeat enrollment with additional clear speech samples until enrollmentStatus reports Enrolled.', + 'Speaker Recognition is a Limited Access service; the Azure Speech resource must have access enabled.' + ], + constraints: [ + 'Audio should be clear WAV speech for the target speaker.', + 'Synthetic, noisy, or very short audio may not satisfy Azure enrollment requirements.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + profileType: z + .enum(['verification', 'identification']) + .describe('Type of text-independent speaker profile'), + profileId: z.string().describe('Profile ID returned by Manage Speaker Profile'), + audioBase64: z + .string() + .describe('Base64-encoded WAV audio containing clear speech from the speaker') + }) + ) + .output( + z.object({ + profile: speakerProfileSchema.describe('Updated speaker profile after enrollment') + }) + ) + .handleInvocation(async ctx => { + let client = new SpeakerRecognitionClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + ctx.info(`Enrolling ${ctx.input.profileType} profile ${ctx.input.profileId}...`); + + let result = await client.enrollProfile( + ctx.input.profileType, + ctx.input.profileId, + ctx.input.audioBase64 + ); + + return { + output: { + profile: { + profileId: result.profileId ?? ctx.input.profileId, + locale: result.locale, + enrollmentStatus: result.enrollmentStatus, + enrollmentsCount: result.enrollmentsCount, + enrollmentsSpeechLength: result.enrollmentsSpeechLength, + remainingEnrollmentsSpeechLength: result.remainingEnrollmentsSpeechLength, + createdAt: result.createdDateTime + } + }, + message: `Enrollment added to ${ctx.input.profileType} profile \`${ctx.input.profileId}\`. Status: **${result.enrollmentStatus ?? 'Unknown'}**.` + }; + }) + .build(); diff --git a/integrations/azure-speech/src/tools/fast-transcribe-audio.ts b/integrations/azure-speech/src/tools/fast-transcribe-audio.ts new file mode 100644 index 0000000000..0fc9c9dca2 --- /dev/null +++ b/integrations/azure-speech/src/tools/fast-transcribe-audio.ts @@ -0,0 +1,206 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { SpeechToTextClient } from '../lib/client'; +import { azureSpeechServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let wordSchema = z.object({ + text: z.string().describe('Recognized word text'), + offsetMilliseconds: z.number().optional().describe('Word start offset in milliseconds'), + durationMilliseconds: z.number().optional().describe('Word duration in milliseconds') +}); + +let phraseSchema = z.object({ + channel: z + .number() + .optional() + .describe('Audio channel index when channel separation is used'), + speaker: z.number().optional().describe('Speaker label when diarization is enabled'), + offsetMilliseconds: z.number().optional().describe('Phrase start offset in milliseconds'), + durationMilliseconds: z.number().optional().describe('Phrase duration in milliseconds'), + text: z.string().describe('Recognized phrase text'), + locale: z.string().optional().describe('Detected or configured phrase locale'), + confidence: z.number().optional().describe('Recognition confidence from 0.0 to 1.0'), + words: z.array(wordSchema).optional().describe('Word-level timings when returned') +}); + +export let fastTranscribeAudio = SlateTool.create(spec, { + name: 'Fast Transcribe Audio', + key: 'fast_transcribe_audio', + description: `Synchronously transcribes one audio file with Azure Speech fast transcription. Use this for quick file transcription with predictable latency when the audio is too large for short-audio recognition or when phrase/channel/diarization detail is needed.`, + instructions: [ + 'Provide one base64-encoded audio file. Azure supports common formats such as WAV, MP3, FLAC, and OGG for fast transcription.', + 'Set locales when the expected language is known. Omit locales to let Azure use multilingual detection.', + 'Enable diarization only for single-channel audio. Do not request channels [0, 1] with diarization.' + ], + constraints: [ + 'The audio file must be shorter than 2 hours and smaller than 250 MB.', + 'Fast transcription is synchronous and may still take time for longer audio.' + ], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + audioBase64: z.string().describe('Base64-encoded audio file content'), + fileName: z + .string() + .optional() + .describe('Original file name, including extension. Defaults to audio.wav.'), + contentType: z + .string() + .optional() + .describe('Audio MIME type, such as audio/wav or audio/mpeg. Defaults to audio/wav.'), + locales: z + .array(z.string()) + .optional() + .describe( + 'Optional candidate locales. Use one known locale for accuracy or multiple locales for language identification.' + ), + channels: z + .array(z.number()) + .optional() + .describe('Optional zero-based channels to transcribe separately, such as [0, 1]'), + diarizationEnabled: z + .boolean() + .optional() + .describe('Enable speaker diarization for single-channel audio'), + diarizationMaxSpeakers: z + .number() + .optional() + .describe('Maximum expected speakers for diarization (2-35)'), + profanityFilterMode: z + .enum(['None', 'Masked', 'Removed', 'Tags']) + .optional() + .describe('How Azure should handle profanity in transcription results') + }) + ) + .output( + z.object({ + transcript: z.string().describe('Combined transcript text across channels'), + durationMilliseconds: z + .number() + .optional() + .describe('Audio duration in milliseconds when returned'), + combinedPhrases: z + .array( + z.object({ + channel: z.number().optional().describe('Audio channel index'), + text: z.string().describe('Combined transcript text for this channel') + }) + ) + .describe('Full transcript phrases, separated by channel when applicable'), + phrases: z.array(phraseSchema).describe('Detailed phrase-level transcription results') + }) + ) + .handleInvocation(async ctx => { + if (ctx.input.locales?.length === 0) { + throw azureSpeechServiceError('locales must contain at least one locale when provided.'); + } + + if (ctx.input.channels && ctx.input.channels.length > 2) { + throw azureSpeechServiceError('Fast transcription supports at most two channels.'); + } + + if ( + ctx.input.diarizationMaxSpeakers !== undefined && + (ctx.input.diarizationMaxSpeakers < 2 || ctx.input.diarizationMaxSpeakers > 35) + ) { + throw azureSpeechServiceError('diarizationMaxSpeakers must be between 2 and 35.'); + } + + if ( + (ctx.input.diarizationEnabled || ctx.input.diarizationMaxSpeakers !== undefined) && + ctx.input.channels?.length === 2 + ) { + throw azureSpeechServiceError( + 'diarization cannot be combined with channel separation for channels [0, 1].' + ); + } + + let client = new SpeechToTextClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + ctx.info('Running fast transcription...'); + + let result = await client.fastTranscribeAudio({ + audioBase64: ctx.input.audioBase64, + fileName: ctx.input.fileName, + contentType: ctx.input.contentType, + locales: ctx.input.locales, + channels: ctx.input.channels, + diarization: + ctx.input.diarizationEnabled !== undefined || + ctx.input.diarizationMaxSpeakers !== undefined + ? { + enabled: ctx.input.diarizationEnabled ?? true, + maxSpeakers: ctx.input.diarizationMaxSpeakers + } + : undefined, + profanityFilterMode: ctx.input.profanityFilterMode + }); + + let combinedPhrases: Array<{ channel?: number; text: string }> = ( + result.combinedPhrases ?? [] + ).map((phrase: any) => ({ + channel: phrase.channel, + text: phrase.text ?? '' + })); + + let phrases: Array<{ + channel?: number; + speaker?: number; + offsetMilliseconds?: number; + durationMilliseconds?: number; + text: string; + locale?: string; + confidence?: number; + words?: Array<{ + text: string; + offsetMilliseconds?: number; + durationMilliseconds?: number; + }>; + }> = (result.phrases ?? []).map((phrase: any) => ({ + channel: phrase.channel, + speaker: phrase.speaker, + offsetMilliseconds: phrase.offsetMilliseconds, + durationMilliseconds: phrase.durationMilliseconds, + text: phrase.text ?? '', + locale: phrase.locale, + confidence: phrase.confidence, + words: Array.isArray(phrase.words) + ? phrase.words.map((word: any) => ({ + text: word.text ?? '', + offsetMilliseconds: word.offsetMilliseconds, + durationMilliseconds: word.durationMilliseconds + })) + : undefined + })); + + let transcript = + combinedPhrases + .map((phrase: { text: string }) => phrase.text) + .filter(Boolean) + .join('\n') || + phrases + .map((phrase: { text: string }) => phrase.text) + .filter(Boolean) + .join(' '); + + return { + output: { + transcript, + durationMilliseconds: result.durationMilliseconds, + combinedPhrases, + phrases + }, + message: transcript + ? `Fast transcription completed: "${transcript.slice(0, 160)}${transcript.length > 160 ? '...' : ''}"` + : 'Fast transcription completed without transcript text.' + }; + }) + .build(); diff --git a/integrations/azure-speech/src/tools/identify-speaker.ts b/integrations/azure-speech/src/tools/identify-speaker.ts index 5da74098d2..b93de5b3bb 100644 --- a/integrations/azure-speech/src/tools/identify-speaker.ts +++ b/integrations/azure-speech/src/tools/identify-speaker.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SpeakerRecognitionClient } from '../lib/client'; +import { azureSpeechServiceError } from '../lib/errors'; import { spec } from '../spec'; export let identifySpeaker = SlateTool.create(spec, { @@ -45,7 +46,13 @@ Uses text-independent identification — the speaker can say anything.`, }); if (ctx.input.profileIds.length > 50) { - throw new Error('Maximum 50 candidate profiles per identification request.'); + throw azureSpeechServiceError( + 'Maximum 50 candidate profiles per identification request.' + ); + } + + if (ctx.input.profileIds.length === 0) { + throw azureSpeechServiceError('At least one profileId is required.'); } ctx.info(`Identifying speaker against ${ctx.input.profileIds.length} profiles...`); diff --git a/integrations/azure-speech/src/tools/index.ts b/integrations/azure-speech/src/tools/index.ts index 72e01e2078..d6bda86557 100644 --- a/integrations/azure-speech/src/tools/index.ts +++ b/integrations/azure-speech/src/tools/index.ts @@ -1,5 +1,7 @@ export * from './create-batch-transcription'; export * from './delete-batch-transcription'; +export * from './enroll-speaker-profile'; +export * from './fast-transcribe-audio'; export * from './get-batch-transcription'; export * from './identify-speaker'; export * from './list-batch-transcriptions'; diff --git a/integrations/azure-speech/src/tools/manage-speaker-profile.ts b/integrations/azure-speech/src/tools/manage-speaker-profile.ts index 0579588f87..cc9f3d9c91 100644 --- a/integrations/azure-speech/src/tools/manage-speaker-profile.ts +++ b/integrations/azure-speech/src/tools/manage-speaker-profile.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { SpeakerRecognitionClient } from '../lib/client'; +import { azureSpeechServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageSpeakerProfile = SlateTool.create(spec, { @@ -84,7 +85,9 @@ Supports text-independent speaker recognition profiles.`, let { action, profileType, profileId, locale } = ctx.input; if (action === 'create') { - if (!locale) throw new Error('locale is required for creating a profile.'); + if (!locale) { + throw azureSpeechServiceError('locale is required for creating a profile.'); + } ctx.info(`Creating ${profileType} profile...`); let result = await client.createVerificationProfile(locale); if (profileType === 'identification') { @@ -107,7 +110,9 @@ Supports text-independent speaker recognition profiles.`, } if (action === 'get') { - if (!profileId) throw new Error('profileId is required for getting a profile.'); + if (!profileId) { + throw azureSpeechServiceError('profileId is required for getting a profile.'); + } let result = await client.getProfile(profileType, profileId); return { output: { @@ -141,7 +146,9 @@ Supports text-independent speaker recognition profiles.`, } if (action === 'delete') { - if (!profileId) throw new Error('profileId is required for deleting a profile.'); + if (!profileId) { + throw azureSpeechServiceError('profileId is required for deleting a profile.'); + } ctx.info(`Deleting ${profileType} profile ${profileId}...`); await client.deleteProfile(profileType, profileId); return { @@ -152,6 +159,6 @@ Supports text-independent speaker recognition profiles.`, }; } - throw new Error(`Unknown action: ${action}`); + throw azureSpeechServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/azure-speech/src/tools/shared.ts b/integrations/azure-speech/src/tools/shared.ts new file mode 100644 index 0000000000..45a1b0797d --- /dev/null +++ b/integrations/azure-speech/src/tools/shared.ts @@ -0,0 +1,23 @@ +import { createBase64Attachment } from 'slates'; +import { z } from 'zod'; + +export type AudioResult = { + contentBase64: string; + contentType: string; + byteLength: number; +}; + +export let audioOutputSchema = z.object({ + contentType: z.string().describe('MIME type of the returned audio attachment'), + byteLength: z.number().describe('Decoded audio byte length'), + attachmentCount: z.number().describe('Number of audio attachments returned') +}); + +export let audioOutput = (result: AudioResult) => ({ + contentType: result.contentType, + byteLength: result.byteLength, + attachmentCount: 1 +}); + +export let audioAttachment = (result: AudioResult) => + createBase64Attachment(result.contentBase64, result.contentType); diff --git a/integrations/azure-speech/src/tools/synthesize-speech.ts b/integrations/azure-speech/src/tools/synthesize-speech.ts index c71b77a322..ce134467ae 100644 --- a/integrations/azure-speech/src/tools/synthesize-speech.ts +++ b/integrations/azure-speech/src/tools/synthesize-speech.ts @@ -1,14 +1,16 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { TextToSpeechClient } from '../lib/client'; +import { azureSpeechServiceError } from '../lib/errors'; import { spec } from '../spec'; +import { audioAttachment, audioOutput, audioOutputSchema } from './shared'; export let synthesizeSpeech = SlateTool.create(spec, { name: 'Synthesize Speech', key: 'synthesize_speech', description: `Converts text into natural-sounding synthesized speech audio using Azure neural voices. Provide either plain text (which will be wrapped in SSML automatically) or custom SSML for fine-grained control over pronunciation, prosody, speaking styles, pauses, and other speech characteristics. -Returns the synthesized audio as a base64-encoded string.`, +Returns the synthesized audio as a Slate attachment.`, instructions: [ 'When providing plain text, specify a voiceName to select the desired voice. Use the **List Voices** tool to discover available voices.', 'For advanced control (e.g., speaking styles, emphasis, breaks), provide custom SSML directly in the ssml field instead of text.', @@ -16,7 +18,7 @@ Returns the synthesized audio as a base64-encoded string.`, ], constraints: [ 'Audio output is truncated to 10 minutes maximum.', - 'The audio is returned as base64, which increases payload size by ~33%.' + 'Generated audio is returned as an attachment; tool output only includes metadata.' ], tags: { destructive: false, @@ -57,17 +59,16 @@ Returns the synthesized audio as a base64-encoded string.`, ) }) ) - .output( - z.object({ - audioBase64: z.string().describe('Base64-encoded audio data of the synthesized speech'), - contentType: z.string().describe('MIME content type of the audio response') - }) - ) + .output(audioOutputSchema) .handleInvocation(async ctx => { let { text, ssml, voiceName, language, outputFormat } = ctx.input; if (!text && !ssml) { - throw new Error('Either text or ssml must be provided.'); + throw azureSpeechServiceError('Either text or ssml must be provided.'); + } + + if (text && ssml) { + throw azureSpeechServiceError('text and ssml are mutually exclusive.'); } let finalSsml: string; @@ -75,7 +76,7 @@ Returns the synthesized audio as a base64-encoded string.`, finalSsml = ssml; } else { if (!voiceName) { - throw new Error('voiceName is required when using plain text input.'); + throw azureSpeechServiceError('voiceName is required when using plain text input.'); } let escapedText = text! .replace(/&/g, '&') @@ -97,10 +98,8 @@ Returns the synthesized audio as a base64-encoded string.`, }); return { - output: { - audioBase64: result.audioBase64, - contentType: result.contentType - }, + output: audioOutput(result), + attachments: [audioAttachment(result)], message: `Successfully synthesized speech. Audio format: **${outputFormat}**, content type: ${result.contentType}.` }; }) diff --git a/integrations/bigcommerce/README.md b/integrations/bigcommerce/README.md index df116bd49f..097d44d6a2 100644 --- a/integrations/bigcommerce/README.md +++ b/integrations/bigcommerce/README.md @@ -32,6 +32,10 @@ List all sales channels configured for the store. Channels represent different s Search and list customers. Supports filtering by email, name, company, customer group, date range, and more. Returns paginated results with customer details. +### List Order Statuses + +List BigCommerce order statuses and their IDs. Use this before updating an order status when you need the correct statusId value. + ### List Orders Search and list orders from the store. Supports filtering by status, customer, date range, and more. Returns order details including totals, status, and customer information. @@ -46,7 +50,7 @@ List, create, update, or delete product brands. Brands help organize products an ### Manage Cart -Create, retrieve, update, or delete carts and their line items. Supports creating draft carts with customer association, adding/removing items, and updating quantities. +Create, retrieve, update, or delete carts and their line items. Supports draft carts, item quantity or list price updates, optimistic cart versions, included sub-resources, and one-time redirect URLs. ### Manage Category @@ -60,6 +64,10 @@ List, create, update, or delete coupons for marketing promotions. Supports perce Create, update, or delete a customer. When creating, provide first name, last name, and email. When updating, provide the customer ID and the fields to change. Supports managing addresses alongside the customer. +### Manage Inventory + +List location-aware inventory items, list inventory locations, or apply absolute inventory adjustments. Absolute adjustments set the current quantity for tracked products or variants at specific locations. + ### Manage Order Shipment Create or update shipments for an order. Use this to mark items as shipped with tracking information, or to list existing shipments for an order. diff --git a/integrations/bigcommerce/package.json b/integrations/bigcommerce/package.json index ec4927abe2..0fdba5dd97 100644 --- a/integrations/bigcommerce/package.json +++ b/integrations/bigcommerce/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/bigcommerce/src/auth.ts b/integrations/bigcommerce/src/auth.ts index 27b9a64c78..f804d111a1 100644 --- a/integrations/bigcommerce/src/auth.ts +++ b/integrations/bigcommerce/src/auth.ts @@ -1,10 +1,16 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { bigcommerceApiError } from './lib/errors'; let loginAxios = createAxios({ baseURL: 'https://login.bigcommerce.com' }); +loginAxios.interceptors.response.use( + response => response, + error => Promise.reject(bigcommerceApiError(error, 'OAuth request')) +); + export let auth = SlateAuth.create() .output( z.object({ @@ -124,6 +130,16 @@ export let auth = SlateAuth.create() description: 'Read and modify store profile and settings', scope: 'store_v2_information' }, + { + title: 'Store Inventory (Read-Only)', + description: 'View location-aware inventory items and locations', + scope: 'store_inventory_read_only' + }, + { + title: 'Store Inventory', + description: 'Read and modify location-aware inventory levels', + scope: 'store_inventory' + }, { title: 'Storefront API Tokens', description: 'Manage storefront API tokens', diff --git a/integrations/bigcommerce/src/index.ts b/integrations/bigcommerce/src/index.ts index 65e8775b3b..720dd1252b 100644 --- a/integrations/bigcommerce/src/index.ts +++ b/integrations/bigcommerce/src/index.ts @@ -8,6 +8,7 @@ import { getStoreInformation, listChannels, listCustomers, + listOrderStatuses, listOrders, listProducts, manageBrand, @@ -15,6 +16,7 @@ import { manageCategory, manageCoupon, manageCustomer, + manageInventory, manageOrderShipment, managePage, managePriceList, @@ -46,6 +48,7 @@ export let provider = Slate.create({ listCustomers, manageCustomer, manageCart, + manageInventory, manageCategory, manageBrand, manageCoupon, @@ -53,7 +56,8 @@ export let provider = Slate.create({ getStoreInformation, manageSubscriber, listChannels, - managePriceList + managePriceList, + listOrderStatuses ], triggers: [ orderEvents, diff --git a/integrations/bigcommerce/src/lib/client.ts b/integrations/bigcommerce/src/lib/client.ts index 7baa61ee9d..9a07e58c71 100644 --- a/integrations/bigcommerce/src/lib/client.ts +++ b/integrations/bigcommerce/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { bigcommerceApiError } from './errors'; export interface ClientConfig { token: string; @@ -37,6 +38,11 @@ export class Client { 'Content-Type': 'application/json' } }); + + this.api.interceptors.response.use( + response => response, + error => Promise.reject(bigcommerceApiError(error)) + ); } // ─── Products ─────────────────────────────────────────────────────── @@ -288,8 +294,11 @@ export class Client { // ─── Carts ────────────────────────────────────────────────────────── - async createCart(data: Record): Promise> { - let response = await this.api.post('/v3/carts', data); + async createCart( + data: Record, + params?: Record + ): Promise> { + let response = await this.api.post('/v3/carts', data, { params }); return response.data; } @@ -300,18 +309,22 @@ export class Client { async addCartLineItems( cartId: string, - data: Record + data: Record, + params?: Record ): Promise> { - let response = await this.api.post(`/v3/carts/${cartId}/items`, data); + let response = await this.api.post(`/v3/carts/${cartId}/items`, data, { params }); return response.data; } async updateCartLineItem( cartId: string, itemId: string, - data: Record + data: Record, + params?: Record ): Promise> { - let response = await this.api.put(`/v3/carts/${cartId}/items/${itemId}`, data); + let response = await this.api.put(`/v3/carts/${cartId}/items/${itemId}`, data, { + params + }); return response.data; } @@ -323,6 +336,14 @@ export class Client { await this.api.delete(`/v3/carts/${cartId}`); } + async createCartRedirectUrl( + cartId: string, + data?: Record + ): Promise> { + let response = await this.api.post(`/v3/carts/${cartId}/redirect_urls`, data ?? {}); + return response.data; + } + // ─── Checkouts ────────────────────────────────────────────────────── async getCheckout(checkoutId: string): Promise> { @@ -523,8 +544,11 @@ export class Client { return response.data; } - async adjustInventory(data: Record[]): Promise { - let response = await this.api.put('/v3/inventory/adjustments/absolute', { items: data }); + async adjustInventory(data: { + items: Record[]; + reason?: string; + }): Promise { + let response = await this.api.put('/v3/inventory/adjustments/absolute', data); return response.data; } diff --git a/integrations/bigcommerce/src/lib/errors.ts b/integrations/bigcommerce/src/lib/errors.ts new file mode 100644 index 0000000000..ae7325e5a2 --- /dev/null +++ b/integrations/bigcommerce/src/lib/errors.ts @@ -0,0 +1,93 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) collectDetails(item, details); + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.title); + pushDetail(details, value.message); + pushDetail(details, value.detail); + pushDetail(details, value.error); + pushDetail(details, value.code); + collectDetails(value.errors, details); +}; + +let extractBigCommerceMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + if (isRecord(response?.data)) { + collectDetails(response.data.errors, details); + collectDetails(response.data.error, details); + collectDetails(response.data.message, details); + collectDetails(response.data.title, details); + } else { + collectDetails(response?.data, details); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let bigcommerceServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let bigcommerceApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = bigcommerceServiceError( + `BigCommerce API ${operation} failed: ${statusLabel}${extractBigCommerceMessage(error)}` + ); + + serviceError.data.reason = 'bigcommerce_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/bigcommerce/src/tools.schema.test.ts b/integrations/bigcommerce/src/tools.schema.test.ts new file mode 100644 index 0000000000..01fa028b62 --- /dev/null +++ b/integrations/bigcommerce/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('BigCommerce tool input schemas', provider.actions); diff --git a/integrations/bigcommerce/src/tools/index.ts b/integrations/bigcommerce/src/tools/index.ts index 4dfa932627..dd173b5e33 100644 --- a/integrations/bigcommerce/src/tools/index.ts +++ b/integrations/bigcommerce/src/tools/index.ts @@ -5,6 +5,7 @@ export * from './get-product'; export * from './get-store-information'; export * from './list-channels'; export * from './list-customers'; +export * from './list-order-statuses'; export * from './list-orders'; export * from './list-products'; export * from './manage-brand'; @@ -12,6 +13,7 @@ export * from './manage-cart'; export * from './manage-category'; export * from './manage-coupon'; export * from './manage-customer'; +export * from './manage-inventory'; export * from './manage-order-shipment'; export * from './manage-page'; export * from './manage-price-list'; diff --git a/integrations/bigcommerce/src/tools/list-order-statuses.ts b/integrations/bigcommerce/src/tools/list-order-statuses.ts new file mode 100644 index 0000000000..19f515745e --- /dev/null +++ b/integrations/bigcommerce/src/tools/list-order-statuses.ts @@ -0,0 +1,33 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listOrderStatuses = SlateTool.create(spec, { + name: 'List Order Statuses', + key: 'list_order_statuses', + description: `List BigCommerce order statuses and their IDs. Use this before updating an order status when you need the correct statusId value.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + statuses: z.array(z.any()).describe('Array of order status objects') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + storeHash: ctx.config.storeHash + }); + + let statuses = await client.listOrderStatuses(); + + return { + output: { statuses }, + message: `Found ${statuses.length} order statuses.` + }; + }) + .build(); diff --git a/integrations/bigcommerce/src/tools/manage-brand.ts b/integrations/bigcommerce/src/tools/manage-brand.ts index 8b276382cc..fdbdd4a517 100644 --- a/integrations/bigcommerce/src/tools/manage-brand.ts +++ b/integrations/bigcommerce/src/tools/manage-brand.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { bigcommerceServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageBrand = SlateTool.create(spec, { @@ -52,7 +53,7 @@ export let manageBrand = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.brandId) throw new Error('brandId is required for delete'); + if (!ctx.input.brandId) throw bigcommerceServiceError('brandId is required for delete'); await client.deleteBrand(ctx.input.brandId); return { output: { deleted: true }, @@ -74,7 +75,7 @@ export let manageBrand = SlateTool.create(spec, { }; } - if (!ctx.input.brandId) throw new Error('brandId is required for update'); + if (!ctx.input.brandId) throw bigcommerceServiceError('brandId is required for update'); let result = await client.updateBrand(ctx.input.brandId, data); return { output: { brand: result.data }, diff --git a/integrations/bigcommerce/src/tools/manage-cart.ts b/integrations/bigcommerce/src/tools/manage-cart.ts index cfd300adff..8957aa48f6 100644 --- a/integrations/bigcommerce/src/tools/manage-cart.ts +++ b/integrations/bigcommerce/src/tools/manage-cart.ts @@ -1,37 +1,58 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { bigcommerceServiceError } from '../lib/errors'; import { spec } from '../spec'; let lineItemSchema = z.object({ productId: z.number().describe('Product ID to add'), quantity: z.number().describe('Quantity to add'), variantId: z.number().optional().describe('Specific variant ID'), - listPrice: z.number().optional().describe('Override list price') + listPrice: z.number().optional().describe('Override list price'), + optionSelections: z + .array( + z.object({ + optionId: z.number().describe('Product option ID'), + optionValue: z.any().describe('Selected option value') + }) + ) + .optional() + .describe('Product option selections for configurable products') }); export let manageCart = SlateTool.create(spec, { name: 'Manage Cart', key: 'manage_cart', - description: `Create, retrieve, update, or delete carts and their line items. Supports creating draft carts with customer association, adding/removing items, and updating quantities.`, + description: `Create, retrieve, update, or delete carts and their line items. Supports draft carts, item quantity or list price updates, and one-time cart or checkout redirect URLs.`, instructions: [ 'Use action "create" to create a new cart with line items.', 'Use action "get" to retrieve an existing cart by cartId.', 'Use action "add_items" to add line items to an existing cart.', - 'Use action "update_item" to change quantity of a specific line item.', + 'Use action "update_item" to change quantity or listPrice of a specific line item.', 'Use action "remove_item" to remove a specific line item.', + 'Use action "create_redirect_url" to create one-time redirect URLs for a cart.', 'Use action "delete" to delete the entire cart.' ] }) .input( z.object({ action: z - .enum(['create', 'get', 'add_items', 'update_item', 'remove_item', 'delete']) + .enum([ + 'create', + 'get', + 'add_items', + 'update_item', + 'remove_item', + 'create_redirect_url', + 'delete' + ]) .describe('Action to perform'), cartId: z .string() .optional() - .describe('Cart ID (required for get/add_items/update_item/remove_item/delete)'), + .describe( + 'Cart ID (required for get/add_items/update_item/remove_item/create_redirect_url/delete)' + ), customerId: z.number().optional().describe('Customer ID to associate with the cart'), lineItems: z .array(lineItemSchema) @@ -41,12 +62,33 @@ export let manageCart = SlateTool.create(spec, { .string() .optional() .describe('Specific line item ID for update_item or remove_item'), - quantity: z.number().optional().describe('New quantity for update_item action') + quantity: z.number().optional().describe('New quantity for update_item action'), + listPrice: z.number().optional().describe('New list price for update_item action'), + version: z + .number() + .optional() + .describe('Expected cart version for optimistic concurrency control'), + include: z + .array( + z.enum([ + 'redirect_urls', + 'line_items.physical_items.options', + 'line_items.digital_items.options', + 'promotions.banners' + ]) + ) + .optional() + .describe('Cart sub-resources to include in create/get/add/update responses'), + queryParams: z + .record(z.string(), z.string()) + .optional() + .describe('Optional query parameters for create_redirect_url') }) ) .output( z.object({ cart: z.any().optional().describe('The cart object'), + redirectUrls: z.any().optional().describe('Created cart redirect URLs'), deleted: z.boolean().optional().describe('Whether the cart or item was deleted') }) ) @@ -61,25 +103,39 @@ export let manageCart = SlateTool.create(spec, { product_id: item.productId, quantity: item.quantity, variant_id: item.variantId, - list_price: item.listPrice + list_price: item.listPrice, + option_selections: item.optionSelections?.map(selection => ({ + option_id: selection.optionId, + option_value: selection.optionValue + })) })); + let params: Record = {}; + if (ctx.input.include?.length) params.include = ctx.input.include.join(','); + if (ctx.input.action === 'create') { + if (!ctx.input.lineItems?.length) { + throw bigcommerceServiceError('lineItems is required for create'); + } + let data: Record = { line_items: mapLineItems(ctx.input.lineItems) }; if (ctx.input.customerId) data.customer_id = ctx.input.customerId; - let result = await client.createCart(data); + if (ctx.input.version !== undefined) data.version = ctx.input.version; + let result = await client.createCart(data, params); return { output: { cart: result.data }, message: `Created cart (ID: ${result.data.id}) with ${ctx.input.lineItems?.length || 0} item(s).` }; } - if (!ctx.input.cartId) throw new Error('cartId is required for this action'); + if (!ctx.input.cartId) { + throw bigcommerceServiceError('cartId is required for this action'); + } if (ctx.input.action === 'get') { - let result = await client.getCart(ctx.input.cartId); + let result = await client.getCart(ctx.input.cartId, params); return { output: { cart: result.data }, message: `Retrieved cart ${ctx.input.cartId}.` @@ -87,8 +143,13 @@ export let manageCart = SlateTool.create(spec, { } if (ctx.input.action === 'add_items') { - let data = { line_items: mapLineItems(ctx.input.lineItems) }; - let result = await client.addCartLineItems(ctx.input.cartId, data); + if (!ctx.input.lineItems?.length) { + throw bigcommerceServiceError('lineItems is required for add_items'); + } + + let data: Record = { line_items: mapLineItems(ctx.input.lineItems) }; + if (ctx.input.version !== undefined) data.version = ctx.input.version; + let result = await client.addCartLineItems(ctx.input.cartId, data, params); return { output: { cart: result.data }, message: `Added ${ctx.input.lineItems?.length || 0} item(s) to cart ${ctx.input.cartId}.` @@ -96,12 +157,25 @@ export let manageCart = SlateTool.create(spec, { } if (ctx.input.action === 'update_item') { - if (!ctx.input.lineItemId) throw new Error('lineItemId is required for update_item'); - let data: Record = { line_item: { quantity: ctx.input.quantity } }; + if (!ctx.input.lineItemId) { + throw bigcommerceServiceError('lineItemId is required for update_item'); + } + if (ctx.input.quantity === undefined && ctx.input.listPrice === undefined) { + throw bigcommerceServiceError('quantity or listPrice is required for update_item'); + } + + let lineItem: Record = {}; + if (ctx.input.quantity !== undefined) lineItem.quantity = ctx.input.quantity; + if (ctx.input.listPrice !== undefined) lineItem.list_price = ctx.input.listPrice; + + let data: Record = { line_item: lineItem }; + if (ctx.input.version !== undefined) data.version = ctx.input.version; + let result = await client.updateCartLineItem( ctx.input.cartId, ctx.input.lineItemId, - data + data, + params ); return { output: { cart: result.data }, @@ -110,7 +184,9 @@ export let manageCart = SlateTool.create(spec, { } if (ctx.input.action === 'remove_item') { - if (!ctx.input.lineItemId) throw new Error('lineItemId is required for remove_item'); + if (!ctx.input.lineItemId) { + throw bigcommerceServiceError('lineItemId is required for remove_item'); + } await client.deleteCartLineItem(ctx.input.cartId, ctx.input.lineItemId); return { output: { deleted: true }, @@ -118,6 +194,16 @@ export let manageCart = SlateTool.create(spec, { }; } + if (ctx.input.action === 'create_redirect_url') { + let result = await client.createCartRedirectUrl(ctx.input.cartId, { + query_params: ctx.input.queryParams + }); + return { + output: { redirectUrls: result.data }, + message: `Created redirect URLs for cart ${ctx.input.cartId}.` + }; + } + if (ctx.input.action === 'delete') { await client.deleteCart(ctx.input.cartId); return { @@ -126,6 +212,6 @@ export let manageCart = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw bigcommerceServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/bigcommerce/src/tools/manage-category.ts b/integrations/bigcommerce/src/tools/manage-category.ts index a812918845..cfd2ca7f62 100644 --- a/integrations/bigcommerce/src/tools/manage-category.ts +++ b/integrations/bigcommerce/src/tools/manage-category.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { bigcommerceServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCategory = SlateTool.create(spec, { @@ -59,7 +60,8 @@ export let manageCategory = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.categoryId) throw new Error('categoryId is required for delete'); + if (!ctx.input.categoryId) + throw bigcommerceServiceError('categoryId is required for delete'); await client.deleteCategory(ctx.input.categoryId); return { output: { deleted: true }, @@ -87,7 +89,8 @@ export let manageCategory = SlateTool.create(spec, { }; } - if (!ctx.input.categoryId) throw new Error('categoryId is required for update'); + if (!ctx.input.categoryId) + throw bigcommerceServiceError('categoryId is required for update'); categoryData.category_id = ctx.input.categoryId; let result = await client.updateCategory(categoryData); let cat = Array.isArray(result.data) ? result.data[0] : result.data; diff --git a/integrations/bigcommerce/src/tools/manage-coupon.ts b/integrations/bigcommerce/src/tools/manage-coupon.ts index bc19c458c5..2fb306bf9c 100644 --- a/integrations/bigcommerce/src/tools/manage-coupon.ts +++ b/integrations/bigcommerce/src/tools/manage-coupon.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { bigcommerceServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCoupon = SlateTool.create(spec, { @@ -76,7 +77,8 @@ export let manageCoupon = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.couponId) throw new Error('couponId is required for delete'); + if (!ctx.input.couponId) + throw bigcommerceServiceError('couponId is required for delete'); await client.deleteCoupon(ctx.input.couponId); return { output: { deleted: true }, @@ -110,7 +112,7 @@ export let manageCoupon = SlateTool.create(spec, { }; } - if (!ctx.input.couponId) throw new Error('couponId is required for update'); + if (!ctx.input.couponId) throw bigcommerceServiceError('couponId is required for update'); let coupon = await client.updateCoupon(ctx.input.couponId, data); return { output: { coupon }, diff --git a/integrations/bigcommerce/src/tools/manage-customer.ts b/integrations/bigcommerce/src/tools/manage-customer.ts index 3ddd8b6fba..0970b92140 100644 --- a/integrations/bigcommerce/src/tools/manage-customer.ts +++ b/integrations/bigcommerce/src/tools/manage-customer.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { bigcommerceServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCustomer = SlateTool.create(spec, { @@ -59,7 +60,8 @@ export let manageCustomer = SlateTool.create(spec, { }); if (ctx.input.action === 'delete') { - if (!ctx.input.customerId) throw new Error('customerId is required for delete'); + if (!ctx.input.customerId) + throw bigcommerceServiceError('customerId is required for delete'); await client.deleteCustomers([ctx.input.customerId]); return { output: { deleted: true }, @@ -100,7 +102,8 @@ export let manageCustomer = SlateTool.create(spec, { }; } - if (!ctx.input.customerId) throw new Error('customerId is required for update'); + if (!ctx.input.customerId) + throw bigcommerceServiceError('customerId is required for update'); customerData.id = ctx.input.customerId; let result = await client.updateCustomers([customerData]); let customer = result.data[0]; diff --git a/integrations/bigcommerce/src/tools/manage-inventory.ts b/integrations/bigcommerce/src/tools/manage-inventory.ts new file mode 100644 index 0000000000..9d88a821c7 --- /dev/null +++ b/integrations/bigcommerce/src/tools/manage-inventory.ts @@ -0,0 +1,134 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { bigcommerceServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let inventoryAdjustmentItemSchema = z.object({ + locationId: z.number().describe('Inventory location ID to update'), + quantity: z.number().describe('Absolute inventory quantity to set'), + sku: z + .string() + .optional() + .describe('SKU to update. Provide sku, variantId, or productId for each item.'), + variantId: z + .number() + .optional() + .describe('Variant ID to update. Provide sku, variantId, or productId for each item.'), + productId: z + .number() + .optional() + .describe('Product ID to update when the product has no variants.') +}); + +export let manageInventory = SlateTool.create(spec, { + name: 'Manage Inventory', + key: 'manage_inventory', + description: `List location-aware inventory items, list inventory locations, or apply absolute inventory adjustments. Absolute adjustments set the current quantity for tracked products or variants at specific locations.`, + instructions: [ + 'Use action "list_items" to view inventory quantities across locations.', + 'Use action "list_locations" to retrieve inventory locations.', + 'Use action "adjust_absolute" to set inventory quantities; each item needs locationId, quantity, and at least one of sku, variantId, or productId.' + ] +}) + .input( + z.object({ + action: z + .enum(['list_items', 'list_locations', 'adjust_absolute']) + .describe('Action to perform'), + page: z.number().optional().describe('Page number for list actions'), + limit: z.number().optional().describe('Results per page for list actions'), + sku: z.string().optional().describe('Filter inventory items by SKU'), + variantId: z.number().optional().describe('Filter inventory items by variant ID'), + productId: z.number().optional().describe('Filter inventory items by product ID'), + locationId: z.number().optional().describe('Filter inventory items by location ID'), + reason: z.string().optional().describe('Reason for an absolute adjustment'), + items: z + .array(inventoryAdjustmentItemSchema) + .optional() + .describe('Items for adjust_absolute. Not used for list actions.') + }) + ) + .output( + z.object({ + inventoryItems: z + .array(z.any()) + .optional() + .describe('Inventory item records returned by list_items'), + locations: z + .array(z.any()) + .optional() + .describe('Inventory locations returned by list_locations'), + adjustment: z.any().optional().describe('Absolute adjustment response') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + storeHash: ctx.config.storeHash + }); + + if (ctx.input.action === 'list_locations') { + let params: Record = {}; + if (ctx.input.page) params.page = ctx.input.page; + if (ctx.input.limit) params.limit = ctx.input.limit; + + let result = await client.listLocations(params); + return { + output: { locations: result.data }, + message: `Found ${result.data.length} inventory locations.` + }; + } + + if (ctx.input.action === 'list_items') { + let params: Record = {}; + if (ctx.input.page) params.page = ctx.input.page; + if (ctx.input.limit) params.limit = ctx.input.limit; + if (ctx.input.sku) params.sku = ctx.input.sku; + if (ctx.input.variantId !== undefined) params.variant_id = ctx.input.variantId; + if (ctx.input.productId !== undefined) params.product_id = ctx.input.productId; + if (ctx.input.locationId !== undefined) params.location_id = ctx.input.locationId; + + let result = await client.getInventoryItems(params); + return { + output: { inventoryItems: result.data }, + message: `Found ${result.data.length} inventory item records.` + }; + } + + if (!ctx.input.items?.length) { + throw bigcommerceServiceError('items is required for adjust_absolute'); + } + + let items = ctx.input.items.map((item, index) => { + if ( + item.sku === undefined && + item.variantId === undefined && + item.productId === undefined + ) { + throw bigcommerceServiceError(`items[${index}] requires sku, variantId, or productId`); + } + + let mapped: Record = { + location_id: item.locationId, + quantity: item.quantity + }; + + if (item.sku !== undefined) mapped.sku = item.sku; + if (item.variantId !== undefined) mapped.variant_id = item.variantId; + if (item.productId !== undefined) mapped.product_id = item.productId; + + return mapped; + }); + + let adjustment = await client.adjustInventory({ + items, + reason: ctx.input.reason + }); + + return { + output: { adjustment }, + message: `Applied absolute inventory adjustment for ${items.length} item(s).` + }; + }) + .build(); diff --git a/integrations/bigcommerce/src/tools/manage-page.ts b/integrations/bigcommerce/src/tools/manage-page.ts index b2c9259ecd..b873f6d79d 100644 --- a/integrations/bigcommerce/src/tools/manage-page.ts +++ b/integrations/bigcommerce/src/tools/manage-page.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { bigcommerceServiceError } from '../lib/errors'; import { spec } from '../spec'; export let managePage = SlateTool.create(spec, { @@ -65,7 +66,7 @@ export let managePage = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.pageId) throw new Error('pageId is required for get'); + if (!ctx.input.pageId) throw bigcommerceServiceError('pageId is required for get'); let result = await client.getPage(ctx.input.pageId); return { output: { contentPage: result.data }, @@ -74,7 +75,7 @@ export let managePage = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.pageId) throw new Error('pageId is required for delete'); + if (!ctx.input.pageId) throw bigcommerceServiceError('pageId is required for delete'); await client.deletePage(ctx.input.pageId); return { output: { deleted: true }, @@ -102,7 +103,7 @@ export let managePage = SlateTool.create(spec, { }; } - if (!ctx.input.pageId) throw new Error('pageId is required for update'); + if (!ctx.input.pageId) throw bigcommerceServiceError('pageId is required for update'); let result = await client.updatePage(ctx.input.pageId, data); return { output: { contentPage: result.data }, diff --git a/integrations/bigcommerce/src/tools/manage-price-list.ts b/integrations/bigcommerce/src/tools/manage-price-list.ts index 19d672d875..a5890c7f50 100644 --- a/integrations/bigcommerce/src/tools/manage-price-list.ts +++ b/integrations/bigcommerce/src/tools/manage-price-list.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { bigcommerceServiceError } from '../lib/errors'; import { spec } from '../spec'; export let managePriceList = SlateTool.create(spec, { @@ -56,7 +57,8 @@ export let managePriceList = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.priceListId) throw new Error('priceListId is required for get'); + if (!ctx.input.priceListId) + throw bigcommerceServiceError('priceListId is required for get'); let result = await client.getPriceList(ctx.input.priceListId); return { output: { priceList: result.data }, @@ -65,7 +67,8 @@ export let managePriceList = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.priceListId) throw new Error('priceListId is required for delete'); + if (!ctx.input.priceListId) + throw bigcommerceServiceError('priceListId is required for delete'); await client.deletePriceList(ctx.input.priceListId); return { output: { deleted: true }, @@ -85,7 +88,8 @@ export let managePriceList = SlateTool.create(spec, { }; } - if (!ctx.input.priceListId) throw new Error('priceListId is required for update'); + if (!ctx.input.priceListId) + throw bigcommerceServiceError('priceListId is required for update'); let result = await client.updatePriceList(ctx.input.priceListId, data); return { output: { priceList: result.data }, diff --git a/integrations/bigcommerce/src/tools/manage-subscriber.ts b/integrations/bigcommerce/src/tools/manage-subscriber.ts index 332f184754..eefc81a857 100644 --- a/integrations/bigcommerce/src/tools/manage-subscriber.ts +++ b/integrations/bigcommerce/src/tools/manage-subscriber.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { bigcommerceServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageSubscriber = SlateTool.create(spec, { @@ -56,7 +57,8 @@ export let manageSubscriber = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.subscriberId) throw new Error('subscriberId is required for delete'); + if (!ctx.input.subscriberId) + throw bigcommerceServiceError('subscriberId is required for delete'); await client.deleteSubscriber(ctx.input.subscriberId); return { output: { deleted: true }, @@ -79,7 +81,8 @@ export let manageSubscriber = SlateTool.create(spec, { }; } - if (!ctx.input.subscriberId) throw new Error('subscriberId is required for update'); + if (!ctx.input.subscriberId) + throw bigcommerceServiceError('subscriberId is required for update'); let result = await client.updateSubscriber(ctx.input.subscriberId, data); return { output: { subscriber: result.data }, diff --git a/integrations/bigcommerce/vitest.config.ts b/integrations/bigcommerce/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/bigcommerce/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/bigquery/README.md b/integrations/bigquery/README.md index 558d82319f..57fcc9c261 100644 --- a/integrations/bigquery/README.md +++ b/integrations/bigquery/README.md @@ -24,6 +24,10 @@ List all datasets in the configured BigQuery project. Returns dataset IDs, frien List BigQuery jobs in the project. Jobs include queries, loads, exports, and copy operations. Filter by state, time range, or parent job. +### List Models + +List BigQuery ML models in a dataset and inspect model metadata such as model type, labels, expiration, feature columns, and training runs. Model training and prediction remain SQL workflows through **Execute SQL Query**; model metadata can be retrieved, updated, and deleted with the model tools. + ### List Routines List user-defined functions (UDFs), stored procedures, and table-valued functions in a BigQuery dataset. diff --git a/integrations/bigquery/docs/SPEC.md b/integrations/bigquery/docs/SPEC.md index ca0e8d88f3..795ccb6957 100644 --- a/integrations/bigquery/docs/SPEC.md +++ b/integrations/bigquery/docs/SPEC.md @@ -82,7 +82,7 @@ This API facilitates data sharing within and across organizations. It allows dat ### Machine Learning (BigQuery ML) -Create, train, evaluate, and predict with machine learning models directly in BigQuery using SQL. Supports models for classification, regression, clustering, time series forecasting, and more. Models are managed as resources within datasets. +Create, train, evaluate, and predict with machine learning models directly in BigQuery using SQL. Supports models for classification, regression, clustering, time series forecasting, and more. Models are managed as resources within datasets; the REST API exposes model list, get, patch, and delete operations for metadata and lifecycle management. ### IAM and Access Control diff --git a/integrations/bigquery/package.json b/integrations/bigquery/package.json index 736e7fcb41..15b97f097c 100644 --- a/integrations/bigquery/package.json +++ b/integrations/bigquery/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/bigquery/src/auth.ts b/integrations/bigquery/src/auth.ts index 1afe3a1ec1..71a1b4e4e1 100644 --- a/integrations/bigquery/src/auth.ts +++ b/integrations/bigquery/src/auth.ts @@ -1,5 +1,44 @@ import { axios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { bigQueryOAuthError, bigQueryServiceError } from './lib/errors'; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let parseServiceAccountJson = (raw: string) => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + let serviceError = bigQueryServiceError('Service account JSON must be valid JSON.'); + if (error instanceof Error) { + serviceError.setParent(error); + } + throw serviceError; + } + + if (!isRecord(parsed)) { + throw bigQueryServiceError('Service account JSON must be an object.'); + } + + let clientEmail = parsed.client_email; + let privateKey = parsed.private_key; + + if (typeof clientEmail !== 'string' || clientEmail.length === 0) { + throw bigQueryServiceError('Service account JSON must include client_email.'); + } + + if (typeof privateKey !== 'string' || privateKey.length === 0) { + throw bigQueryServiceError('Service account JSON must include private_key.'); + } + + return { clientEmail, privateKey }; +}; + +let expiresAtFrom = (expiresIn: unknown) => { + let seconds = typeof expiresIn === 'number' && Number.isFinite(expiresIn) ? expiresIn : 3600; + return new Date(Date.now() + seconds * 1000).toISOString(); +}; export let auth = SlateAuth.create() .output( @@ -76,48 +115,67 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let response = await axios.post('https://oauth2.googleapis.com/token', { - code: ctx.code, - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - redirect_uri: ctx.redirectUri, - grant_type: 'authorization_code' - }); - - let data = response.data; - let expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString(); + try { + let response = await axios.post('https://oauth2.googleapis.com/token', { + code: ctx.code, + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + redirect_uri: ctx.redirectUri, + grant_type: 'authorization_code' + }); - return { - output: { - token: data.access_token, - refreshToken: data.refresh_token, - expiresAt + let data = response.data; + if (typeof data.access_token !== 'string' || data.access_token.length === 0) { + throw bigQueryServiceError( + 'Google OAuth token response did not include an access token.' + ); } - }; + + return { + output: { + token: data.access_token, + refreshToken: + typeof data.refresh_token === 'string' ? data.refresh_token : undefined, + expiresAt: expiresAtFrom(data.expires_in) + } + }; + } catch (error) { + throw bigQueryOAuthError('authorization code exchange', error); + } }, handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - throw new Error('No refresh token available'); + throw bigQueryServiceError( + 'No refresh token is available for this BigQuery auth method.' + ); } - let response = await axios.post('https://oauth2.googleapis.com/token', { - refresh_token: ctx.output.refreshToken, - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - grant_type: 'refresh_token' - }); - - let data = response.data; - let expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString(); + try { + let response = await axios.post('https://oauth2.googleapis.com/token', { + refresh_token: ctx.output.refreshToken, + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + grant_type: 'refresh_token' + }); - return { - output: { - token: data.access_token, - refreshToken: ctx.output.refreshToken, - expiresAt + let data = response.data; + if (typeof data.access_token !== 'string' || data.access_token.length === 0) { + throw bigQueryServiceError( + 'Google OAuth refresh response did not include an access token.' + ); } - }; + + return { + output: { + token: data.access_token, + refreshToken: ctx.output.refreshToken, + expiresAt: expiresAtFrom(data.expires_in) + } + }; + } catch (error) { + throw bigQueryOAuthError('token refresh', error); + } }, getProfile: async (ctx: { @@ -125,22 +183,26 @@ export let auth = SlateAuth.create() input: {}; scopes: string[]; }) => { - let response = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + try { + let response = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); - let data = response.data; + let data = response.data; - return { - profile: { - id: data.id, - email: data.email, - name: data.name, - imageUrl: data.picture - } - }; + return { + profile: { + id: data.id, + email: data.email, + name: data.name, + imageUrl: data.picture + } + }; + } catch (error) { + throw bigQueryOAuthError('profile lookup', error); + } } }) .addCustomAuth({ @@ -153,82 +215,90 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { - let serviceAccount = JSON.parse(ctx.input.serviceAccountJson); - let clientEmail = serviceAccount.client_email; - let privateKey = serviceAccount.private_key; - - let now = Math.floor(Date.now() / 1000); - let header = { alg: 'RS256', typ: 'JWT' }; - let payload = { - iss: clientEmail, - scope: - 'https://www.googleapis.com/auth/bigquery https://www.googleapis.com/auth/cloud-platform', - aud: 'https://oauth2.googleapis.com/token', - iat: now, - exp: now + 3600 - }; + try { + let { clientEmail, privateKey } = parseServiceAccountJson( + ctx.input.serviceAccountJson + ); - let encodedHeader = btoa(JSON.stringify(header)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - let encodedPayload = btoa(JSON.stringify(payload)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - let signingInput = `${encodedHeader}.${encodedPayload}`; - - let pemContents = privateKey - .replace(/-----BEGIN PRIVATE KEY-----/g, '') - .replace(/-----END PRIVATE KEY-----/g, '') - .replace(/\s/g, ''); - - let binaryKey = atob(pemContents); - let keyArray = new Uint8Array(binaryKey.length); - for (let i = 0; i < binaryKey.length; i++) { - keyArray[i] = binaryKey.charCodeAt(i); - } + let now = Math.floor(Date.now() / 1000); + let header = { alg: 'RS256', typ: 'JWT' }; + let payload = { + iss: clientEmail, + scope: + 'https://www.googleapis.com/auth/bigquery https://www.googleapis.com/auth/cloud-platform', + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: now + 3600 + }; - let cryptoKey = await crypto.subtle.importKey( - 'pkcs8', - keyArray.buffer, - { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, - false, - ['sign'] - ); - - let encoder = new TextEncoder(); - let signatureBuffer = await crypto.subtle.sign( - 'RSASSA-PKCS1-v1_5', - cryptoKey, - encoder.encode(signingInput) - ); - - let signatureArray = new Uint8Array(signatureBuffer); - let signatureStr = ''; - for (let i = 0; i < signatureArray.length; i++) { - signatureStr += String.fromCharCode(signatureArray[i]!); - } - let encodedSignature = btoa(signatureStr) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + let encodedHeader = btoa(JSON.stringify(header)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + let encodedPayload = btoa(JSON.stringify(payload)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + let signingInput = `${encodedHeader}.${encodedPayload}`; - let jwt = `${signingInput}.${encodedSignature}`; + let pemContents = privateKey + .replace(/-----BEGIN PRIVATE KEY-----/g, '') + .replace(/-----END PRIVATE KEY-----/g, '') + .replace(/\s/g, ''); - let response = await axios.post('https://oauth2.googleapis.com/token', { - grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', - assertion: jwt - }); + let binaryKey = atob(pemContents); + let keyArray = new Uint8Array(binaryKey.length); + for (let i = 0; i < binaryKey.length; i++) { + keyArray[i] = binaryKey.charCodeAt(i); + } - let data = response.data; - let expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString(); + let cryptoKey = await crypto.subtle.importKey( + 'pkcs8', + keyArray.buffer, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + false, + ['sign'] + ); - return { - output: { - token: data.access_token, - expiresAt + let encoder = new TextEncoder(); + let signatureBuffer = await crypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + cryptoKey, + encoder.encode(signingInput) + ); + + let signatureArray = new Uint8Array(signatureBuffer); + let signatureStr = ''; + for (let i = 0; i < signatureArray.length; i++) { + signatureStr += String.fromCharCode(signatureArray[i]!); } - }; + let encodedSignature = btoa(signatureStr) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + let jwt = `${signingInput}.${encodedSignature}`; + + let response = await axios.post('https://oauth2.googleapis.com/token', { + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwt + }); + + let data = response.data; + if (typeof data.access_token !== 'string' || data.access_token.length === 0) { + throw bigQueryServiceError( + 'Google service account token response did not include an access token.' + ); + } + + return { + output: { + token: data.access_token, + expiresAt: expiresAtFrom(data.expires_in) + } + }; + } catch (error) { + throw bigQueryOAuthError('service account token exchange', error); + } } }); diff --git a/integrations/bigquery/src/index.ts b/integrations/bigquery/src/index.ts index 0450bfe3b3..a2616ae839 100644 --- a/integrations/bigquery/src/index.ts +++ b/integrations/bigquery/src/index.ts @@ -7,22 +7,26 @@ import { createRoutine, createTable, deleteDataset, + deleteModel, deleteRoutine, deleteTable, executeQuery, exportData, getDataset, getJob, + getModel, getRoutine, getTable, insertRows, listDatasets, listJobs, + listModels, listRoutines, listTables, loadData, readTableData, updateDataset, + updateModel, updateTable } from './tools'; import { datasetUpdated, inboundWebhook, jobCompleted } from './triggers'; @@ -49,6 +53,10 @@ export let provider = Slate.create({ readTableData, insertRows, copyTable, + listModels, + getModel, + updateModel, + deleteModel, listRoutines, getRoutine, createRoutine, diff --git a/integrations/bigquery/src/lib/client.ts b/integrations/bigquery/src/lib/client.ts index 73cdd732c7..58ca8647d4 100644 --- a/integrations/bigquery/src/lib/client.ts +++ b/integrations/bigquery/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { bigQueryApiError, bigQueryServiceError } from './errors'; export class BigQueryClient { private http: ReturnType; @@ -15,6 +16,11 @@ export class BigQueryClient { 'Content-Type': 'application/json' } }); + + this.http.interceptors.response.use( + response => response, + error => Promise.reject(bigQueryApiError(error)) + ); } // ── Datasets ────────────────────────────────────────────────────────── @@ -191,6 +197,51 @@ export class BigQueryClient { ); } + // ── Models ──────────────────────────────────────────────────────────── + + async listModels(datasetId: string, params?: { maxResults?: number; pageToken?: string }) { + let response = await this.http.get( + `/projects/${this.projectId}/datasets/${datasetId}/models`, + { + params: { + maxResults: params?.maxResults, + pageToken: params?.pageToken + } + } + ); + return response.data; + } + + async getModel(datasetId: string, modelId: string) { + let response = await this.http.get( + `/projects/${this.projectId}/datasets/${datasetId}/models/${modelId}` + ); + return response.data; + } + + async updateModel( + datasetId: string, + modelId: string, + updates: { + friendlyName?: string; + description?: string; + expirationTime?: string; + labels?: Record; + } + ) { + let response = await this.http.patch( + `/projects/${this.projectId}/datasets/${datasetId}/models/${modelId}`, + updates + ); + return response.data; + } + + async deleteModel(datasetId: string, modelId: string) { + await this.http.delete( + `/projects/${this.projectId}/datasets/${datasetId}/models/${modelId}` + ); + } + // ── Jobs / Queries ──────────────────────────────────────────────────── async createQueryJob(params: { @@ -563,6 +614,6 @@ export class BigQueryClient { } await new Promise(resolve => setTimeout(resolve, intervalMs)); } - throw new Error(`Job ${jobId} did not complete within ${timeoutMs}ms`); + throw bigQueryServiceError(`Job ${jobId} did not complete within ${timeoutMs}ms.`); } } diff --git a/integrations/bigquery/src/lib/errors.ts b/integrations/bigquery/src/lib/errors.ts new file mode 100644 index 0000000000..62958f2c09 --- /dev/null +++ b/integrations/bigquery/src/lib/errors.ts @@ -0,0 +1,135 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.status); + addDetail(details, value.reason); + addDetail(details, value.code); + addDetail(details, value.error_description); + collectDetails(value.errors, details); +}; + +let getErrorResponse = (error: unknown) => + isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + +let getErrorStatus = (error: unknown) => { + let response = getErrorResponse(error); + if (response?.status !== undefined) { + return response.status; + } + + if (isRecord(error) && typeof error.status === 'number') { + return error.status; + } + + if (isRecord(error) && isRecord(error.data) && typeof error.data.status === 'number') { + return error.data.status; + } + + return undefined; +}; + +let getStatusText = (error: unknown) => getErrorResponse(error)?.statusText; + +let extractBigQueryMessage = (error: unknown) => { + let details: string[] = []; + let response = getErrorResponse(error); + + if (isRecord(response?.data)) { + collectDetails(response.data.error, details); + collectDetails(response.data, details); + } else { + collectDetails(response?.data, details); + } + + if (isRecord(error) && isRecord(error.data)) { + collectDetails(error.data, details); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabel = (error: unknown) => { + let status = getErrorStatus(error); + let statusText = getStatusText(error); + return status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : ''; +}; + +export let bigQueryServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let bigQueryApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let serviceError = bigQueryServiceError( + `BigQuery API ${operation} failed: ${statusLabel(error)}${extractBigQueryMessage(error)}` + ); + serviceError.data.reason = 'bigquery_api_error'; + serviceError.data.upstreamStatus = getErrorStatus(error); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let bigQueryOAuthError = (operation: string, error: unknown) => { + if (error instanceof ServiceError) { + return error; + } + + let serviceError = bigQueryServiceError( + `BigQuery OAuth ${operation} failed: ${statusLabel(error)}${extractBigQueryMessage(error)}` + ); + serviceError.data.reason = 'bigquery_oauth_error'; + serviceError.data.upstreamStatus = getErrorStatus(error); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/bigquery/src/tools/execute-query.ts b/integrations/bigquery/src/tools/execute-query.ts index 165d541216..ffbe233756 100644 --- a/integrations/bigquery/src/tools/execute-query.ts +++ b/integrations/bigquery/src/tools/execute-query.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BigQueryClient } from '../lib/client'; +import { bigQueryServiceError } from '../lib/errors'; import { spec } from '../spec'; let queryParameterSchema = z.object({ @@ -163,15 +164,14 @@ Parameterized queries are supported for safe value interpolation. You can option let status = completedJob.status; if (status?.errorResult) { - return { - output: { - jobId, - jobComplete: true, - errors: [status.errorResult, ...(status.errors || [])], - totalBytesProcessed: completedJob.statistics?.totalBytesProcessed - }, - message: `Query job **${jobId}** failed: ${status.errorResult.message}` - }; + let serviceError = bigQueryServiceError( + `Query job ${jobId} failed: ${status.errorResult.message || 'Unknown query error.'}` + ); + serviceError.data.reason = 'bigquery_query_job_failed'; + serviceError.data.jobId = jobId; + serviceError.data.errors = [status.errorResult, ...(status.errors || [])]; + serviceError.data.totalBytesProcessed = completedJob.statistics?.totalBytesProcessed; + throw serviceError; } let queryStats = completedJob.statistics?.query; diff --git a/integrations/bigquery/src/tools/index.ts b/integrations/bigquery/src/tools/index.ts index a321cbced0..cabdf483b3 100644 --- a/integrations/bigquery/src/tools/index.ts +++ b/integrations/bigquery/src/tools/index.ts @@ -3,6 +3,7 @@ export * from './export-data'; export * from './load-data'; export * from './manage-datasets'; export * from './manage-jobs'; +export * from './manage-models'; export * from './manage-routines'; export * from './manage-tables'; export * from './table-data'; diff --git a/integrations/bigquery/src/tools/manage-models.ts b/integrations/bigquery/src/tools/manage-models.ts new file mode 100644 index 0000000000..823c34ba18 --- /dev/null +++ b/integrations/bigquery/src/tools/manage-models.ts @@ -0,0 +1,211 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BigQueryClient } from '../lib/client'; +import { bigQueryServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let modelSummarySchema = z.object({ + modelId: z.string(), + datasetId: z.string(), + projectId: z.string(), + modelType: z.string().optional(), + location: z.string().optional(), + friendlyName: z.string().optional(), + description: z.string().optional(), + creationTime: z.string().optional(), + lastModifiedTime: z.string().optional(), + expirationTime: z.string().optional(), + labels: z.record(z.string(), z.string()).optional() +}); + +let modelDetailsSchema = modelSummarySchema.extend({ + etag: z.string().optional(), + featureColumns: z.array(z.any()).optional(), + labelColumns: z.array(z.any()).optional(), + trainingRuns: z.array(z.any()).optional(), + encryptionConfiguration: z.any().optional() +}); + +let formatModel = (model: any) => ({ + modelId: model.modelReference.modelId, + datasetId: model.modelReference.datasetId, + projectId: model.modelReference.projectId, + modelType: model.modelType, + location: model.location, + friendlyName: model.friendlyName, + description: model.description, + creationTime: model.creationTime, + lastModifiedTime: model.lastModifiedTime, + expirationTime: model.expirationTime, + labels: model.labels, + etag: model.etag, + featureColumns: model.featureColumns, + labelColumns: model.labelColumns, + trainingRuns: model.trainingRuns, + encryptionConfiguration: model.encryptionConfiguration +}); + +export let listModels = SlateTool.create(spec, { + name: 'List Models', + key: 'list_models', + description: `List BigQuery ML models in a dataset. Model creation and training are performed with SQL using **execute_query** and CREATE MODEL; this tool lists the resulting model resources and metadata.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + datasetId: z.string().describe('Dataset to list models from'), + maxResults: z.number().optional().describe('Maximum number of models to return'), + pageToken: z.string().optional().describe('Page token for paginated results') + }) + ) + .output( + z.object({ + models: z.array(modelSummarySchema), + nextPageToken: z.string().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new BigQueryClient({ + token: ctx.auth.token, + projectId: ctx.config.projectId, + location: ctx.config.location + }); + + let result = await client.listModels(ctx.input.datasetId, { + maxResults: ctx.input.maxResults, + pageToken: ctx.input.pageToken + }); + + let models = (result.models || []).map(formatModel); + + return { + output: { + models, + nextPageToken: result.nextPageToken + }, + message: `Found **${models.length}** model(s) in dataset **${ctx.input.datasetId}**.` + }; + }) + .build(); + +export let getModel = SlateTool.create(spec, { + name: 'Get Model', + key: 'get_model', + description: `Retrieve detailed metadata for a BigQuery ML model, including model type, feature columns, label columns, training runs, labels, and expiration metadata.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + datasetId: z.string().describe('Dataset containing the model'), + modelId: z.string().describe('Model ID to retrieve') + }) + ) + .output(modelDetailsSchema) + .handleInvocation(async ctx => { + let client = new BigQueryClient({ + token: ctx.auth.token, + projectId: ctx.config.projectId, + location: ctx.config.location + }); + + let model = await client.getModel(ctx.input.datasetId, ctx.input.modelId); + let output = formatModel(model); + + return { + output, + message: `Model **${ctx.input.modelId}** (${output.modelType || 'unknown type'}) retrieved from dataset **${ctx.input.datasetId}**.` + }; + }) + .build(); + +export let updateModel = SlateTool.create(spec, { + name: 'Update Model', + key: 'update_model', + description: `Update BigQuery ML model metadata such as friendly name, description, expiration time, and labels. To change model training or prediction behavior, run SQL with **execute_query** instead.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + datasetId: z.string().describe('Dataset containing the model'), + modelId: z.string().describe('Model ID to update'), + friendlyName: z.string().optional().describe('Updated human-readable model name'), + description: z.string().optional().describe('Updated model description'), + expirationTime: z + .string() + .optional() + .describe('Updated expiration time as epoch milliseconds string'), + labels: z.record(z.string(), z.string()).optional().describe('Updated key-value labels') + }) + ) + .output(modelDetailsSchema) + .handleInvocation(async ctx => { + let updates = { + friendlyName: ctx.input.friendlyName, + description: ctx.input.description, + expirationTime: ctx.input.expirationTime, + labels: ctx.input.labels + }; + + if (Object.values(updates).every(value => value === undefined)) { + throw bigQueryServiceError( + 'At least one of friendlyName, description, expirationTime, or labels is required to update a model.' + ); + } + + let client = new BigQueryClient({ + token: ctx.auth.token, + projectId: ctx.config.projectId, + location: ctx.config.location + }); + + let model = await client.updateModel(ctx.input.datasetId, ctx.input.modelId, updates); + + return { + output: formatModel(model), + message: `Model **${ctx.input.modelId}** updated in dataset **${ctx.input.datasetId}**.` + }; + }) + .build(); + +export let deleteModel = SlateTool.create(spec, { + name: 'Delete Model', + key: 'delete_model', + description: `Permanently delete a BigQuery ML model from a dataset. This deletes the model resource and cannot be undone.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + datasetId: z.string().describe('Dataset containing the model'), + modelId: z.string().describe('Model ID to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new BigQueryClient({ + token: ctx.auth.token, + projectId: ctx.config.projectId, + location: ctx.config.location + }); + + await client.deleteModel(ctx.input.datasetId, ctx.input.modelId); + + return { + output: { deleted: true }, + message: `Model **${ctx.input.modelId}** deleted from dataset **${ctx.input.datasetId}**.` + }; + }) + .build(); diff --git a/integrations/bigquery/src/tools/table-data.ts b/integrations/bigquery/src/tools/table-data.ts index 8b0bb4c2f3..cd789f1dc4 100644 --- a/integrations/bigquery/src/tools/table-data.ts +++ b/integrations/bigquery/src/tools/table-data.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BigQueryClient } from '../lib/client'; +import { bigQueryServiceError } from '../lib/errors'; import { spec } from '../spec'; export let readTableData = SlateTool.create(spec, { @@ -145,6 +146,16 @@ export let insertRows = SlateTool.create(spec, { let errorCount = insertErrors?.length || 0; let insertedCount = ctx.input.rows.length - errorCount; + if (errorCount > 0 && ctx.input.skipInvalidRows !== true) { + let serviceError = bigQueryServiceError( + `BigQuery rejected ${errorCount} row(s) for ${ctx.input.datasetId}.${ctx.input.tableId}.` + ); + serviceError.data.reason = 'bigquery_insert_rows_failed'; + serviceError.data.insertErrors = insertErrors; + serviceError.data.insertedCount = insertedCount; + throw serviceError; + } + return { output: { insertedCount, diff --git a/integrations/box/README.md b/integrations/box/README.md index 321040f3ad..d7030f6de6 100644 --- a/integrations/box/README.md +++ b/integrations/box/README.md @@ -1,6 +1,6 @@ # Box -Upload, download, copy, move, rename, lock, and delete files and folders in Box. Manage file versions, shared links, and collaborations with configurable roles. Search content by full text, metadata, file type, and date ranges. Create and manage e-signature requests via Box Sign. Apply custom metadata templates to files and folders. Add comments and task assignments to files. Generate documents from templates with merged data. Use Box AI to ask questions about file content, generate text, and extract structured data. Manage enterprise users, groups, retention policies, legal holds, and security classifications. Monitor file, folder, collaboration, comment, shared link, metadata, task, sign request, and document generation events via webhooks. +Upload, download, copy, move, rename, lock, and delete files and folders in Box. Manage file versions, shared links, and collaborations with configurable roles. Search content by full text, metadata, file type, and date ranges. Create and manage e-signature requests via Box Sign. Apply custom metadata templates to files. Add comments and task assignments to files. Create and manage web link bookmarks. List the current Box user or enterprise users when the authenticated account has access. Monitor file, folder, collaboration, comment, shared link, metadata, task, and sign request events via webhooks. ## Tools @@ -8,6 +8,10 @@ Upload, download, copy, move, rename, lock, and delete files and folders in Box. Get a temporary download URL for a Box file. The URL can be used to download the file content directly. +### Download File + +Download a Box file and return the file content as a Slate attachment, with structured output limited to file metadata. + ### Get File Info Retrieve detailed information about a file in Box, including its name, size, owner, timestamps, parent folder, shared links, and version history. diff --git a/integrations/box/package.json b/integrations/box/package.json index 2602d26ec9..0107ab247c 100644 --- a/integrations/box/package.json +++ b/integrations/box/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/box/slate.json b/integrations/box/slate.json index 4763658317..7bae865a79 100644 --- a/integrations/box/slate.json +++ b/integrations/box/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/box", - "description": "Upload, download, copy, move, rename, lock, and delete files and folders in Box. Manage file versions, shared links, and collaborations with configurable roles. Search content by full text, metadata, file type, and date ranges. Create and manage e-signature requests via Box Sign. Apply custom metadata templates to files and folders. Add comments and task assignments to files. Generate documents from templates with merged data. Use Box AI to ask questions about file content, generate text, and extract structured data. Manage enterprise users, groups, retention policies, legal holds, and security classifications. Monitor file, folder, collaboration, comment, shared link, metadata, task, sign request, and document generation events via webhooks.", + "description": "Upload, download, copy, move, rename, lock, and delete files and folders in Box. Manage file versions, shared links, and collaborations with configurable roles. Search content by full text, metadata, file type, and date ranges. Create and manage e-signature requests via Box Sign. Apply custom metadata templates to files. Add comments and task assignments to files. Create and manage web link bookmarks. List the current Box user or enterprise users when the authenticated account has access. Monitor file, folder, collaboration, comment, shared link, metadata, task, and sign request events via webhooks.", "categories": ["apis-and-http-requests", "document-processing"], "skills": [ "upload and download files", @@ -10,9 +10,8 @@ "manage collaborations", "create e-signature requests", "apply metadata to files", - "generate documents from templates", - "AI-powered content analysis", - "manage users and groups" + "manage web link bookmarks", + "list Box users" ], "logoUrl": "https://provider-logos.metorial-cdn.com/box.svg" } diff --git a/integrations/box/src/auth.ts b/integrations/box/src/auth.ts index dde9883c87..30f8de0f76 100644 --- a/integrations/box/src/auth.ts +++ b/integrations/box/src/auth.ts @@ -1,10 +1,16 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { boxApiError, boxServiceError } from './lib/errors'; let authAxios = createAxios({ baseURL: 'https://api.box.com' }); +authAxios.interceptors.response.use( + response => response, + error => Promise.reject(boxApiError(error, 'authentication request')) +); + export let auth = SlateAuth.create() .output( z.object({ @@ -116,7 +122,7 @@ export let auth = SlateAuth.create() handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - throw new Error('No refresh token available'); + throw boxServiceError('No refresh token available'); } let response = await authAxios.post( diff --git a/integrations/box/src/index.ts b/integrations/box/src/index.ts index eda5a998e5..02c7567663 100644 --- a/integrations/box/src/index.ts +++ b/integrations/box/src/index.ts @@ -1,6 +1,7 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + downloadFile, getDownloadUrl, getFileInfo, listFolderItems, @@ -33,6 +34,7 @@ export let provider = Slate.create({ tools: [ getFileInfo, uploadFile, + downloadFile, manageFile, getDownloadUrl, listFolderItems, diff --git a/integrations/box/src/lib/client.ts b/integrations/box/src/lib/client.ts index 5463ee8e20..318e3a2532 100644 --- a/integrations/box/src/lib/client.ts +++ b/integrations/box/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { boxApiError } from './errors'; let api = createAxios({ baseURL: 'https://api.box.com/2.0' @@ -8,6 +9,40 @@ let uploadApi = createAxios({ baseURL: 'https://upload.box.com/api/2.0' }); +let applyBoxApiErrorInterceptor = ( + http: ReturnType, + operation: string +) => { + http.interceptors.response.use( + response => response, + error => Promise.reject(boxApiError(error, operation)) + ); +}; + +let getHeaderValue = (headers: Record, name: string) => { + let value = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return typeof value === 'string' ? value : undefined; +}; + +let toBuffer = (data: unknown) => { + if (Buffer.isBuffer(data)) { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + return Buffer.from(data as any); +}; + +applyBoxApiErrorInterceptor(api, 'request'); +applyBoxApiErrorInterceptor(uploadApi, 'upload request'); + export class Client { private headers: Record; @@ -80,6 +115,36 @@ export class Client { return response.headers?.location || response.request?.responseURL || ''; } + async downloadFile( + fileId: string, + version?: string + ): Promise<{ + file: any; + contentBase64: string; + mimeType: string; + byteLength: number; + }> { + let [file, response] = await Promise.all([ + this.getFileInfo(fileId, ['id', 'name', 'size', 'extension']), + api.get(`/files/${fileId}/content`, { + headers: this.headers, + params: version ? { version } : undefined, + responseType: 'arraybuffer' + }) + ]); + let content = toBuffer(response.data); + let mimeType = + getHeaderValue(response.headers as Record, 'content-type') || + 'application/octet-stream'; + + return { + file, + contentBase64: content.toString('base64'), + mimeType, + byteLength: content.byteLength + }; + } + async lockFile(fileId: string, expiresAt?: string): Promise { let lock: Record = { type: 'lock' }; if (expiresAt) lock.expires_at = expiresAt; diff --git a/integrations/box/src/lib/errors.ts b/integrations/box/src/lib/errors.ts new file mode 100644 index 0000000000..bd1c98dfcb --- /dev/null +++ b/integrations/box/src/lib/errors.ts @@ -0,0 +1,100 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.type); + addDetail(details, value.error); + addDetail(details, value.error_description); + addDetail(details, value.code); + addDetail(details, value.status); + collectDetails(value.context_info, details); + collectDetails(value.details, details); +}; + +let extractBoxMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data ?? (isRecord(error) ? error.data : undefined); + let details: string[] = []; + + collectDetails(data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getBoxErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + let data = isRecord(error.data) ? error.data : undefined; + return response?.status ?? error.status ?? data?.status; +}; + +export let boxServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let boxApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getBoxErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = boxServiceError( + `Box API ${operation} failed: ${statusLabel}${extractBoxMessage(error)}` + ); + serviceError.data.reason = 'box_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/box/src/tools.schema.test.ts b/integrations/box/src/tools.schema.test.ts new file mode 100644 index 0000000000..e715409356 --- /dev/null +++ b/integrations/box/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Box tool input schemas', provider.actions); diff --git a/integrations/box/src/tools/download-file.ts b/integrations/box/src/tools/download-file.ts new file mode 100644 index 0000000000..71d1547b06 --- /dev/null +++ b/integrations/box/src/tools/download-file.ts @@ -0,0 +1,48 @@ +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let downloadFile = SlateTool.create(spec, { + name: 'Download File', + key: 'download_file', + description: `Download a Box file and return the file content as a Slate attachment, with structured output limited to file metadata.`, + tags: { + readOnly: true, + destructive: false + } +}) + .input( + z.object({ + fileId: z.string().describe('The unique ID of the Box file to download'), + version: z.string().optional().describe('Optional file version ID to download') + }) + ) + .output( + z.object({ + fileId: z.string().describe('The downloaded file ID'), + name: z.string().describe('Name of the downloaded file'), + size: z.number().optional().describe('Box-reported file size in bytes'), + byteLength: z.number().describe('Decoded byte length of the returned attachment'), + mimeType: z.string().describe('MIME type of the returned attachment'), + attachmentCount: z.number().describe('Number of attachments returned') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.downloadFile(ctx.input.fileId, ctx.input.version); + let file = result.file; + + return { + output: { + fileId: file.id, + name: file.name, + size: file.size, + byteLength: result.byteLength, + mimeType: result.mimeType, + attachmentCount: 1 + }, + attachments: [createBase64Attachment(result.contentBase64, result.mimeType)], + message: `Downloaded file **${file.name}** (${file.id}) as an attachment.` + }; + }); diff --git a/integrations/box/src/tools/index.ts b/integrations/box/src/tools/index.ts index 9ef45e1431..c144d66658 100644 --- a/integrations/box/src/tools/index.ts +++ b/integrations/box/src/tools/index.ts @@ -1,3 +1,4 @@ +export * from './download-file'; export * from './get-download-url'; export * from './get-file-info'; export * from './list-folder-items'; diff --git a/integrations/box/src/tools/manage-collaboration.ts b/integrations/box/src/tools/manage-collaboration.ts index 55221e30ae..0fb2208707 100644 --- a/integrations/box/src/tools/manage-collaboration.ts +++ b/integrations/box/src/tools/manage-collaboration.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { boxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCollaboration = SlateTool.create(spec, { @@ -102,7 +103,7 @@ export let manageCollaboration = SlateTool.create(spec, { if (action === 'list') { if (!itemType || !itemId) - throw new Error('itemType and itemId are required for list action'); + throw boxServiceError('itemType and itemId are required for list action'); let collabs = itemType === 'file' ? await client.getFileCollaborations(itemId) @@ -122,8 +123,8 @@ export let manageCollaboration = SlateTool.create(spec, { if (action === 'create') { if (!itemType || !itemId) - throw new Error('itemType and itemId are required for create action'); - if (!role) throw new Error('role is required for create action'); + throw boxServiceError('itemType and itemId are required for create action'); + if (!role) throw boxServiceError('role is required for create action'); let accessibleBy: { type: string; id?: string; login?: string } = { type: 'user' }; if (collaboratorGroupId) { @@ -133,7 +134,7 @@ export let manageCollaboration = SlateTool.create(spec, { } else if (collaboratorEmail) { accessibleBy = { type: 'user', login: collaboratorEmail }; } else { - throw new Error( + throw boxServiceError( 'One of collaboratorEmail, collaboratorUserId, or collaboratorGroupId is required' ); } @@ -155,8 +156,9 @@ export let manageCollaboration = SlateTool.create(spec, { } if (action === 'update') { - if (!collaborationId) throw new Error('collaborationId is required for update action'); - if (!role) throw new Error('role is required for update action'); + if (!collaborationId) + throw boxServiceError('collaborationId is required for update action'); + if (!role) throw boxServiceError('role is required for update action'); let collab = await client.updateCollaboration(collaborationId, role); return { output: { @@ -174,7 +176,8 @@ export let manageCollaboration = SlateTool.create(spec, { } // delete - if (!collaborationId) throw new Error('collaborationId is required for delete action'); + if (!collaborationId) + throw boxServiceError('collaborationId is required for delete action'); await client.deleteCollaboration(collaborationId); return { output: { collaborationId, deleted: true }, diff --git a/integrations/box/src/tools/manage-comments.ts b/integrations/box/src/tools/manage-comments.ts index fb87953f66..2f80e7f38e 100644 --- a/integrations/box/src/tools/manage-comments.ts +++ b/integrations/box/src/tools/manage-comments.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { boxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageComments = SlateTool.create(spec, { @@ -51,7 +52,7 @@ export let manageComments = SlateTool.create(spec, { let { action, fileId, commentId, message: msg, taggedMessage } = ctx.input; if (action === 'list') { - if (!fileId) throw new Error('fileId is required for list action'); + if (!fileId) throw boxServiceError('fileId is required for list action'); let comments = await client.getComments(fileId); let mapped = comments.map((c: any) => ({ commentId: c.id, @@ -66,9 +67,9 @@ export let manageComments = SlateTool.create(spec, { } if (action === 'create') { - if (!fileId) throw new Error('fileId is required for create action'); + if (!fileId) throw boxServiceError('fileId is required for create action'); if (!msg && !taggedMessage) - throw new Error('message or taggedMessage is required for create action'); + throw boxServiceError('message or taggedMessage is required for create action'); let comment = await client.addComment(fileId, msg || '', taggedMessage); return { output: { @@ -82,8 +83,8 @@ export let manageComments = SlateTool.create(spec, { } if (action === 'update') { - if (!commentId) throw new Error('commentId is required for update action'); - if (!msg) throw new Error('message is required for update action'); + if (!commentId) throw boxServiceError('commentId is required for update action'); + if (!msg) throw boxServiceError('message is required for update action'); let comment = await client.updateComment(commentId, msg); return { output: { @@ -97,7 +98,7 @@ export let manageComments = SlateTool.create(spec, { } // delete - if (!commentId) throw new Error('commentId is required for delete action'); + if (!commentId) throw boxServiceError('commentId is required for delete action'); await client.deleteComment(commentId); return { output: { commentId, deleted: true }, diff --git a/integrations/box/src/tools/manage-file.ts b/integrations/box/src/tools/manage-file.ts index 34eb39dc7d..9b512cf465 100644 --- a/integrations/box/src/tools/manage-file.ts +++ b/integrations/box/src/tools/manage-file.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { boxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageFile = SlateTool.create(spec, { @@ -51,7 +52,7 @@ export let manageFile = SlateTool.create(spec, { } if (action === 'rename') { - if (!name) throw new Error('Name is required for rename action'); + if (!name) throw boxServiceError('Name is required for rename action'); let file = await client.updateFile(fileId, { name }); return { output: { fileId: file.id, name: file.name, parentFolderId: file.parent?.id }, @@ -60,7 +61,7 @@ export let manageFile = SlateTool.create(spec, { } if (action === 'move') { - if (!parentFolderId) throw new Error('parentFolderId is required for move action'); + if (!parentFolderId) throw boxServiceError('parentFolderId is required for move action'); let updates: Record = { parent: { id: parentFolderId } }; if (name) updates.name = name; let file = await client.updateFile(fileId, updates); @@ -71,7 +72,7 @@ export let manageFile = SlateTool.create(spec, { } if (action === 'copy') { - if (!parentFolderId) throw new Error('parentFolderId is required for copy action'); + if (!parentFolderId) throw boxServiceError('parentFolderId is required for copy action'); let file = await client.copyFile(fileId, parentFolderId, name); return { output: { fileId: file.id, name: file.name, parentFolderId: file.parent?.id }, diff --git a/integrations/box/src/tools/manage-folder.ts b/integrations/box/src/tools/manage-folder.ts index c09eee2269..de0cf3024c 100644 --- a/integrations/box/src/tools/manage-folder.ts +++ b/integrations/box/src/tools/manage-folder.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { boxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageFolder = SlateTool.create(spec, { @@ -48,8 +49,9 @@ export let manageFolder = SlateTool.create(spec, { let { action, folderId, parentFolderId, name, recursive } = ctx.input; if (action === 'create') { - if (!parentFolderId) throw new Error('parentFolderId is required for create action'); - if (!name) throw new Error('name is required for create action'); + if (!parentFolderId) + throw boxServiceError('parentFolderId is required for create action'); + if (!name) throw boxServiceError('name is required for create action'); let folder = await client.createFolder(parentFolderId, name); return { output: { folderId: folder.id, name: folder.name, parentFolderId: folder.parent?.id }, @@ -57,7 +59,7 @@ export let manageFolder = SlateTool.create(spec, { }; } - if (!folderId) throw new Error('folderId is required for this action'); + if (!folderId) throw boxServiceError('folderId is required for this action'); if (action === 'delete') { await client.deleteFolder(folderId, recursive ?? false); @@ -68,7 +70,7 @@ export let manageFolder = SlateTool.create(spec, { } if (action === 'rename') { - if (!name) throw new Error('name is required for rename action'); + if (!name) throw boxServiceError('name is required for rename action'); let folder = await client.updateFolder(folderId, { name }); return { output: { folderId: folder.id, name: folder.name, parentFolderId: folder.parent?.id }, @@ -77,7 +79,7 @@ export let manageFolder = SlateTool.create(spec, { } if (action === 'move') { - if (!parentFolderId) throw new Error('parentFolderId is required for move action'); + if (!parentFolderId) throw boxServiceError('parentFolderId is required for move action'); let updates: Record = { parent: { id: parentFolderId } }; if (name) updates.name = name; let folder = await client.updateFolder(folderId, updates); @@ -88,7 +90,7 @@ export let manageFolder = SlateTool.create(spec, { } // copy - if (!parentFolderId) throw new Error('parentFolderId is required for copy action'); + if (!parentFolderId) throw boxServiceError('parentFolderId is required for copy action'); let folder = await client.copyFolder(folderId, parentFolderId, name); return { output: { folderId: folder.id, name: folder.name, parentFolderId: folder.parent?.id }, diff --git a/integrations/box/src/tools/manage-metadata.ts b/integrations/box/src/tools/manage-metadata.ts index 9fbf179b9c..eb48a2a883 100644 --- a/integrations/box/src/tools/manage-metadata.ts +++ b/integrations/box/src/tools/manage-metadata.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { boxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageMetadata = SlateTool.create(spec, { @@ -118,7 +119,7 @@ export let manageMetadata = SlateTool.create(spec, { let { action, fileId, scope, templateKey, metadata, operations } = ctx.input; if (action === 'list_templates') { - if (!scope) throw new Error('scope is required for list_templates action'); + if (!scope) throw boxServiceError('scope is required for list_templates action'); let templates = await client.getMetadataTemplates(scope); let mapped = templates.map((t: any) => ({ templateKey: t.templateKey, @@ -138,7 +139,7 @@ export let manageMetadata = SlateTool.create(spec, { if (action === 'get_template') { if (!scope || !templateKey) - throw new Error('scope and templateKey are required for get_template action'); + throw boxServiceError('scope and templateKey are required for get_template action'); let tmpl = await client.getMetadataTemplate(scope, templateKey); return { output: { @@ -158,7 +159,7 @@ export let manageMetadata = SlateTool.create(spec, { }; } - if (!fileId) throw new Error('fileId is required for this action'); + if (!fileId) throw boxServiceError('fileId is required for this action'); if (action === 'get_all') { let entries = await client.getAllFileMetadata(fileId); @@ -169,7 +170,7 @@ export let manageMetadata = SlateTool.create(spec, { } if (!scope || !templateKey) - throw new Error('scope and templateKey are required for this action'); + throw boxServiceError('scope and templateKey are required for this action'); if (action === 'get') { let md = await client.getFileMetadata(fileId, scope, templateKey); @@ -180,7 +181,7 @@ export let manageMetadata = SlateTool.create(spec, { } if (action === 'apply') { - if (!metadata) throw new Error('metadata is required for apply action'); + if (!metadata) throw boxServiceError('metadata is required for apply action'); let md = await client.applyFileMetadata(fileId, scope, templateKey, metadata); return { output: { metadata: md }, @@ -189,7 +190,7 @@ export let manageMetadata = SlateTool.create(spec, { } if (action === 'update') { - if (!operations) throw new Error('operations are required for update action'); + if (!operations) throw boxServiceError('operations are required for update action'); let md = await client.updateFileMetadata(fileId, scope, templateKey, operations); return { output: { metadata: md }, diff --git a/integrations/box/src/tools/manage-sign-request.ts b/integrations/box/src/tools/manage-sign-request.ts index 32d86bfb58..2c13323e5a 100644 --- a/integrations/box/src/tools/manage-sign-request.ts +++ b/integrations/box/src/tools/manage-sign-request.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { boxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageSignRequest = SlateTool.create(spec, { @@ -118,7 +119,7 @@ export let manageSignRequest = SlateTool.create(spec, { } if (action === 'get') { - if (!signRequestId) throw new Error('signRequestId is required for get action'); + if (!signRequestId) throw boxServiceError('signRequestId is required for get action'); let sr = await client.getSignRequest(signRequestId); return { output: { @@ -136,7 +137,7 @@ export let manageSignRequest = SlateTool.create(spec, { } if (action === 'cancel') { - if (!signRequestId) throw new Error('signRequestId is required for cancel action'); + if (!signRequestId) throw boxServiceError('signRequestId is required for cancel action'); await client.cancelSignRequest(signRequestId); return { output: { signRequestId, cancelled: true }, @@ -146,10 +147,10 @@ export let manageSignRequest = SlateTool.create(spec, { // create if (!signers || signers.length === 0) - throw new Error('signers are required for create action'); + throw boxServiceError('signers are required for create action'); if (!sourceFileIds || sourceFileIds.length === 0) - throw new Error('sourceFileIds are required for create action'); - if (!parentFolderId) throw new Error('parentFolderId is required for create action'); + throw boxServiceError('sourceFileIds are required for create action'); + if (!parentFolderId) throw boxServiceError('parentFolderId is required for create action'); let sr = await client.createSignRequest({ signers, diff --git a/integrations/box/src/tools/manage-tasks.ts b/integrations/box/src/tools/manage-tasks.ts index bd77453e30..d1be3b1e18 100644 --- a/integrations/box/src/tools/manage-tasks.ts +++ b/integrations/box/src/tools/manage-tasks.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { boxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageTasks = SlateTool.create(spec, { @@ -80,7 +81,7 @@ export let manageTasks = SlateTool.create(spec, { } = ctx.input; if (action === 'list') { - if (!fileId) throw new Error('fileId is required for list action'); + if (!fileId) throw boxServiceError('fileId is required for list action'); let tasks = await client.getFileTasks(fileId); let mapped = tasks.map((t: any) => ({ taskId: t.id, @@ -96,7 +97,7 @@ export let manageTasks = SlateTool.create(spec, { } if (action === 'create') { - if (!fileId) throw new Error('fileId is required for create action'); + if (!fileId) throw boxServiceError('fileId is required for create action'); let task = await client.createTask(fileId, { message: taskMessage, dueAt, @@ -125,7 +126,7 @@ export let manageTasks = SlateTool.create(spec, { } if (action === 'update') { - if (!taskId) throw new Error('taskId is required for update action'); + if (!taskId) throw boxServiceError('taskId is required for update action'); let updates: Record = {}; if (taskMessage !== undefined) updates.message = taskMessage; if (dueAt !== undefined) updates.due_at = dueAt; @@ -142,7 +143,7 @@ export let manageTasks = SlateTool.create(spec, { } // delete - if (!taskId) throw new Error('taskId is required for delete action'); + if (!taskId) throw boxServiceError('taskId is required for delete action'); await client.deleteTask(taskId); return { output: { taskId, deleted: true }, diff --git a/integrations/box/src/tools/manage-web-link.ts b/integrations/box/src/tools/manage-web-link.ts index 664884328b..975a8cff7d 100644 --- a/integrations/box/src/tools/manage-web-link.ts +++ b/integrations/box/src/tools/manage-web-link.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { boxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageWebLink = SlateTool.create(spec, { @@ -45,8 +46,9 @@ export let manageWebLink = SlateTool.create(spec, { let { action, webLinkId, url, parentFolderId, name, description } = ctx.input; if (action === 'create') { - if (!url) throw new Error('url is required for create action'); - if (!parentFolderId) throw new Error('parentFolderId is required for create action'); + if (!url) throw boxServiceError('url is required for create action'); + if (!parentFolderId) + throw boxServiceError('parentFolderId is required for create action'); let wl = await client.createWebLink(url, parentFolderId, name, description); return { output: { @@ -60,7 +62,7 @@ export let manageWebLink = SlateTool.create(spec, { }; } - if (!webLinkId) throw new Error('webLinkId is required for this action'); + if (!webLinkId) throw boxServiceError('webLinkId is required for this action'); if (action === 'get') { let wl = await client.getWebLink(webLinkId); diff --git a/integrations/box/vitest.config.ts b/integrations/box/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/box/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/braintree/package.json b/integrations/braintree/package.json index 4b8533d417..27df804fc4 100644 --- a/integrations/braintree/package.json +++ b/integrations/braintree/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/braintree/src/index.ts b/integrations/braintree/src/index.ts index 54fdc7eb3b..097b71a159 100644 --- a/integrations/braintree/src/index.ts +++ b/integrations/braintree/src/index.ts @@ -4,6 +4,7 @@ import { acceptDispute, addDisputeEvidence, cancelSubscription, + createClientToken, createCustomer, createSubscription, createTransaction, @@ -17,6 +18,9 @@ import { findTransaction, getSettlementReport, refundTransaction, + searchCustomers, + searchDisputes, + searchSubscriptions, searchTransactions, settleTransaction, updateCustomer, @@ -30,6 +34,7 @@ export let provider = Slate.create({ spec, tools: [ createTransaction, + createClientToken, findTransaction, searchTransactions, refundTransaction, @@ -38,12 +43,15 @@ export let provider = Slate.create({ createCustomer, updateCustomer, findCustomer, + searchCustomers, deleteCustomer, createSubscription, findSubscription, updateSubscription, cancelSubscription, + searchSubscriptions, findDispute, + searchDisputes, acceptDispute, addDisputeEvidence, finalizeDispute, diff --git a/integrations/braintree/src/lib/client.ts b/integrations/braintree/src/lib/client.ts index 1408c07ce1..fe088f0f5f 100644 --- a/integrations/braintree/src/lib/client.ts +++ b/integrations/braintree/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { braintreeApiError, braintreeGraphQLError } from './errors'; let GRAPHQL_URLS: Record = { sandbox: 'https://payments.sandbox.braintree-api.com/graphql', @@ -31,18 +32,26 @@ export class BraintreeGraphQLClient { }); } - async query(query: string, variables?: Record): Promise { + async query( + query: string, + variables?: Record, + operation = 'GraphQL request' + ): Promise { let body: Record = { query }; if (variables) { body.variables = variables; } - let response = await this.http.post('', body); - let data = response.data; - if (data.errors && data.errors.length > 0) { - let messages = data.errors.map((e: any) => e.message).join('; '); - throw new Error(`Braintree GraphQL error: ${messages}`); + + try { + let response = await this.http.post('', body); + let data = response.data; + if (Array.isArray(data.errors) && data.errors.length > 0) { + throw braintreeGraphQLError(data.errors, operation, data.extensions?.requestId); + } + return data.data; + } catch (error) { + throw braintreeApiError(error, operation); } - return data.data; } } @@ -69,30 +78,46 @@ export class BraintreeRestClient { } async get(path: string): Promise { - let response = await this.http.get(`${this.merchantPath}${path}`, { - responseType: 'text' - }); - return response.data; + try { + let response = await this.http.get(`${this.merchantPath}${path}`, { + responseType: 'text' + }); + return response.data; + } catch (error) { + throw braintreeApiError(error, `GET ${path}`); + } } async post(path: string, body: string): Promise { - let response = await this.http.post(`${this.merchantPath}${path}`, body, { - responseType: 'text' - }); - return response.data; + try { + let response = await this.http.post(`${this.merchantPath}${path}`, body, { + responseType: 'text' + }); + return response.data; + } catch (error) { + throw braintreeApiError(error, `POST ${path}`); + } } async put(path: string, body: string): Promise { - let response = await this.http.put(`${this.merchantPath}${path}`, body, { - responseType: 'text' - }); - return response.data; + try { + let response = await this.http.put(`${this.merchantPath}${path}`, body, { + responseType: 'text' + }); + return response.data; + } catch (error) { + throw braintreeApiError(error, `PUT ${path}`); + } } async delete(path: string): Promise { - let response = await this.http.delete(`${this.merchantPath}${path}`, { - responseType: 'text' - }); - return response.data; + try { + let response = await this.http.delete(`${this.merchantPath}${path}`, { + responseType: 'text' + }); + return response.data; + } catch (error) { + throw braintreeApiError(error, `DELETE ${path}`); + } } } diff --git a/integrations/braintree/src/lib/errors.ts b/integrations/braintree/src/lib/errors.ts new file mode 100644 index 0000000000..12cf6d51dc --- /dev/null +++ b/integrations/braintree/src/lib/errors.ts @@ -0,0 +1,113 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +type BraintreeGraphQLError = { + message?: string; + path?: Array; + extensions?: { + errorClass?: string; + legacyCode?: string; + inputPath?: Array; + }; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let stripXml = (value: string) => + value + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +let extractBraintreeMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + addDetail(details, data.message); + addDetail(details, data.error); + addDetail(details, data.error_description); + } else if (typeof data === 'string') { + let messageMatches = [...data.matchAll(/([\s\S]*?)<\/message>/g)].map( + match => match[1] + ); + for (let message of messageMatches) { + addDetail(details, message); + } + if (details.length === 0) { + addDetail(details, stripXml(data)); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) return details.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +export let braintreeServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let braintreeApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = braintreeServiceError( + `Braintree API ${operation} failed: ${statusLabel}${extractBraintreeMessage(error)}` + ); + + serviceError.data.reason = 'braintree_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let braintreeGraphQLError = ( + errors: BraintreeGraphQLError[], + operation = 'GraphQL request', + requestId?: string +) => { + let message = errors + .map(error => { + let parts = [error.message || 'Unknown GraphQL error']; + if (error.extensions?.errorClass) parts.push(`class=${error.extensions.errorClass}`); + if (error.extensions?.legacyCode) parts.push(`code=${error.extensions.legacyCode}`); + if (error.extensions?.inputPath) + parts.push(`input=${error.extensions.inputPath.join('.')}`); + if (error.path) parts.push(`path=${error.path.join('.')}`); + return parts.join(' '); + }) + .join(', '); + + let serviceError = braintreeServiceError(`Braintree API ${operation} failed: ${message}`); + serviceError.data.reason = 'braintree_graphql_error'; + serviceError.data.requestId = requestId; + return serviceError; +}; diff --git a/integrations/braintree/src/lib/graphql-queries.ts b/integrations/braintree/src/lib/graphql-queries.ts index 52887eabe2..c3beba5b3e 100644 --- a/integrations/braintree/src/lib/graphql-queries.ts +++ b/integrations/braintree/src/lib/graphql-queries.ts @@ -50,6 +50,14 @@ export let CHARGE_PAYMENT_METHOD = ` } `; +export let CREATE_CLIENT_TOKEN = ` + mutation CreateClientToken($input: CreateClientTokenInput!) { + createClientToken(input: $input) { + clientToken + } + } +`; + export let AUTHORIZE_PAYMENT_METHOD = ` mutation AuthorizePaymentMethod($input: AuthorizePaymentMethodInput!) { authorizePaymentMethod(input: $input) { @@ -258,6 +266,109 @@ export let SEARCH_TRANSACTION = ` } `; +export let SEARCH_CUSTOMERS = ` + query SearchCustomers($input: CustomerSearchInput!, $first: Int, $after: String) { + search { + customers(input: $input, first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + legacyId + company + email + firstName + lastName + phoneNumber + website + createdAt + } + } + } + } + } +`; + +export let SEARCH_DISPUTES = ` + query SearchDisputes($input: DisputeSearchInput!, $first: Int, $after: String) { + search { + disputes(input: $input, first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + legacyId + status + type + amountDisputed { + value + currencyCode + } + receivedDate + replyByDate + transaction { + id + legacyId + } + processorResponse { + reason + reasonCode + reasonDescription + } + createdAt + } + } + } + } + } +`; + +export let SEARCH_SUBSCRIPTIONS = ` + query SearchSubscriptions( + $input: RecurringBillingSubscriptionSearchInput! + $first: Int + $after: String + ) { + search { + recurringBillingSubscriptions(input: $input, first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + legacyId + planId + status + price + balance + paymentMethodId + merchantAccountId + billingDayOfMonth + currentBillingCycle + numberOfBillingCycles + failureCount + daysPastDue + timeline { + firstBillingDate + nextBillingDate + billingPeriodEndDate + paidThroughDate + } + } + } + } + } + } +`; + export let CREATE_CUSTOMER = ` mutation CreateCustomer($input: CreateCustomerInput!) { createCustomer(input: $input) { diff --git a/integrations/braintree/src/lib/webhook.ts b/integrations/braintree/src/lib/webhook.ts index 019fd60b7e..71cf4ea5f2 100644 --- a/integrations/braintree/src/lib/webhook.ts +++ b/integrations/braintree/src/lib/webhook.ts @@ -1,3 +1,4 @@ +import { braintreeServiceError } from './errors'; import { parseXml } from './xml'; /** @@ -21,7 +22,7 @@ export let verifyAndParseWebhook = (params: { }); if (!matchingPair) { - throw new Error('No matching signature found for the configured public key'); + throw braintreeServiceError('No matching signature found for the configured public key'); } let signature = matchingPair.split('|')[1] || ''; @@ -40,7 +41,7 @@ export let verifyAndParseWebhook = (params: { !secureCompare(signature, expectedSignature) && !secureCompare(signature, expectedSignatureWithNewline) ) { - throw new Error('Webhook signature verification failed'); + throw braintreeServiceError('Webhook signature verification failed'); } // Decode and parse payload diff --git a/integrations/braintree/src/tools/create-client-token.ts b/integrations/braintree/src/tools/create-client-token.ts new file mode 100644 index 0000000000..5f715afbce --- /dev/null +++ b/integrations/braintree/src/tools/create-client-token.ts @@ -0,0 +1,89 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BraintreeGraphQLClient } from '../lib/client'; +import { CREATE_CLIENT_TOKEN } from '../lib/graphql-queries'; +import { spec } from '../spec'; + +export let createClientToken = SlateTool.create(spec, { + name: 'Create Client Token', + key: 'create_client_token', + description: `Generates a Braintree client token for initializing client-side Braintree SDKs. Use this when an app needs to tokenize payment methods before vaulting or charging them server-side.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + customerId: z + .string() + .optional() + .describe( + 'Existing customer ID to allow the customer to vault and manage payment methods.' + ), + merchantAccountId: z + .string() + .optional() + .describe('Merchant account ID used to create the client token.'), + version: z + .number() + .int() + .min(1) + .max(3) + .optional() + .describe('Client token version. Defaults to Braintree version 2.'), + domains: z + .array(z.string()) + .optional() + .describe('Allowed domains for services that require domain restrictions.'), + failOnDuplicatePaymentMethodForCustomer: z + .boolean() + .optional() + .describe( + 'When true, prevents storing the same payment method more than once for the customer.' + ), + paymentMethodId: z + .string() + .optional() + .describe('Preferred payment method ID for View/Edit Funding Instrument flows.') + }) + ) + .output( + z.object({ + clientToken: z.string().describe('Base64 encoded client token for Braintree SDKs'), + tokenLength: z.number().describe('Length of the returned token') + }) + ) + .handleInvocation(async ctx => { + let client = new BraintreeGraphQLClient({ + token: ctx.auth.token, + environment: ctx.config.environment + }); + + let clientToken: Record = {}; + if (ctx.input.customerId) clientToken.customerId = ctx.input.customerId; + if (ctx.input.merchantAccountId) + clientToken.merchantAccountId = ctx.input.merchantAccountId; + if (ctx.input.version !== undefined) clientToken.version = ctx.input.version; + if (ctx.input.domains) clientToken.domains = ctx.input.domains; + if (ctx.input.failOnDuplicatePaymentMethodForCustomer !== undefined) { + clientToken.failOnDuplicatePaymentMethodForCustomer = + ctx.input.failOnDuplicatePaymentMethodForCustomer; + } + if (ctx.input.paymentMethodId) clientToken.paymentMethodId = ctx.input.paymentMethodId; + + let result = await client.query( + CREATE_CLIENT_TOKEN, + { input: { clientToken } }, + 'create client token' + ); + let token = result.createClientToken.clientToken; + + return { + output: { + clientToken: token, + tokenLength: token.length + }, + message: `Created Braintree client token (${token.length} characters)` + }; + }) + .build(); diff --git a/integrations/braintree/src/tools/create-transaction.ts b/integrations/braintree/src/tools/create-transaction.ts index cee6efb8ba..b379a258ba 100644 --- a/integrations/braintree/src/tools/create-transaction.ts +++ b/integrations/braintree/src/tools/create-transaction.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BraintreeGraphQLClient } from '../lib/client'; +import { braintreeServiceError } from '../lib/errors'; import { AUTHORIZE_PAYMENT_METHOD, CHARGE_PAYMENT_METHOD } from '../lib/graphql-queries'; import { spec } from '../spec'; @@ -40,8 +41,12 @@ Requires a payment method ID (from the Braintree vault or a single-use nonce) an .string() .optional() .describe( - 'ISO 4217 currency code (e.g. "USD"). Defaults to merchant default currency.' + 'Deprecated. Braintree determines currency from merchantAccountId; provide merchantAccountId instead.' ), + apiRequestKey: z + .string() + .optional() + .describe('Optional idempotency key to prevent duplicate transaction creation.'), type: z .enum(['sale', 'authorize']) .default('sale') @@ -71,6 +76,12 @@ Requires a payment method ID (from the Braintree vault or a single-use nonce) an ) .output(transactionOutputSchema) .handleInvocation(async ctx => { + if (ctx.input.currencyCode) { + throw braintreeServiceError( + 'Braintree does not accept currencyCode directly when creating transactions. Set merchantAccountId for the account configured with the desired currency.' + ); + } + let client = new BraintreeGraphQLClient({ token: ctx.auth.token, environment: ctx.config.environment @@ -85,18 +96,33 @@ Requires a payment method ID (from the Braintree vault or a single-use nonce) an if (ctx.input.merchantAccountId) transactionInput.merchantAccountId = ctx.input.merchantAccountId; if (ctx.input.customerId) transactionInput.customerId = ctx.input.customerId; + if (ctx.input.lineItems) transactionInput.lineItems = ctx.input.lineItems; + + let input: Record = { + paymentMethodId: ctx.input.paymentMethodId, + transaction: transactionInput + }; + if (ctx.input.apiRequestKey) input.apiRequestKey = ctx.input.apiRequestKey; let transaction: any; if (ctx.input.type === 'authorize') { - let result = await client.query(AUTHORIZE_PAYMENT_METHOD, { - input: { paymentMethodId: ctx.input.paymentMethodId, transaction: transactionInput } - }); + let result = await client.query( + AUTHORIZE_PAYMENT_METHOD, + { + input + }, + 'authorize payment method' + ); transaction = result.authorizePaymentMethod.transaction; } else { - let result = await client.query(CHARGE_PAYMENT_METHOD, { - input: { paymentMethodId: ctx.input.paymentMethodId, transaction: transactionInput } - }); + let result = await client.query( + CHARGE_PAYMENT_METHOD, + { + input + }, + 'charge payment method' + ); transaction = result.chargePaymentMethod.transaction; } @@ -105,7 +131,7 @@ Requires a payment method ID (from the Braintree vault or a single-use nonce) an legacyId: transaction.legacyId, status: transaction.status, amount: transaction.amount?.value || ctx.input.amount, - currencyCode: transaction.amount?.currencyCode || ctx.input.currencyCode || 'USD', + currencyCode: transaction.amount?.currencyCode || 'USD', merchantAccountId: transaction.merchantAccountId, orderId: transaction.orderId, customerEmail: transaction.customer?.email, diff --git a/integrations/braintree/src/tools/index.ts b/integrations/braintree/src/tools/index.ts index 6c08020763..d84c02713a 100644 --- a/integrations/braintree/src/tools/index.ts +++ b/integrations/braintree/src/tools/index.ts @@ -1,3 +1,4 @@ +export * from './create-client-token'; export * from './create-transaction'; export * from './find-transaction'; export * from './manage-customer'; @@ -5,6 +6,9 @@ export * from './manage-dispute'; export * from './manage-payment-method'; export * from './manage-subscription'; export * from './refund-transaction'; +export * from './search-customers'; +export * from './search-disputes'; +export * from './search-subscriptions'; export * from './search-transactions'; export * from './settle-transaction'; export * from './settlement-report'; diff --git a/integrations/braintree/src/tools/refund-transaction.ts b/integrations/braintree/src/tools/refund-transaction.ts index 657ca8af3c..463e03a296 100644 --- a/integrations/braintree/src/tools/refund-transaction.ts +++ b/integrations/braintree/src/tools/refund-transaction.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BraintreeGraphQLClient } from '../lib/client'; +import { braintreeServiceError } from '../lib/errors'; import { REFUND_TRANSACTION, REVERSE_TRANSACTION } from '../lib/graphql-queries'; import { spec } from '../spec'; @@ -30,7 +31,11 @@ Use "refund" for settled transactions and "reverse" to automatically void or ref .string() .optional() .describe('Amount to refund. Omit for full refund. Only applies to mode "refund".'), - orderId: z.string().optional().describe('Order ID to associate with the refund') + orderId: z.string().optional().describe('Order ID to associate with the refund'), + apiRequestKey: z + .string() + .optional() + .describe('Optional idempotency key to prevent duplicate refund or reversal creation.') }) ) .output( @@ -44,6 +49,12 @@ Use "refund" for settled transactions and "reverse" to automatically void or ref }) ) .handleInvocation(async ctx => { + if (ctx.input.mode === 'reverse' && (ctx.input.amount || ctx.input.orderId)) { + throw braintreeServiceError( + 'amount and orderId only apply to mode "refund"; remove them when using mode "reverse".' + ); + } + let client = new BraintreeGraphQLClient({ token: ctx.auth.token, environment: ctx.config.environment @@ -53,7 +64,8 @@ Use "refund" for settled transactions and "reverse" to automatically void or ref let input: Record = { transactionId: ctx.input.transactionId }; - let result = await client.query(REVERSE_TRANSACTION, { input }); + if (ctx.input.apiRequestKey) input.apiRequestKey = ctx.input.apiRequestKey; + let result = await client.query(REVERSE_TRANSACTION, { input }, 'reverse transaction'); let reversal = result.reverseTransaction.reversal; return { @@ -72,6 +84,7 @@ Use "refund" for settled transactions and "reverse" to automatically void or ref let input: Record = { transactionId: ctx.input.transactionId }; + if (ctx.input.apiRequestKey) input.apiRequestKey = ctx.input.apiRequestKey; if (ctx.input.amount) { input.refund = { amount: ctx.input.amount }; } @@ -79,7 +92,7 @@ Use "refund" for settled transactions and "reverse" to automatically void or ref input.refund = { ...input.refund, orderId: ctx.input.orderId }; } - let result = await client.query(REFUND_TRANSACTION, { input }); + let result = await client.query(REFUND_TRANSACTION, { input }, 'refund transaction'); let refund = result.refundTransaction.refund; return { diff --git a/integrations/braintree/src/tools/search-customers.ts b/integrations/braintree/src/tools/search-customers.ts new file mode 100644 index 0000000000..6c820047ef --- /dev/null +++ b/integrations/braintree/src/tools/search-customers.ts @@ -0,0 +1,120 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BraintreeGraphQLClient } from '../lib/client'; +import { SEARCH_CUSTOMERS } from '../lib/graphql-queries'; +import { spec } from '../spec'; + +let textMatchMode = z + .enum(['is', 'contains', 'startsWith', 'endsWith']) + .default('is') + .describe('How text fields should be matched.'); + +let dateRangeSchema = z.object({ + after: z.string().optional().describe('Inclusive lower bound as an ISO timestamp'), + before: z.string().optional().describe('Exclusive upper bound as an ISO timestamp') +}); + +let textFilter = (value: string | undefined, mode: z.infer) => + value ? { [mode]: value } : undefined; + +let dateRangeFilter = (range?: z.infer) => { + if (!range) return undefined; + let filter: Record = {}; + if (range.after) filter.greaterThanOrEqualTo = range.after; + if (range.before) filter.lessThanOrEqualTo = range.before; + return Object.keys(filter).length > 0 ? filter : undefined; +}; + +export let searchCustomers = SlateTool.create(spec, { + name: 'Search Customers', + key: 'search_customers', + description: `Searches Braintree customers by ID, email, name, company, phone, or created date. Use this before finding, updating, or deleting a customer when only partial customer details are known.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + customerId: z.string().optional().describe('Exact customer ID to find'), + email: z.string().optional().describe('Customer email filter'), + firstName: z.string().optional().describe('Customer first name filter'), + lastName: z.string().optional().describe('Customer last name filter'), + company: z.string().optional().describe('Customer company filter'), + phoneNumber: z.string().optional().describe('Customer phone number filter'), + textMatchMode, + createdAt: dateRangeSchema.optional().describe('Created-at timestamp range'), + first: z.number().int().min(1).max(50).default(20).describe('Results to return'), + after: z.string().optional().describe('Pagination cursor') + }) + ) + .output( + z.object({ + customers: z.array( + z.object({ + customerId: z.string().describe('Legacy customer ID usable with REST tools'), + graphQLId: z.string().describe('GraphQL customer ID'), + email: z.string().optional().nullable(), + firstName: z.string().optional().nullable(), + lastName: z.string().optional().nullable(), + company: z.string().optional().nullable(), + phone: z.string().optional().nullable(), + website: z.string().optional().nullable(), + createdAt: z.string().optional().nullable() + }) + ), + hasNextPage: z.boolean().describe('Whether more results are available'), + endCursor: z.string().optional().nullable().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new BraintreeGraphQLClient({ + token: ctx.auth.token, + environment: ctx.config.environment + }); + + let input: Record = {}; + if (ctx.input.customerId) input.id = { is: ctx.input.customerId }; + if (ctx.input.email) input.email = textFilter(ctx.input.email, ctx.input.textMatchMode); + if (ctx.input.firstName) + input.firstName = textFilter(ctx.input.firstName, ctx.input.textMatchMode); + if (ctx.input.lastName) + input.lastName = textFilter(ctx.input.lastName, ctx.input.textMatchMode); + if (ctx.input.company) + input.company = textFilter(ctx.input.company, ctx.input.textMatchMode); + if (ctx.input.phoneNumber) + input.phoneNumber = textFilter(ctx.input.phoneNumber, ctx.input.textMatchMode); + let createdAt = dateRangeFilter(ctx.input.createdAt); + if (createdAt) input.createdAt = createdAt; + + let result = await client.query( + SEARCH_CUSTOMERS, + { + input, + first: ctx.input.first, + after: ctx.input.after || null + }, + 'search customers' + ); + let connection = result.search.customers; + let customers = (connection.edges || []).map((edge: any) => ({ + customerId: edge.node.legacyId || edge.node.id, + graphQLId: edge.node.id, + email: edge.node.email, + firstName: edge.node.firstName, + lastName: edge.node.lastName, + company: edge.node.company, + phone: edge.node.phoneNumber, + website: edge.node.website, + createdAt: edge.node.createdAt + })); + + return { + output: { + customers, + hasNextPage: connection.pageInfo?.hasNextPage || false, + endCursor: connection.pageInfo?.endCursor + }, + message: `Found **${customers.length}** Braintree customer(s)${connection.pageInfo?.hasNextPage ? ' (more available)' : ''}` + }; + }) + .build(); diff --git a/integrations/braintree/src/tools/search-disputes.ts b/integrations/braintree/src/tools/search-disputes.ts new file mode 100644 index 0000000000..3c5769fae3 --- /dev/null +++ b/integrations/braintree/src/tools/search-disputes.ts @@ -0,0 +1,122 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BraintreeGraphQLClient } from '../lib/client'; +import { SEARCH_DISPUTES } from '../lib/graphql-queries'; +import { spec } from '../spec'; + +let dateRangeSchema = z.object({ + after: z.string().optional().describe('Inclusive lower bound as YYYY-MM-DD'), + before: z.string().optional().describe('Inclusive upper bound as YYYY-MM-DD') +}); + +let dateRangeFilter = (range?: z.infer) => { + if (!range) return undefined; + let filter: Record = {}; + if (range.after) filter.greaterThanOrEqualTo = range.after; + if (range.before) filter.lessThanOrEqualTo = range.before; + return Object.keys(filter).length > 0 ? filter : undefined; +}; + +export let searchDisputes = SlateTool.create(spec, { + name: 'Search Disputes', + key: 'search_disputes', + description: `Searches Braintree disputes by ID, status, type, reason, transaction, customer, or deadline dates. Use this to discover open disputes before adding evidence, accepting, or finalizing.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + disputeId: z.string().optional().describe('Exact dispute ID to find'), + statuses: z.array(z.string()).optional().describe('Dispute statuses to include'), + type: z.string().optional().describe('Dispute type, such as CHARGEBACK or RETRIEVAL'), + reason: z.string().optional().describe('Dispute reason enum value'), + transactionId: z.string().optional().describe('Associated transaction ID'), + customerId: z.string().optional().describe('Associated customer ID'), + receivedDate: dateRangeSchema.optional().describe('Received-date range'), + replyByDate: dateRangeSchema.optional().describe('Reply-by date range'), + first: z.number().int().min(1).max(50).default(20).describe('Results to return'), + after: z.string().optional().describe('Pagination cursor') + }) + ) + .output( + z.object({ + disputes: z.array( + z.object({ + disputeId: z.string().describe('Legacy dispute ID'), + graphQLId: z.string().describe('GraphQL dispute ID'), + status: z.string().optional().nullable(), + type: z.string().optional().nullable(), + reason: z.string().optional().nullable(), + reasonCode: z.string().optional().nullable(), + reasonDescription: z.string().optional().nullable(), + amount: z.string().optional().nullable(), + currencyCode: z.string().optional().nullable(), + receivedDate: z.string().optional().nullable(), + replyByDate: z.string().optional().nullable(), + transactionId: z.string().optional().nullable(), + createdAt: z.string().optional().nullable() + }) + ), + hasNextPage: z.boolean().describe('Whether more results are available'), + endCursor: z.string().optional().nullable().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new BraintreeGraphQLClient({ + token: ctx.auth.token, + environment: ctx.config.environment + }); + + let input: Record = {}; + if (ctx.input.disputeId) input.id = { is: ctx.input.disputeId }; + if (ctx.input.statuses?.length) input.status = { in: ctx.input.statuses }; + if (ctx.input.type) input.type = { is: ctx.input.type }; + if (ctx.input.reason) input.reason = { in: [ctx.input.reason] }; + if (ctx.input.transactionId || ctx.input.customerId) { + input.transaction = {}; + if (ctx.input.transactionId) + input.transaction.transactionId = { is: ctx.input.transactionId }; + if (ctx.input.customerId) input.transaction.customerId = { is: ctx.input.customerId }; + } + let receivedDate = dateRangeFilter(ctx.input.receivedDate); + if (receivedDate) input.receivedDate = receivedDate; + let replyByDate = dateRangeFilter(ctx.input.replyByDate); + if (replyByDate) input.replyByDate = replyByDate; + + let result = await client.query( + SEARCH_DISPUTES, + { + input, + first: ctx.input.first, + after: ctx.input.after || null + }, + 'search disputes' + ); + let connection = result.search.disputes; + let disputes = (connection.edges || []).map((edge: any) => ({ + disputeId: edge.node.legacyId || edge.node.id, + graphQLId: edge.node.id, + status: edge.node.status, + type: edge.node.type, + reason: edge.node.processorResponse?.reason, + reasonCode: edge.node.processorResponse?.reasonCode, + reasonDescription: edge.node.processorResponse?.reasonDescription, + amount: edge.node.amountDisputed?.value, + currencyCode: edge.node.amountDisputed?.currencyCode, + receivedDate: edge.node.receivedDate, + replyByDate: edge.node.replyByDate, + transactionId: edge.node.transaction?.legacyId || edge.node.transaction?.id, + createdAt: edge.node.createdAt + })); + + return { + output: { + disputes, + hasNextPage: connection.pageInfo?.hasNextPage || false, + endCursor: connection.pageInfo?.endCursor + }, + message: `Found **${disputes.length}** Braintree dispute(s)${connection.pageInfo?.hasNextPage ? ' (more available)' : ''}` + }; + }) + .build(); diff --git a/integrations/braintree/src/tools/search-subscriptions.ts b/integrations/braintree/src/tools/search-subscriptions.ts new file mode 100644 index 0000000000..8942947535 --- /dev/null +++ b/integrations/braintree/src/tools/search-subscriptions.ts @@ -0,0 +1,128 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BraintreeGraphQLClient } from '../lib/client'; +import { SEARCH_SUBSCRIPTIONS } from '../lib/graphql-queries'; +import { spec } from '../spec'; + +let dateRangeSchema = z.object({ + after: z.string().optional().describe('Inclusive lower bound'), + before: z.string().optional().describe('Upper bound') +}); + +let dateRangeFilter = (range?: z.infer) => { + if (!range) return undefined; + let filter: Record = {}; + if (range.after) filter.greaterThanOrEqualTo = range.after; + if (range.before) filter.lessThanOrEqualTo = range.before; + return Object.keys(filter).length > 0 ? filter : undefined; +}; + +export let searchSubscriptions = SlateTool.create(spec, { + name: 'Search Subscriptions', + key: 'search_subscriptions', + description: `Searches Braintree recurring billing subscriptions by ID, plan, status, merchant account, transaction, trial state, and billing dates. Use this before finding, updating, retrying, or canceling subscriptions.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + subscriptionId: z.string().optional().describe('Exact subscription ID to find'), + planId: z.string().optional().describe('Plan ID filter'), + statuses: z.array(z.string()).optional().describe('Subscription statuses to include'), + merchantAccountId: z.string().optional().describe('Merchant account ID filter'), + inTrialPeriod: z.boolean().optional().describe('Whether the subscription is in trial'), + transactionId: z.string().optional().describe('Associated transaction ID'), + createdAt: dateRangeSchema.optional().describe('Created-at timestamp range'), + nextBillingDate: dateRangeSchema.optional().describe('Next billing date range'), + first: z.number().int().min(1).max(50).default(20).describe('Results to return'), + after: z.string().optional().describe('Pagination cursor') + }) + ) + .output( + z.object({ + subscriptions: z.array( + z.object({ + subscriptionId: z.string().describe('Legacy subscription ID'), + graphQLId: z.string().describe('GraphQL subscription ID'), + planId: z.string().optional().nullable(), + status: z.string().optional().nullable(), + paymentMethodId: z.string().optional().nullable(), + merchantAccountId: z.string().optional().nullable(), + price: z.string().optional().nullable(), + currencyCode: z.string().optional().nullable(), + balance: z.string().optional().nullable(), + billingDayOfMonth: z.number().optional().nullable(), + currentBillingCycle: z.number().optional().nullable(), + numberOfBillingCycles: z.number().optional().nullable(), + failureCount: z.number().optional().nullable(), + daysPastDue: z.number().optional().nullable(), + firstBillingDate: z.string().optional().nullable(), + nextBillingDate: z.string().optional().nullable(), + billingPeriodEndDate: z.string().optional().nullable(), + paidThroughDate: z.string().optional().nullable() + }) + ), + hasNextPage: z.boolean().describe('Whether more results are available'), + endCursor: z.string().optional().nullable().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new BraintreeGraphQLClient({ + token: ctx.auth.token, + environment: ctx.config.environment + }); + + let input: Record = {}; + if (ctx.input.subscriptionId) input.id = { is: ctx.input.subscriptionId }; + if (ctx.input.planId) input.planId = { is: ctx.input.planId }; + if (ctx.input.statuses?.length) input.status = ctx.input.statuses; + if (ctx.input.merchantAccountId) + input.merchantAccountId = { is: ctx.input.merchantAccountId }; + if (ctx.input.inTrialPeriod !== undefined) input.inTrialPeriod = ctx.input.inTrialPeriod; + if (ctx.input.transactionId) input.transactionId = { is: ctx.input.transactionId }; + let createdAt = dateRangeFilter(ctx.input.createdAt); + if (createdAt) input.createdAt = createdAt; + let nextBillingDate = dateRangeFilter(ctx.input.nextBillingDate); + if (nextBillingDate) input.nextBillingDate = nextBillingDate; + + let result = await client.query( + SEARCH_SUBSCRIPTIONS, + { + input, + first: ctx.input.first, + after: ctx.input.after || null + }, + 'search subscriptions' + ); + let connection = result.search.recurringBillingSubscriptions; + let subscriptions = (connection.edges || []).map((edge: any) => ({ + subscriptionId: edge.node.legacyId || edge.node.id, + graphQLId: edge.node.id, + planId: edge.node.planId, + status: edge.node.status, + paymentMethodId: edge.node.paymentMethodId, + merchantAccountId: edge.node.merchantAccountId, + price: edge.node.price, + balance: edge.node.balance, + billingDayOfMonth: edge.node.billingDayOfMonth, + currentBillingCycle: edge.node.currentBillingCycle, + numberOfBillingCycles: edge.node.numberOfBillingCycles, + failureCount: edge.node.failureCount, + daysPastDue: edge.node.daysPastDue, + firstBillingDate: edge.node.timeline?.firstBillingDate, + nextBillingDate: edge.node.timeline?.nextBillingDate, + billingPeriodEndDate: edge.node.timeline?.billingPeriodEndDate, + paidThroughDate: edge.node.timeline?.paidThroughDate + })); + + return { + output: { + subscriptions, + hasNextPage: connection.pageInfo?.hasNextPage || false, + endCursor: connection.pageInfo?.endCursor + }, + message: `Found **${subscriptions.length}** Braintree subscription(s)${connection.pageInfo?.hasNextPage ? ' (more available)' : ''}` + }; + }) + .build(); diff --git a/integrations/braze/package.json b/integrations/braze/package.json index c79ca65fb5..b1d2da4491 100644 --- a/integrations/braze/package.json +++ b/integrations/braze/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/braze/src/index.ts b/integrations/braze/src/index.ts index 76fba88823..bcfea2745b 100644 --- a/integrations/braze/src/index.ts +++ b/integrations/braze/src/index.ts @@ -5,9 +5,12 @@ import { exportUsers, getCampaignAnalytics, getCampaignDetails, + getCanvasAnalytics, getCanvasDetails, getCustomEventAnalytics, getKpiAnalytics, + getPurchaseAnalytics, + getSegmentAnalytics, getSegmentDetails, getSubscriptionStatus, listCampaigns, @@ -15,9 +18,12 @@ import { listCatalogs, listSegments, manageCatalogItems, + manageCatalogs, manageContentBlocks, manageEmailList, manageEmailTemplates, + manageSmsInvalidPhoneNumbers, + manageUserIdentity, mergeUsers, scheduleMessage, sendMessage, @@ -38,22 +44,28 @@ export let provider = Slate.create({ exportUsers, deleteUsers, mergeUsers, + manageUserIdentity, listCampaigns, getCampaignDetails, getCampaignAnalytics, listCanvases, getCanvasDetails, + getCanvasAnalytics, listSegments, getSegmentDetails, + getSegmentAnalytics, updateSubscriptionStatus, getSubscriptionStatus, + manageCatalogs, listCatalogs, manageCatalogItems, manageEmailList, + manageSmsInvalidPhoneNumbers, manageEmailTemplates, manageContentBlocks, getKpiAnalytics, getCustomEventAnalytics, + getPurchaseAnalytics, scheduleMessage ], triggers: [inboundWebhook, campaignActivity, emailBlocklistActivity] diff --git a/integrations/braze/src/lib/client.ts b/integrations/braze/src/lib/client.ts index 47e30d8403..ba3f5de14e 100644 --- a/integrations/braze/src/lib/client.ts +++ b/integrations/braze/src/lib/client.ts @@ -1,4 +1,12 @@ import { createAxios } from 'slates'; +import { brazeApiError } from './errors'; + +type UserAlias = { aliasName: string; aliasLabel: string }; + +let mapUserAlias = (alias: UserAlias) => ({ + alias_name: alias.aliasName, + alias_label: alias.aliasLabel +}); export class BrazeClient { private axios; @@ -12,6 +20,11 @@ export class BrazeClient { 'Content-Type': 'application/json' } }); + + this.axios.interceptors.response.use( + response => response, + error => Promise.reject(brazeApiError(error)) + ); } // ─── User Data ─────────────────────────────────────────────── @@ -28,35 +41,55 @@ export class BrazeClient { async deleteUsers(params: { externalIds?: string[]; brazeIds?: string[]; - userAliases?: { aliasName: string; aliasLabel: string }[]; + userAliases?: UserAlias[]; }) { let body: Record = {}; if (params.externalIds) body.external_ids = params.externalIds; if (params.brazeIds) body.braze_ids = params.brazeIds; - if (params.userAliases) - body.user_aliases = params.userAliases.map(a => ({ - alias_name: a.aliasName, - alias_label: a.aliasLabel - })); + if (params.userAliases) body.user_aliases = params.userAliases.map(mapUserAlias); let resp = await this.axios.post('/users/delete', body); return resp.data; } - async identifyUsers( - aliases: { + async identifyUsers(params: { + aliasesToIdentify?: { externalId: string; - userAlias: { aliasName: string; aliasLabel: string }; - }[] - ) { - let resp = await this.axios.post('/users/identify', { - aliases_to_identify: aliases.map(a => ({ + userAlias: UserAlias; + }[]; + emailsToIdentify?: { + externalId: string; + email: string; + prioritization: string[]; + }[]; + phoneNumbersToIdentify?: { + externalId: string; + phone: string; + prioritization: string[]; + }[]; + }) { + let body: Record = {}; + if (params.aliasesToIdentify) { + body.aliases_to_identify = params.aliasesToIdentify.map(a => ({ external_id: a.externalId, - user_alias: { - alias_name: a.userAlias.aliasName, - alias_label: a.userAlias.aliasLabel - } - })) - }); + user_alias: mapUserAlias(a.userAlias) + })); + } + if (params.emailsToIdentify) { + body.emails_to_identify = params.emailsToIdentify.map(e => ({ + external_id: e.externalId, + email: e.email, + prioritization: e.prioritization + })); + } + if (params.phoneNumbersToIdentify) { + body.phone_numbers_to_identify = params.phoneNumbersToIdentify.map(p => ({ + external_id: p.externalId, + phone: p.phone, + prioritization: p.prioritization + })); + } + + let resp = await this.axios.post('/users/identify', body); return resp.data; } @@ -94,22 +127,20 @@ export class BrazeClient { async exportUsersByIds(params: { externalIds?: string[]; - brazeIds?: string[]; - userAliases?: { aliasName: string; aliasLabel: string }[]; - emails?: string[]; - phones?: string[]; + brazeId?: string; + deviceId?: string; + userAliases?: UserAlias[]; + email?: string; + phone?: string; fieldsToExport?: string[]; }) { let body: Record = {}; if (params.externalIds) body.external_ids = params.externalIds; - if (params.brazeIds) body.braze_ids = params.brazeIds; - if (params.userAliases) - body.user_aliases = params.userAliases.map(a => ({ - alias_name: a.aliasName, - alias_label: a.aliasLabel - })); - if (params.emails) body.email_address = params.emails; - if (params.phones) body.phone = params.phones; + if (params.brazeId) body.braze_id = params.brazeId; + if (params.deviceId) body.device_id = params.deviceId; + if (params.userAliases) body.user_aliases = params.userAliases.map(mapUserAlias); + if (params.email) body.email_address = params.email; + if (params.phone) body.phone = params.phone; if (params.fieldsToExport) body.fields_to_export = params.fieldsToExport; let resp = await this.axios.post('/users/export/ids', body); return resp.data; @@ -126,14 +157,28 @@ export class BrazeClient { async sendMessages(params: { externalUserIds?: string[]; + userAliases?: UserAlias[]; segmentId?: string; + audience?: Record; broadcast?: boolean; + campaignId?: string; + sendId?: string; + overrideFrequencyCapping?: boolean; + recipientSubscriptionState?: string; messages?: Record; - overrideMessageSettings?: Record; }) { let body: Record = {}; if (params.externalUserIds) body.external_user_ids = params.externalUserIds; + if (params.userAliases) body.user_aliases = params.userAliases.map(mapUserAlias); + if (params.segmentId) body.segment_id = params.segmentId; + if (params.audience) body.audience = params.audience; if (params.broadcast !== undefined) body.broadcast = params.broadcast; + if (params.campaignId) body.campaign_id = params.campaignId; + if (params.sendId) body.send_id = params.sendId; + if (params.overrideFrequencyCapping !== undefined) + body.override_frequency_capping = params.overrideFrequencyCapping; + if (params.recipientSubscriptionState) + body.recipient_subscription_state = params.recipientSubscriptionState; if (params.messages) body.messages = params.messages; let resp = await this.axios.post('/messages/send', body); return resp.data; @@ -143,6 +188,7 @@ export class BrazeClient { campaignId: string; recipients?: { externalUserId?: string; + userAlias?: UserAlias; triggerProperties?: Record; sendToExistingOnly?: boolean; attributes?: Record; @@ -159,6 +205,7 @@ export class BrazeClient { body.recipients = params.recipients.map(r => { let rec: Record = {}; if (r.externalUserId) rec.external_user_id = r.externalUserId; + if (r.userAlias) rec.user_alias = mapUserAlias(r.userAlias); if (r.triggerProperties) rec.trigger_properties = r.triggerProperties; if (r.sendToExistingOnly !== undefined) rec.send_to_existing_only = r.sendToExistingOnly; @@ -174,6 +221,7 @@ export class BrazeClient { canvasId: string; recipients?: { externalUserId?: string; + userAlias?: UserAlias; canvasEntryProperties?: Record; sendToExistingOnly?: boolean; }[]; @@ -190,6 +238,7 @@ export class BrazeClient { body.recipients = params.recipients.map(r => { let rec: Record = {}; if (r.externalUserId) rec.external_user_id = r.externalUserId; + if (r.userAlias) rec.user_alias = mapUserAlias(r.userAlias); if (r.canvasEntryProperties) rec.canvas_entry_properties = r.canvasEntryProperties; if (r.sendToExistingOnly !== undefined) rec.send_to_existing_only = r.sendToExistingOnly; @@ -205,6 +254,13 @@ export class BrazeClient { async scheduleMessage(params: { broadcast?: boolean; externalUserIds?: string[]; + userAliases?: UserAlias[]; + segmentId?: string; + audience?: Record; + campaignId?: string; + sendId?: string; + overrideFrequencyCapping?: boolean; + recipientSubscriptionState?: string; messages?: Record; schedule: { time: string; inLocalTime?: boolean }; }) { @@ -215,6 +271,15 @@ export class BrazeClient { body.schedule.in_local_time = params.schedule.inLocalTime; if (params.broadcast !== undefined) body.broadcast = params.broadcast; if (params.externalUserIds) body.external_user_ids = params.externalUserIds; + if (params.userAliases) body.user_aliases = params.userAliases.map(mapUserAlias); + if (params.segmentId) body.segment_id = params.segmentId; + if (params.audience) body.audience = params.audience; + if (params.campaignId) body.campaign_id = params.campaignId; + if (params.sendId) body.send_id = params.sendId; + if (params.overrideFrequencyCapping !== undefined) + body.override_frequency_capping = params.overrideFrequencyCapping; + if (params.recipientSubscriptionState) + body.recipient_subscription_state = params.recipientSubscriptionState; if (params.messages) body.messages = params.messages; let resp = await this.axios.post('/messages/schedule/create', body); return resp.data; @@ -435,6 +500,11 @@ export class BrazeClient { return resp.data; } + async blocklistEmails(emails: string[]) { + let resp = await this.axios.post('/email/blocklist', { email: emails }); + return resp.data; + } + async setEmailSubscriptionStatus(email: string, subscriptionState: string) { let resp = await this.axios.post('/email/status', { email, diff --git a/integrations/braze/src/lib/errors.ts b/integrations/braze/src/lib/errors.ts new file mode 100644 index 0000000000..667c594160 --- /dev/null +++ b/integrations/braze/src/lib/errors.ts @@ -0,0 +1,110 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.error); + addDetail(details, value.type); + collectDetails(value.errors, details); +}; + +let extractBrazeMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let brazeServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let brazeApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = brazeServiceError( + `Braze API ${operation} failed: ${statusLabelFor(response)}${extractBrazeMessage(error)}` + ); + serviceError.data.reason = 'braze_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let requireBrazeString = (value: unknown, label: string, action?: string) => { + if (typeof value === 'string' && value.trim()) { + return value; + } + + throw brazeServiceError(`${label} is required${action ? ` for "${action}"` : ''}.`); +}; + +export let requireBrazeNumber = (value: unknown, label: string, action?: string) => { + if (typeof value === 'number') { + return value; + } + + throw brazeServiceError(`${label} is required${action ? ` for "${action}"` : ''}.`); +}; + +export let requireBrazeArray = (value: T[] | undefined, label: string, action?: string) => { + if (Array.isArray(value) && value.length > 0) { + return value; + } + + throw brazeServiceError( + `${label} must contain at least one item${action ? ` for "${action}"` : ''}.` + ); +}; diff --git a/integrations/braze/src/tools.schema.test.ts b/integrations/braze/src/tools.schema.test.ts new file mode 100644 index 0000000000..a9b3100952 --- /dev/null +++ b/integrations/braze/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Braze tool input schemas', provider.actions); diff --git a/integrations/braze/src/tools/export-users.ts b/integrations/braze/src/tools/export-users.ts index 4c658f7b1c..388892aab0 100644 --- a/integrations/braze/src/tools/export-users.ts +++ b/integrations/braze/src/tools/export-users.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError } from '../lib/errors'; import { spec } from '../spec'; export let exportUsers = SlateTool.create(spec, { @@ -23,9 +24,10 @@ export let exportUsers = SlateTool.create(spec, { .input( z.object({ externalIds: z.array(z.string()).optional().describe('External user IDs to export'), - brazeIds: z.array(z.string()).optional().describe('Braze internal user IDs to export'), - emails: z.array(z.string()).optional().describe('Email addresses to look up'), - phones: z.array(z.string()).optional().describe('Phone numbers to look up'), + brazeId: z.string().optional().describe('Single Braze internal user ID to export'), + deviceId: z.string().optional().describe('Single device ID to export'), + email: z.string().optional().describe('Single email address to look up'), + phone: z.string().optional().describe('Single phone number in E.164 format to look up'), userAliases: z .array( z.object({ @@ -56,11 +58,43 @@ export let exportUsers = SlateTool.create(spec, { instanceUrl: ctx.config.instanceUrl }); + let externalIdentifierCount = + (ctx.input.externalIds?.length ?? 0) + (ctx.input.userAliases?.length ?? 0); + let singleIdentifiers = [ + ctx.input.brazeId, + ctx.input.deviceId, + ctx.input.email, + ctx.input.phone + ].filter(Boolean); + + if (externalIdentifierCount === 0 && singleIdentifiers.length === 0) { + throw brazeServiceError( + 'Provide externalIds, userAliases, brazeId, deviceId, email, or phone.' + ); + } + + if ( + (ctx.input.externalIds?.length ?? 0) > 50 || + (ctx.input.userAliases?.length ?? 0) > 50 + ) { + throw brazeServiceError('externalIds and userAliases can include at most 50 values.'); + } + + if ( + singleIdentifiers.length > 1 || + (singleIdentifiers.length === 1 && externalIdentifierCount > 0) + ) { + throw brazeServiceError( + 'Use only one singular identifier (brazeId, deviceId, email, or phone), and do not combine it with externalIds or userAliases.' + ); + } + let result = await client.exportUsersByIds({ externalIds: ctx.input.externalIds, - brazeIds: ctx.input.brazeIds, - emails: ctx.input.emails, - phones: ctx.input.phones, + brazeId: ctx.input.brazeId, + deviceId: ctx.input.deviceId, + email: ctx.input.email, + phone: ctx.input.phone, userAliases: ctx.input.userAliases, fieldsToExport: ctx.input.fieldsToExport }); diff --git a/integrations/braze/src/tools/get-analytics.ts b/integrations/braze/src/tools/get-analytics.ts index 0de52d431f..544881c649 100644 --- a/integrations/braze/src/tools/get-analytics.ts +++ b/integrations/braze/src/tools/get-analytics.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { requireBrazeNumber, requireBrazeString } from '../lib/errors'; import { spec } from '../spec'; export let getKpiAnalytics = SlateTool.create(spec, { @@ -138,18 +139,103 @@ export let getCustomEventAnalytics = SlateTool.create(spec, { message: `Found **${(result.events ?? []).length}** custom event(s).` }; } else { - let result = await client.getCustomEventAnalytics( - ctx.input.eventName!, - ctx.input.length!, - ctx.input.endingAt - ); + let eventName = requireBrazeString(ctx.input.eventName, 'eventName', 'analytics'); + let length = requireBrazeNumber(ctx.input.length, 'length', 'analytics'); + let result = await client.getCustomEventAnalytics(eventName, length, ctx.input.endingAt); return { output: { dataSeries: result.data ?? [], message: result.message }, - message: `Retrieved **${(result.data ?? []).length}** data points for event **${ctx.input.eventName}**.` + message: `Retrieved **${(result.data ?? []).length}** data points for event **${eventName}**.` }; } }) .build(); + +export let getPurchaseAnalytics = SlateTool.create(spec, { + name: 'Get Purchase Analytics', + key: 'get_purchase_analytics', + description: `List product IDs or retrieve Braze purchase quantity and revenue analytics for a product or all products over a date range.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + action: z + .enum(['list_products', 'quantity_series', 'revenue_series']) + .describe('Purchase analytics operation to perform'), + length: z + .number() + .optional() + .describe('Number of days of data to return (required for series actions, max 100)'), + endingAt: z + .string() + .optional() + .describe('End date for the data series in ISO 8601 format'), + productId: z.string().optional().describe('Optional product ID to filter series data'), + page: z.number().optional().describe('Page number for list_products') + }) + ) + .output( + z.object({ + productIds: z.array(z.string()).optional().describe('Product IDs'), + dataSeries: z + .array(z.record(z.string(), z.any())) + .optional() + .describe('Purchase analytics data points'), + message: z.string().describe('Response status') + }) + ) + .handleInvocation(async ctx => { + let client = new BrazeClient({ + token: ctx.auth.token, + instanceUrl: ctx.config.instanceUrl + }); + + switch (ctx.input.action) { + case 'list_products': { + let result = await client.listProducts(ctx.input.page); + return { + output: { + productIds: result.products ?? result.product_ids ?? [], + message: result.message + }, + message: `Found **${(result.products ?? result.product_ids ?? []).length}** product ID(s).` + }; + } + case 'quantity_series': { + let length = requireBrazeNumber(ctx.input.length, 'length', 'quantity_series'); + let result = await client.getPurchaseQuantitySeries( + length, + ctx.input.endingAt, + ctx.input.productId + ); + return { + output: { + dataSeries: result.data ?? [], + message: result.message + }, + message: `Retrieved **${(result.data ?? []).length}** purchase quantity data point(s).` + }; + } + case 'revenue_series': { + let length = requireBrazeNumber(ctx.input.length, 'length', 'revenue_series'); + let result = await client.getPurchaseRevenueSeries( + length, + ctx.input.endingAt, + ctx.input.productId + ); + return { + output: { + dataSeries: result.data ?? [], + message: result.message + }, + message: `Retrieved **${(result.data ?? []).length}** purchase revenue data point(s).` + }; + } + } + }) + .build(); diff --git a/integrations/braze/src/tools/index.ts b/integrations/braze/src/tools/index.ts index 45990b2c7f..c2a694b5e7 100644 --- a/integrations/braze/src/tools/index.ts +++ b/integrations/braze/src/tools/index.ts @@ -5,6 +5,7 @@ export * from './list-canvases'; export * from './list-segments'; export * from './manage-catalogs'; export * from './manage-email'; +export * from './manage-sms'; export * from './manage-subscription'; export * from './manage-templates'; export * from './manage-users'; diff --git a/integrations/braze/src/tools/list-canvases.ts b/integrations/braze/src/tools/list-canvases.ts index cc5a54c6a1..7a5aae69e9 100644 --- a/integrations/braze/src/tools/list-canvases.ts +++ b/integrations/braze/src/tools/list-canvases.ts @@ -130,3 +130,62 @@ export let getCanvasDetails = SlateTool.create(spec, { }; }) .build(); + +export let getCanvasAnalytics = SlateTool.create(spec, { + name: 'Get Canvas Analytics', + key: 'get_canvas_analytics', + description: `Retrieve daily analytics time series for a Braze Canvas, optionally including variant and step breakdowns.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + canvasId: z.string().describe('ID of the Canvas'), + length: z.number().describe('Number of days of data to return (max 100)'), + endingAt: z + .string() + .optional() + .describe('End date for the data series in ISO 8601 format'), + includeVariantBreakdown: z + .boolean() + .optional() + .describe('Include analytics broken down by Canvas variant'), + includeStepBreakdown: z + .boolean() + .optional() + .describe('Include analytics broken down by Canvas step') + }) + ) + .output( + z.object({ + dataSeries: z + .array(z.record(z.string(), z.any())) + .describe('Daily Canvas analytics data points'), + message: z.string().describe('Response status') + }) + ) + .handleInvocation(async ctx => { + let client = new BrazeClient({ + token: ctx.auth.token, + instanceUrl: ctx.config.instanceUrl + }); + + let result = await client.getCanvasAnalytics( + ctx.input.canvasId, + ctx.input.length, + ctx.input.endingAt, + ctx.input.includeVariantBreakdown, + ctx.input.includeStepBreakdown + ); + + return { + output: { + dataSeries: result.data ?? [], + message: result.message + }, + message: `Retrieved **${(result.data ?? []).length}** days of analytics for Canvas **${ctx.input.canvasId}**.` + }; + }) + .build(); diff --git a/integrations/braze/src/tools/list-segments.ts b/integrations/braze/src/tools/list-segments.ts index 9bce853008..d63c69259a 100644 --- a/integrations/braze/src/tools/list-segments.ts +++ b/integrations/braze/src/tools/list-segments.ts @@ -116,3 +116,44 @@ export let getSegmentDetails = SlateTool.create(spec, { }; }) .build(); + +export let getSegmentAnalytics = SlateTool.create(spec, { + name: 'Get Segment Analytics', + key: 'get_segment_analytics', + description: `Retrieve daily analytics time series for a Braze segment over a specified number of days.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + segmentId: z.string().describe('ID of the segment'), + length: z.number().describe('Number of days of data to return (max 100)') + }) + ) + .output( + z.object({ + dataSeries: z + .array(z.record(z.string(), z.any())) + .describe('Daily segment analytics data points'), + message: z.string().describe('Response status') + }) + ) + .handleInvocation(async ctx => { + let client = new BrazeClient({ + token: ctx.auth.token, + instanceUrl: ctx.config.instanceUrl + }); + + let result = await client.getSegmentAnalytics(ctx.input.segmentId, ctx.input.length); + + return { + output: { + dataSeries: result.data ?? [], + message: result.message + }, + message: `Retrieved **${(result.data ?? []).length}** days of analytics for segment **${ctx.input.segmentId}**.` + }; + }) + .build(); diff --git a/integrations/braze/src/tools/manage-catalogs.ts b/integrations/braze/src/tools/manage-catalogs.ts index 8b5ff2deb6..6e5ff727a8 100644 --- a/integrations/braze/src/tools/manage-catalogs.ts +++ b/integrations/braze/src/tools/manage-catalogs.ts @@ -1,8 +1,94 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError, requireBrazeArray, requireBrazeString } from '../lib/errors'; import { spec } from '../spec'; +let catalogFieldSchema = z.object({ + name: z.string().describe('Catalog field name'), + type: z + .string() + .describe('Braze catalog field type, such as string, number, boolean, or time') +}); + +let requireFields = (value: Record | undefined, action: string) => { + if (value && Object.keys(value).length > 0) { + return value; + } + + throw brazeServiceError(`fields must contain at least one item for "${action}".`); +}; + +export let manageCatalogs = SlateTool.create(spec, { + name: 'Manage Catalogs', + key: 'manage_catalogs', + description: `Create or delete Braze catalogs used for personalization. Use List Catalogs to inspect existing catalogs before destructive operations.`, + instructions: [ + 'Use action "create" with catalogName and fields to create a catalog.', + 'Use action "delete" with catalogName to delete a catalog.' + ], + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + action: z.enum(['create', 'delete']).describe('Catalog operation to perform'), + catalogName: z.string().optional().describe('Catalog name'), + description: z.string().optional().describe('Catalog description'), + fields: z + .array(catalogFieldSchema) + .optional() + .describe('Catalog field definitions required for create') + }) + ) + .output( + z.object({ + catalogName: z.string().optional().describe('Catalog name'), + message: z.string().describe('Response status from Braze') + }) + ) + .handleInvocation(async ctx => { + let client = new BrazeClient({ + token: ctx.auth.token, + instanceUrl: ctx.config.instanceUrl + }); + + let catalogName = requireBrazeString( + ctx.input.catalogName, + 'catalogName', + ctx.input.action + ); + + if (ctx.input.action === 'create') { + let fields = requireBrazeArray(ctx.input.fields, 'fields', 'create'); + let result = await client.createCatalog({ + name: catalogName, + description: ctx.input.description ?? '', + fields + }); + + return { + output: { + catalogName, + message: result.message + }, + message: `Created catalog **${catalogName}**.` + }; + } + + let result = await client.deleteCatalog(catalogName); + return { + output: { + catalogName, + message: result.message + }, + message: `Deleted catalog **${catalogName}**.` + }; + }) + .build(); + export let listCatalogs = SlateTool.create(spec, { name: 'List Catalogs', key: 'list_catalogs', @@ -61,6 +147,7 @@ export let manageCatalogItems = SlateTool.create(spec, { .string() .optional() .describe('ID of the catalog item (required for get, create, update, delete)'), + cursor: z.string().optional().describe('Pagination cursor for list action'), fields: z .record(z.string(), z.any()) .optional() @@ -86,7 +173,7 @@ export let manageCatalogItems = SlateTool.create(spec, { switch (ctx.input.action) { case 'list': { - result = await client.listCatalogItems(ctx.input.catalogName); + result = await client.listCatalogItems(ctx.input.catalogName, ctx.input.cursor); return { output: { items: result.items ?? [], @@ -96,48 +183,52 @@ export let manageCatalogItems = SlateTool.create(spec, { }; } case 'get': { - result = await client.getCatalogItem(ctx.input.catalogName, ctx.input.itemId!); + let itemId = requireBrazeString(ctx.input.itemId, 'itemId', 'get'); + result = await client.getCatalogItem(ctx.input.catalogName, itemId); return { output: { items: result.items ? [result.items] : result.item ? [result.item] : [], message: result.message }, - message: `Retrieved item **${ctx.input.itemId}** from catalog **${ctx.input.catalogName}**.` + message: `Retrieved item **${itemId}** from catalog **${ctx.input.catalogName}**.` }; } case 'create': { + let itemId = requireBrazeString(ctx.input.itemId, 'itemId', 'create'); result = await client.createCatalogItem( ctx.input.catalogName, - ctx.input.itemId!, - ctx.input.fields ?? {} + itemId, + requireFields(ctx.input.fields, 'create') ); return { output: { message: result.message }, - message: `Created item **${ctx.input.itemId}** in catalog **${ctx.input.catalogName}**.` + message: `Created item **${itemId}** in catalog **${ctx.input.catalogName}**.` }; } case 'update': { + let itemId = requireBrazeString(ctx.input.itemId, 'itemId', 'update'); result = await client.updateCatalogItem( ctx.input.catalogName, - ctx.input.itemId!, - ctx.input.fields ?? {} + itemId, + requireFields(ctx.input.fields, 'update') ); return { output: { message: result.message }, - message: `Updated item **${ctx.input.itemId}** in catalog **${ctx.input.catalogName}**.` + message: `Updated item **${itemId}** in catalog **${ctx.input.catalogName}**.` }; } case 'delete': { - result = await client.deleteCatalogItem(ctx.input.catalogName, ctx.input.itemId!); + let itemId = requireBrazeString(ctx.input.itemId, 'itemId', 'delete'); + result = await client.deleteCatalogItem(ctx.input.catalogName, itemId); return { output: { message: result.message }, - message: `Deleted item **${ctx.input.itemId}** from catalog **${ctx.input.catalogName}**.` + message: `Deleted item **${itemId}** from catalog **${ctx.input.catalogName}**.` }; } } diff --git a/integrations/braze/src/tools/manage-email.ts b/integrations/braze/src/tools/manage-email.ts index f5440b565c..b73762552c 100644 --- a/integrations/braze/src/tools/manage-email.ts +++ b/integrations/braze/src/tools/manage-email.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { requireBrazeArray, requireBrazeString } from '../lib/errors'; import { spec } from '../spec'; export let manageEmailList = SlateTool.create(spec, { @@ -9,7 +10,8 @@ export let manageEmailList = SlateTool.create(spec, { description: `Query and manage Braze email blocklists. List hard bounced or unsubscribed email addresses, and remove emails from the bounce or spam lists to re-enable delivery.`, instructions: [ 'Use action "list_bounces" to view hard bounced emails, "list_unsubscribes" for unsubscribed emails.', - 'Use "remove_bounces" or "remove_spam" to remove addresses from the respective lists.' + 'Use "remove_bounces" or "remove_spam" to remove addresses from the respective lists.', + 'Use "blocklist" to add addresses to the blocklist, or "set_status" to change one email subscription status.' ], tags: { destructive: false, @@ -19,16 +21,37 @@ export let manageEmailList = SlateTool.create(spec, { .input( z.object({ action: z - .enum(['list_bounces', 'list_unsubscribes', 'remove_bounces', 'remove_spam']) + .enum([ + 'list_bounces', + 'list_unsubscribes', + 'remove_bounces', + 'remove_spam', + 'blocklist', + 'set_status' + ]) .describe('Operation to perform'), emails: z .array(z.string()) .optional() - .describe('Email addresses to remove (required for remove_bounces and remove_spam)'), + .describe( + 'Email addresses to remove or blocklist (required for remove_bounces, remove_spam, and blocklist)' + ), + email: z + .string() + .optional() + .describe('Single email address for filtering or set_status'), + subscriptionState: z + .enum(['opted_in', 'subscribed', 'unsubscribed']) + .optional() + .describe('Email subscription state for set_status'), startDate: z.string().optional().describe('Start date for listing (YYYY-MM-DD format)'), endDate: z.string().optional().describe('End date for listing (YYYY-MM-DD format)'), limit: z.number().optional().describe('Max results to return (default 100, max 500)'), - offset: z.number().optional().describe('Offset for pagination') + offset: z.number().optional().describe('Offset for pagination'), + sortDirection: z + .enum(['asc', 'desc']) + .optional() + .describe('Sort direction for list_unsubscribes') }) ) .output( @@ -51,7 +74,8 @@ export let manageEmailList = SlateTool.create(spec, { startDate: ctx.input.startDate, endDate: ctx.input.endDate, limit: ctx.input.limit, - offset: ctx.input.offset + offset: ctx.input.offset, + email: ctx.input.email }); return { output: { @@ -66,7 +90,9 @@ export let manageEmailList = SlateTool.create(spec, { startDate: ctx.input.startDate, endDate: ctx.input.endDate, limit: ctx.input.limit, - offset: ctx.input.offset + offset: ctx.input.offset, + sortDirection: ctx.input.sortDirection, + email: ctx.input.email }); return { output: { @@ -77,21 +103,48 @@ export let manageEmailList = SlateTool.create(spec, { }; } case 'remove_bounces': { - result = await client.removeFromBounceList(ctx.input.emails ?? []); + let emails = requireBrazeArray(ctx.input.emails, 'emails', 'remove_bounces'); + result = await client.removeFromBounceList(emails); return { output: { message: result.message }, - message: `Removed **${(ctx.input.emails ?? []).length}** email(s) from the bounce list.` + message: `Removed **${emails.length}** email(s) from the bounce list.` }; } case 'remove_spam': { - result = await client.removeFromSpamList(ctx.input.emails ?? []); + let emails = requireBrazeArray(ctx.input.emails, 'emails', 'remove_spam'); + result = await client.removeFromSpamList(emails); + return { + output: { + message: result.message + }, + message: `Removed **${emails.length}** email(s) from the spam list.` + }; + } + case 'blocklist': { + let emails = requireBrazeArray(ctx.input.emails, 'emails', 'blocklist'); + result = await client.blocklistEmails(emails); + return { + output: { + message: result.message + }, + message: `Blocklisted **${emails.length}** email(s).` + }; + } + case 'set_status': { + let email = requireBrazeString(ctx.input.email, 'email', 'set_status'); + let subscriptionState = requireBrazeString( + ctx.input.subscriptionState, + 'subscriptionState', + 'set_status' + ); + result = await client.setEmailSubscriptionStatus(email, subscriptionState); return { output: { message: result.message }, - message: `Removed **${(ctx.input.emails ?? []).length}** email(s) from the spam list.` + message: `Set **${email}** email subscription status to **${subscriptionState}**.` }; } } diff --git a/integrations/braze/src/tools/manage-sms.ts b/integrations/braze/src/tools/manage-sms.ts new file mode 100644 index 0000000000..b585ec8c78 --- /dev/null +++ b/integrations/braze/src/tools/manage-sms.ts @@ -0,0 +1,74 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BrazeClient } from '../lib/client'; +import { requireBrazeArray } from '../lib/errors'; +import { spec } from '../spec'; + +export let manageSmsInvalidPhoneNumbers = SlateTool.create(spec, { + name: 'Manage SMS Invalid Phone Numbers', + key: 'manage_sms_invalid_phone_numbers', + description: `Query or remove phone numbers from Braze's invalid SMS phone number list.`, + instructions: [ + 'Use action "list" to query invalid phone numbers.', + 'Use action "remove" with phones to remove numbers from the invalid phone number list.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + action: z.enum(['list', 'remove']).describe('Operation to perform'), + phones: z + .array(z.string()) + .optional() + .describe('Phone numbers in E.164 format to remove for remove action'), + phone: z.string().optional().describe('Single phone number filter for list action'), + startDate: z.string().optional().describe('Start date for listing (YYYY-MM-DD format)'), + endDate: z.string().optional().describe('End date for listing (YYYY-MM-DD format)'), + limit: z.number().optional().describe('Max results to return'), + offset: z.number().optional().describe('Offset for pagination') + }) + ) + .output( + z.object({ + phones: z.array(z.any()).optional().describe('Invalid phone numbers returned'), + message: z.string().describe('Response status from Braze') + }) + ) + .handleInvocation(async ctx => { + let client = new BrazeClient({ + token: ctx.auth.token, + instanceUrl: ctx.config.instanceUrl + }); + + if (ctx.input.action === 'list') { + let result = await client.listInvalidPhoneNumbers({ + startDate: ctx.input.startDate, + endDate: ctx.input.endDate, + limit: ctx.input.limit, + offset: ctx.input.offset, + phone: ctx.input.phone + }); + + return { + output: { + phones: result.phones ?? result.phone_numbers ?? [], + message: result.message + }, + message: `Found **${(result.phones ?? result.phone_numbers ?? []).length}** invalid phone number(s).` + }; + } + + let phones = requireBrazeArray(ctx.input.phones, 'phones', 'remove'); + let result = await client.removeInvalidPhoneNumbers(phones); + + return { + output: { + message: result.message + }, + message: `Removed **${phones.length}** phone number(s) from the invalid SMS list.` + }; + }) + .build(); diff --git a/integrations/braze/src/tools/manage-subscription.ts b/integrations/braze/src/tools/manage-subscription.ts index 1dcdc32670..e33868f82a 100644 --- a/integrations/braze/src/tools/manage-subscription.ts +++ b/integrations/braze/src/tools/manage-subscription.ts @@ -1,8 +1,24 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError } from '../lib/errors'; import { spec } from '../spec'; +let hasAnyUserIdentifier = (input: { + externalIds?: string[]; + emails?: string[]; + phones?: string[]; + externalId?: string; + email?: string; + phone?: string; +}) => + (input.externalIds?.length ?? 0) > 0 || + (input.emails?.length ?? 0) > 0 || + (input.phones?.length ?? 0) > 0 || + Boolean(input.externalId) || + Boolean(input.email) || + Boolean(input.phone); + export let updateSubscriptionStatus = SlateTool.create(spec, { name: 'Update Subscription Status', key: 'update_subscription_status', @@ -41,6 +57,10 @@ export let updateSubscriptionStatus = SlateTool.create(spec, { instanceUrl: ctx.config.instanceUrl }); + if (!hasAnyUserIdentifier(ctx.input)) { + throw brazeServiceError('Provide externalIds, emails, or phones to update.'); + } + let result = await client.setSubscriptionStatus({ subscriptionGroupId: ctx.input.subscriptionGroupId, subscriptionState: ctx.input.subscriptionState, @@ -100,6 +120,10 @@ export let getSubscriptionStatus = SlateTool.create(spec, { instanceUrl: ctx.config.instanceUrl }); + if (!hasAnyUserIdentifier(ctx.input)) { + throw brazeServiceError('Provide externalId, email, or phone to query.'); + } + let result: any; if (ctx.input.subscriptionGroupId) { result = await client.getSubscriptionStatus({ diff --git a/integrations/braze/src/tools/manage-templates.ts b/integrations/braze/src/tools/manage-templates.ts index 4eee397d24..8810bf6aef 100644 --- a/integrations/braze/src/tools/manage-templates.ts +++ b/integrations/braze/src/tools/manage-templates.ts @@ -1,8 +1,17 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError, requireBrazeString } from '../lib/errors'; import { spec } from '../spec'; +let requireAnyUpdate = (fields: Record, action: string) => { + if (Object.values(fields).some(value => value !== undefined)) { + return; + } + + throw brazeServiceError(`At least one updatable field is required for "${action}".`); +}; + export let manageEmailTemplates = SlateTool.create(spec, { name: 'Manage Email Templates', key: 'manage_email_templates', @@ -86,7 +95,8 @@ export let manageEmailTemplates = SlateTool.create(spec, { }; } case 'get': { - let result = await client.getEmailTemplate(ctx.input.templateId!); + let templateId = requireBrazeString(ctx.input.templateId, 'templateId', 'get'); + let result = await client.getEmailTemplate(templateId); return { output: { templateId: result.email_template_id, @@ -98,14 +108,21 @@ export let manageEmailTemplates = SlateTool.create(spec, { updatedAt: result.updated_at, message: result.message }, - message: `Retrieved template **${result.template_name ?? ctx.input.templateId}**.` + message: `Retrieved template **${result.template_name ?? templateId}**.` }; } case 'create': { + let templateName = requireBrazeString( + ctx.input.templateName, + 'templateName', + 'create' + ); + let subject = requireBrazeString(ctx.input.subject, 'subject', 'create'); + let body = requireBrazeString(ctx.input.body, 'body', 'create'); let result = await client.createEmailTemplate({ - templateName: ctx.input.templateName!, - subject: ctx.input.subject!, - body: ctx.input.body!, + templateName, + subject, + body, plaintextBody: ctx.input.plaintextBody, preheader: ctx.input.preheader, tags: ctx.input.tags @@ -115,12 +132,24 @@ export let manageEmailTemplates = SlateTool.create(spec, { templateId: result.email_template_id, message: result.message }, - message: `Created email template **${ctx.input.templateName}** (ID: ${result.email_template_id}).` + message: `Created email template **${templateName}** (ID: ${result.email_template_id}).` }; } case 'update': { + let templateId = requireBrazeString(ctx.input.templateId, 'templateId', 'update'); + requireAnyUpdate( + { + templateName: ctx.input.templateName, + subject: ctx.input.subject, + body: ctx.input.body, + plaintextBody: ctx.input.plaintextBody, + preheader: ctx.input.preheader, + tags: ctx.input.tags + }, + 'update' + ); let result = await client.updateEmailTemplate({ - templateId: ctx.input.templateId!, + templateId, templateName: ctx.input.templateName, subject: ctx.input.subject, body: ctx.input.body, @@ -130,10 +159,10 @@ export let manageEmailTemplates = SlateTool.create(spec, { }); return { output: { - templateId: ctx.input.templateId, + templateId, message: result.message }, - message: `Updated email template **${ctx.input.templateId}**.` + message: `Updated email template **${templateId}**.` }; } } @@ -229,7 +258,12 @@ export let manageContentBlocks = SlateTool.create(spec, { }; } case 'get': { - let result = await client.getContentBlock(ctx.input.contentBlockId!); + let contentBlockId = requireBrazeString( + ctx.input.contentBlockId, + 'contentBlockId', + 'get' + ); + let result = await client.getContentBlock(contentBlockId); return { output: { contentBlockId: result.content_block_id, @@ -240,14 +274,17 @@ export let manageContentBlocks = SlateTool.create(spec, { updatedAt: result.last_edited, message: result.message }, - message: `Retrieved Content Block **${result.name ?? ctx.input.contentBlockId}**.` + message: `Retrieved Content Block **${result.name ?? contentBlockId}**.` }; } case 'create': { + let name = requireBrazeString(ctx.input.name, 'name', 'create'); + let contentType = requireBrazeString(ctx.input.contentType, 'contentType', 'create'); + let content = requireBrazeString(ctx.input.content, 'content', 'create'); let result = await client.createContentBlock({ - name: ctx.input.name!, - contentType: ctx.input.contentType!, - content: ctx.input.content!, + name, + contentType, + content, description: ctx.input.description, state: ctx.input.state, tags: ctx.input.tags @@ -257,12 +294,28 @@ export let manageContentBlocks = SlateTool.create(spec, { contentBlockId: result.content_block_id, message: result.message }, - message: `Created Content Block **${ctx.input.name}** (ID: ${result.content_block_id}).` + message: `Created Content Block **${name}** (ID: ${result.content_block_id}).` }; } case 'update': { + let contentBlockId = requireBrazeString( + ctx.input.contentBlockId, + 'contentBlockId', + 'update' + ); + requireAnyUpdate( + { + name: ctx.input.name, + contentType: ctx.input.contentType, + content: ctx.input.content, + description: ctx.input.description, + state: ctx.input.state, + tags: ctx.input.tags + }, + 'update' + ); let result = await client.updateContentBlock({ - contentBlockId: ctx.input.contentBlockId!, + contentBlockId, name: ctx.input.name, contentType: ctx.input.contentType, content: ctx.input.content, @@ -272,10 +325,10 @@ export let manageContentBlocks = SlateTool.create(spec, { }); return { output: { - contentBlockId: ctx.input.contentBlockId, + contentBlockId, message: result.message }, - message: `Updated Content Block **${ctx.input.contentBlockId}**.` + message: `Updated Content Block **${contentBlockId}**.` }; } } diff --git a/integrations/braze/src/tools/manage-users.ts b/integrations/braze/src/tools/manage-users.ts index d4a9016425..8788bb4994 100644 --- a/integrations/braze/src/tools/manage-users.ts +++ b/integrations/braze/src/tools/manage-users.ts @@ -1,8 +1,178 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError, requireBrazeArray } from '../lib/errors'; import { spec } from '../spec'; +let userAliasSchema = z.object({ + aliasName: z.string().describe('Alias name'), + aliasLabel: z.string().describe('Alias label') +}); + +let prioritizationSchema = z + .array( + z.enum(['identified', 'unidentified', 'most_recently_updated', 'least_recently_updated']) + ) + .describe('Braze prioritization order for identifying email-only or phone-only users'); + +export let manageUserIdentity = SlateTool.create(spec, { + name: 'Manage User Identity', + key: 'manage_user_identity', + description: `Create Braze user aliases or identify alias-only, email-only, or phone-only users by assigning them an external ID. Use this for identity resolution workflows before tracking or messaging users.`, + instructions: [ + 'Use action "create_aliases" to add aliases to existing external IDs.', + 'Use action "identify_aliases" to assign external IDs to alias-only users.', + 'Use action "identify_emails" or "identify_phones" to identify email-only or phone-only users with prioritization.' + ], + constraints: ['Maximum 50 identity operations per request.'], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + action: z + .enum(['create_aliases', 'identify_aliases', 'identify_emails', 'identify_phones']) + .describe('Identity operation to perform'), + aliases: z + .array( + z.object({ + externalId: z.string().describe('External user ID'), + aliasName: z.string().describe('Alias name'), + aliasLabel: z.string().describe('Alias label') + }) + ) + .optional() + .describe('Aliases to create for existing external IDs'), + aliasesToIdentify: z + .array( + z.object({ + externalId: z.string().describe('External user ID to assign'), + userAlias: userAliasSchema.describe('Existing alias-only user alias') + }) + ) + .optional() + .describe('Alias-only users to identify with external IDs'), + emailsToIdentify: z + .array( + z.object({ + externalId: z.string().describe('External user ID to assign'), + email: z.string().describe('Email-only user address to identify'), + prioritization: prioritizationSchema + }) + ) + .optional() + .describe('Email-only users to identify with external IDs'), + phoneNumbersToIdentify: z + .array( + z.object({ + externalId: z.string().describe('External user ID to assign'), + phone: z.string().describe('Phone-only user number to identify'), + prioritization: prioritizationSchema + }) + ) + .optional() + .describe('Phone-only users to identify with external IDs') + }) + ) + .output( + z.object({ + message: z.string().describe('Response status from Braze'), + errors: z.array(z.any()).optional().describe('Errors encountered') + }) + ) + .handleInvocation(async ctx => { + let client = new BrazeClient({ + token: ctx.auth.token, + instanceUrl: ctx.config.instanceUrl + }); + + switch (ctx.input.action) { + case 'create_aliases': { + let aliases = requireBrazeArray(ctx.input.aliases, 'aliases', 'create_aliases'); + if (aliases.length > 50) { + throw brazeServiceError( + 'manage_user_identity accepts at most 50 aliases per request.' + ); + } + let result = await client.createUserAliases(aliases); + + return { + output: { + message: result.message, + errors: result.errors + }, + message: `Created **${aliases.length}** user alias(es).` + }; + } + case 'identify_aliases': { + let aliasesToIdentify = requireBrazeArray( + ctx.input.aliasesToIdentify, + 'aliasesToIdentify', + 'identify_aliases' + ); + if (aliasesToIdentify.length > 50) { + throw brazeServiceError( + 'manage_user_identity accepts at most 50 aliases per request.' + ); + } + let result = await client.identifyUsers({ aliasesToIdentify }); + + return { + output: { + message: result.message, + errors: result.errors + }, + message: `Identified **${aliasesToIdentify.length}** user alias(es).` + }; + } + case 'identify_emails': { + let emailsToIdentify = requireBrazeArray( + ctx.input.emailsToIdentify, + 'emailsToIdentify', + 'identify_emails' + ); + if (emailsToIdentify.length > 50) { + throw brazeServiceError( + 'manage_user_identity accepts at most 50 emails per request.' + ); + } + let result = await client.identifyUsers({ emailsToIdentify }); + + return { + output: { + message: result.message, + errors: result.errors + }, + message: `Identified **${emailsToIdentify.length}** email-only user(s).` + }; + } + case 'identify_phones': { + let phoneNumbersToIdentify = requireBrazeArray( + ctx.input.phoneNumbersToIdentify, + 'phoneNumbersToIdentify', + 'identify_phones' + ); + if (phoneNumbersToIdentify.length > 50) { + throw brazeServiceError( + 'manage_user_identity accepts at most 50 phone numbers per request.' + ); + } + let result = await client.identifyUsers({ phoneNumbersToIdentify }); + + return { + output: { + message: result.message, + errors: result.errors + }, + message: `Identified **${phoneNumbersToIdentify.length}** phone-only user(s).` + }; + } + } + }) + .build(); + export let deleteUsers = SlateTool.create(spec, { name: 'Delete Users', key: 'delete_users', @@ -43,6 +213,19 @@ export let deleteUsers = SlateTool.create(spec, { instanceUrl: ctx.config.instanceUrl }); + let count = + (ctx.input.externalIds?.length ?? 0) + + (ctx.input.brazeIds?.length ?? 0) + + (ctx.input.userAliases?.length ?? 0); + + if (count === 0) { + throw brazeServiceError('Provide externalIds, brazeIds, or userAliases to delete.'); + } + + if (count > 50) { + throw brazeServiceError('delete_users accepts at most 50 users per request.'); + } + let result = await client.deleteUsers({ externalIds: ctx.input.externalIds, brazeIds: ctx.input.brazeIds, @@ -108,14 +291,19 @@ export let mergeUsers = SlateTool.create(spec, { instanceUrl: ctx.config.instanceUrl }); - let result = await client.mergeUsers(ctx.input.mergeUpdates); + let mergeUpdates = requireBrazeArray(ctx.input.mergeUpdates, 'mergeUpdates'); + if (mergeUpdates.length > 50) { + throw brazeServiceError('merge_users accepts at most 50 merge operations per request.'); + } + + let result = await client.mergeUsers(mergeUpdates); return { output: { message: result.message, errors: result.errors }, - message: `Processed **${ctx.input.mergeUpdates.length}** user merge operation(s).` + message: `Processed **${mergeUpdates.length}** user merge operation(s).` }; }) .build(); diff --git a/integrations/braze/src/tools/schedule-message.ts b/integrations/braze/src/tools/schedule-message.ts index 96fb642c14..137a86bad7 100644 --- a/integrations/braze/src/tools/schedule-message.ts +++ b/integrations/braze/src/tools/schedule-message.ts @@ -1,15 +1,43 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError, requireBrazeString } from '../lib/errors'; import { spec } from '../spec'; +import { assertHasMessageChannel, mapMessageChannels } from './send-message'; + +let userAliasSchema = z.object({ + aliasName: z.string().describe('Alias name'), + aliasLabel: z.string().describe('Alias label') +}); + +let messageChannelsSchema = z.object({ + email: z.record(z.string(), z.any()).optional().describe('Email message object'), + applePush: z + .record(z.string(), z.any()) + .optional() + .describe('Apple push notification object'), + androidPush: z + .record(z.string(), z.any()) + .optional() + .describe('Android push notification object'), + webPush: z.record(z.string(), z.any()).optional().describe('Web push notification object'), + kindlePush: z + .record(z.string(), z.any()) + .optional() + .describe('Kindle/FireOS push notification object'), + sms: z.record(z.string(), z.any()).optional().describe('SMS message object'), + webhook: z.record(z.string(), z.any()).optional().describe('Webhook message object'), + whatsApp: z.record(z.string(), z.any()).optional().describe('WhatsApp message object'), + contentCard: z.record(z.string(), z.any()).optional().describe('Content Card message object') +}); export let scheduleMessage = SlateTool.create(spec, { name: 'Schedule Message', key: 'schedule_message', - description: `Schedule a message for future delivery or cancel an existing scheduled message. Allows you to set up one-off message sends at a specific time, optionally in each user's local timezone.`, + description: `Schedule a message for future delivery, list upcoming scheduled broadcasts, or cancel an existing scheduled message. Allows one-off API message sends at a specific time, optionally in each user's local timezone.`, instructions: [ - 'Use action "create" to schedule a new message, or "delete" to cancel an existing scheduled message.', - 'The scheduled time must be in ISO 8601 format.' + 'Use action "create" to schedule a new message, "list" to retrieve upcoming scheduled broadcasts, or "delete" to cancel an existing scheduled message.', + 'The scheduled time and list end time must be ISO 8601 timestamps.' ], tags: { destructive: false, @@ -19,16 +47,31 @@ export let scheduleMessage = SlateTool.create(spec, { .input( z.object({ action: z - .enum(['create', 'delete']) - .describe('"create" to schedule a message, "delete" to cancel a scheduled message'), + .enum(['create', 'list', 'delete']) + .describe( + '"create" to schedule a message, "list" to retrieve scheduled broadcasts, "delete" to cancel a scheduled message' + ), scheduleId: z .string() .optional() .describe('Schedule ID to cancel (required for delete action)'), + endTime: z + .string() + .optional() + .describe('End of the scheduled broadcast lookup window (required for list action)'), externalUserIds: z .array(z.string()) .optional() .describe('External user IDs to send to (for create action)'), + userAliases: z + .array(userAliasSchema) + .optional() + .describe('User aliases to send to (for create action)'), + segmentId: z.string().optional().describe('Segment ID to target (for create action)'), + audience: z + .record(z.string(), z.any()) + .optional() + .describe('Connected audience object to target (for create action)'), broadcast: z .boolean() .optional() @@ -43,28 +86,17 @@ export let scheduleMessage = SlateTool.create(spec, { .boolean() .optional() .describe("If true, deliver the message in each user's local timezone"), - messages: z - .object({ - email: z.record(z.string(), z.any()).optional().describe('Email message object'), - applePush: z - .record(z.string(), z.any()) - .optional() - .describe('Apple push notification object'), - androidPush: z - .record(z.string(), z.any()) - .optional() - .describe('Android push notification object'), - webPush: z - .record(z.string(), z.any()) - .optional() - .describe('Web push notification object'), - sms: z.record(z.string(), z.any()).optional().describe('SMS message object'), - webhook: z.record(z.string(), z.any()).optional().describe('Webhook message object'), - contentCard: z - .record(z.string(), z.any()) - .optional() - .describe('Content Card message object') - }) + campaignId: z.string().optional().describe('Campaign ID for tracking analytics'), + sendId: z.string().optional().describe('Send ID for tracking this scheduled dispatch'), + overrideFrequencyCapping: z + .boolean() + .optional() + .describe('Ignore campaign frequency capping'), + recipientSubscriptionState: z + .enum(['opted_in', 'subscribed', 'all']) + .optional() + .describe('Recipient subscription state filter'), + messages: messageChannelsSchema .optional() .describe('Channel-specific message objects (for create action)') }) @@ -75,6 +107,10 @@ export let scheduleMessage = SlateTool.create(spec, { .string() .optional() .describe('Schedule ID for the created scheduled message'), + scheduledBroadcasts: z + .array(z.record(z.string(), z.any())) + .optional() + .describe('Upcoming scheduled campaigns and Canvases'), message: z.string().describe('Response status from Braze') }) ) @@ -85,34 +121,65 @@ export let scheduleMessage = SlateTool.create(spec, { }); if (ctx.input.action === 'delete') { - let result = await client.deleteScheduledMessage(ctx.input.scheduleId!); + let scheduleId = requireBrazeString(ctx.input.scheduleId, 'scheduleId', 'delete'); + let result = await client.deleteScheduledMessage(scheduleId); return { output: { message: result.message }, - message: `Cancelled scheduled message **${ctx.input.scheduleId}**.` + message: `Cancelled scheduled message **${scheduleId}**.` }; } - let messages: Record = {}; - if (ctx.input.messages) { - if (ctx.input.messages.email) messages.email = ctx.input.messages.email; - if (ctx.input.messages.applePush) messages.apple_push = ctx.input.messages.applePush; - if (ctx.input.messages.androidPush) - messages.android_push = ctx.input.messages.androidPush; - if (ctx.input.messages.webPush) messages.web_push = ctx.input.messages.webPush; - if (ctx.input.messages.sms) messages.sms = ctx.input.messages.sms; - if (ctx.input.messages.webhook) messages.webhook = ctx.input.messages.webhook; - if (ctx.input.messages.contentCard) - messages.content_card = ctx.input.messages.contentCard; + if (ctx.input.action === 'list') { + let endTime = requireBrazeString(ctx.input.endTime, 'endTime', 'list'); + let result = await client.listScheduledBroadcasts(endTime); + let scheduledBroadcasts = result.scheduled_broadcasts ?? []; + + return { + output: { + scheduledBroadcasts, + message: result.message + }, + message: `Found **${scheduledBroadcasts.length}** scheduled broadcast(s) through **${endTime}**.` + }; } + if ( + ctx.input.broadcast && + ((ctx.input.externalUserIds?.length ?? 0) > 0 || + (ctx.input.userAliases?.length ?? 0) > 0) + ) { + throw brazeServiceError('broadcast cannot be used with externalUserIds or userAliases.'); + } + + if ( + !ctx.input.broadcast && + (ctx.input.externalUserIds?.length ?? 0) === 0 && + (ctx.input.userAliases?.length ?? 0) === 0 + ) { + throw brazeServiceError( + 'Provide externalUserIds or userAliases, or set broadcast to true for segment/audience sends.' + ); + } + + let scheduledTime = requireBrazeString(ctx.input.scheduledTime, 'scheduledTime', 'create'); + let messages = mapMessageChannels(ctx.input.messages ?? {}); + assertHasMessageChannel(messages); + let result = await client.scheduleMessage({ externalUserIds: ctx.input.externalUserIds, + userAliases: ctx.input.userAliases, broadcast: ctx.input.broadcast, + segmentId: ctx.input.segmentId, + audience: ctx.input.audience, + campaignId: ctx.input.campaignId, + sendId: ctx.input.sendId, + overrideFrequencyCapping: ctx.input.overrideFrequencyCapping, + recipientSubscriptionState: ctx.input.recipientSubscriptionState, messages, schedule: { - time: ctx.input.scheduledTime!, + time: scheduledTime, inLocalTime: ctx.input.inLocalTime } }); @@ -122,7 +189,7 @@ export let scheduleMessage = SlateTool.create(spec, { scheduleId: result.schedule_id, message: result.message }, - message: `Scheduled message for **${ctx.input.scheduledTime}**${ctx.input.inLocalTime ? ' (local time)' : ''}.` + message: `Scheduled message for **${scheduledTime}**${ctx.input.inLocalTime ? ' (local time)' : ''}.` }; }) .build(); diff --git a/integrations/braze/src/tools/send-message.ts b/integrations/braze/src/tools/send-message.ts index 97ed3b6224..e814442c14 100644 --- a/integrations/braze/src/tools/send-message.ts +++ b/integrations/braze/src/tools/send-message.ts @@ -1,15 +1,66 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError } from '../lib/errors'; import { spec } from '../spec'; +let userAliasSchema = z.object({ + aliasName: z.string().describe('Alias name'), + aliasLabel: z.string().describe('Alias label') +}); + +let messageChannelsSchema = z.object({ + email: z + .record(z.string(), z.any()) + .optional() + .describe('Email message object with subject, body, from, etc.'), + applePush: z + .record(z.string(), z.any()) + .optional() + .describe('Apple push notification object'), + androidPush: z + .record(z.string(), z.any()) + .optional() + .describe('Android push notification object'), + webPush: z.record(z.string(), z.any()).optional().describe('Web push notification object'), + kindlePush: z + .record(z.string(), z.any()) + .optional() + .describe('Kindle/FireOS push notification object'), + sms: z.record(z.string(), z.any()).optional().describe('SMS message object'), + webhook: z.record(z.string(), z.any()).optional().describe('Webhook message object'), + whatsApp: z.record(z.string(), z.any()).optional().describe('WhatsApp message object'), + contentCard: z.record(z.string(), z.any()).optional().describe('Content Card message object') +}); + +export let mapMessageChannels = (messages: z.infer) => { + let mapped: Record = {}; + if (messages.email) mapped.email = messages.email; + if (messages.applePush) mapped.apple_push = messages.applePush; + if (messages.androidPush) mapped.android_push = messages.androidPush; + if (messages.webPush) mapped.web_push = messages.webPush; + if (messages.kindlePush) mapped.kindle_push = messages.kindlePush; + if (messages.sms) mapped.sms = messages.sms; + if (messages.webhook) mapped.webhook = messages.webhook; + if (messages.whatsApp) mapped.whats_app = messages.whatsApp; + if (messages.contentCard) mapped.content_card = messages.contentCard; + + return mapped; +}; + +export let assertHasMessageChannel = (messages: Record) => { + if (Object.keys(messages).length === 0) { + throw brazeServiceError('At least one message channel object is required.'); + } +}; + export let sendMessage = SlateTool.create(spec, { name: 'Send Message', key: 'send_message', description: `Send messages immediately across channels (email, push, SMS, webhook, Content Cards) to specified users. Supports direct one-off sends to user IDs. For API-triggered campaigns or Canvases, use the dedicated trigger tools instead.`, instructions: [ 'Provide at least one message channel object in the messages field.', - 'Either specify externalUserIds for targeted sends or set broadcast to true for all users.' + 'Specify externalUserIds or userAliases for targeted sends, or set broadcast to true when targeting a segment or connected audience.' ], constraints: [ 'Maximum 50 external user IDs per request.', @@ -26,6 +77,7 @@ export let sendMessage = SlateTool.create(spec, { .array(z.string()) .optional() .describe('List of external user IDs to send to'), + userAliases: z.array(userAliasSchema).optional().describe('User aliases to send to'), broadcast: z .boolean() .optional() @@ -33,33 +85,23 @@ export let sendMessage = SlateTool.create(spec, { 'Set to true to send to all users in a segment. Cannot be used with externalUserIds.' ), segmentId: z.string().optional().describe('Segment ID to target (used with broadcast)'), + audience: z + .record(z.string(), z.any()) + .optional() + .describe('Connected audience object to target'), campaignId: z.string().optional().describe('Campaign ID for tracking analytics'), - messages: z - .object({ - email: z - .record(z.string(), z.any()) - .optional() - .describe('Email message object with subject, body, from, etc.'), - applePush: z - .record(z.string(), z.any()) - .optional() - .describe('Apple push notification object'), - androidPush: z - .record(z.string(), z.any()) - .optional() - .describe('Android push notification object'), - webPush: z - .record(z.string(), z.any()) - .optional() - .describe('Web push notification object'), - sms: z.record(z.string(), z.any()).optional().describe('SMS message object'), - webhook: z.record(z.string(), z.any()).optional().describe('Webhook message object'), - contentCard: z - .record(z.string(), z.any()) - .optional() - .describe('Content Card message object') - }) - .describe('Channel-specific message objects') + sendId: z.string().optional().describe('Send ID for tracking this dispatch'), + overrideFrequencyCapping: z + .boolean() + .optional() + .describe('Ignore campaign frequency capping'), + recipientSubscriptionState: z + .enum(['opted_in', 'subscribed', 'all']) + .optional() + .describe( + 'Recipient subscription state filter. Use "all" only for transactional sends.' + ), + messages: messageChannelsSchema.describe('Channel-specific message objects') }) ) .output( @@ -75,26 +117,44 @@ export let sendMessage = SlateTool.create(spec, { instanceUrl: ctx.config.instanceUrl }); - let messages: Record = {}; - if (ctx.input.messages.email) messages.email = ctx.input.messages.email; - if (ctx.input.messages.applePush) messages.apple_push = ctx.input.messages.applePush; - if (ctx.input.messages.androidPush) messages.android_push = ctx.input.messages.androidPush; - if (ctx.input.messages.webPush) messages.web_push = ctx.input.messages.webPush; - if (ctx.input.messages.sms) messages.sms = ctx.input.messages.sms; - if (ctx.input.messages.webhook) messages.webhook = ctx.input.messages.webhook; - if (ctx.input.messages.contentCard) messages.content_card = ctx.input.messages.contentCard; + if ( + ctx.input.broadcast && + ((ctx.input.externalUserIds?.length ?? 0) > 0 || + (ctx.input.userAliases?.length ?? 0) > 0) + ) { + throw brazeServiceError('broadcast cannot be used with externalUserIds or userAliases.'); + } + + if ( + !ctx.input.broadcast && + (ctx.input.externalUserIds?.length ?? 0) === 0 && + (ctx.input.userAliases?.length ?? 0) === 0 + ) { + throw brazeServiceError( + 'Provide externalUserIds or userAliases, or set broadcast to true for segment/audience sends.' + ); + } + + let messages = mapMessageChannels(ctx.input.messages); + assertHasMessageChannel(messages); let result = await client.sendMessages({ externalUserIds: ctx.input.externalUserIds, + userAliases: ctx.input.userAliases, broadcast: ctx.input.broadcast, segmentId: ctx.input.segmentId, + audience: ctx.input.audience, + campaignId: ctx.input.campaignId, + sendId: ctx.input.sendId, + overrideFrequencyCapping: ctx.input.overrideFrequencyCapping, + recipientSubscriptionState: ctx.input.recipientSubscriptionState, messages }); let targetDesc = ctx.input.broadcast ? 'broadcast' : `${ctx.input.externalUserIds?.length ?? 0} user(s)`; - let channels = Object.keys(ctx.input.messages).filter(k => (ctx.input.messages as any)[k]); + let channels = Object.keys(messages); return { output: { diff --git a/integrations/braze/src/tools/track-users.ts b/integrations/braze/src/tools/track-users.ts index 9860cd263b..91861e75c1 100644 --- a/integrations/braze/src/tools/track-users.ts +++ b/integrations/braze/src/tools/track-users.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError } from '../lib/errors'; import { spec } from '../spec'; let userAliasSchema = z.object({ @@ -17,6 +18,12 @@ let userIdentifierSchema = z.object({ }); let attributeSchema = userIdentifierSchema.extend({ + updateExistingOnly: z + .boolean() + .optional() + .describe( + 'Maps to _update_existing_only. Set false to allow creating an alias-only user profile.' + ), firstName: z.string().optional().describe('First name'), lastName: z.string().optional().describe('Last name'), email: z.string().optional().describe('Email address'), @@ -42,6 +49,7 @@ let attributeSchema = userIdentifierSchema.extend({ let eventSchema = userIdentifierSchema.extend({ name: z.string().describe('Name of the custom event'), + appId: z.string().optional().describe('Braze app identifier for the event'), time: z.string().describe('ISO 8601 timestamp of the event'), properties: z .record(z.string(), z.any()) @@ -51,6 +59,7 @@ let eventSchema = userIdentifierSchema.extend({ let purchaseSchema = userIdentifierSchema.extend({ productId: z.string().describe('Identifier for the purchased product'), + appId: z.string().optional().describe('Braze app identifier for the purchase'), currency: z.string().describe('ISO 4217 currency code (e.g. USD)'), price: z.number().describe('Price of the purchase'), quantity: z.number().optional().describe('Quantity purchased (defaults to 1)'), @@ -61,17 +70,35 @@ let purchaseSchema = userIdentifierSchema.extend({ .describe('Custom properties for the purchase') }); +let validateUserIdentifier = (item: z.infer, label: string) => { + let primaryCount = + (item.externalId ? 1 : 0) + (item.brazeId ? 1 : 0) + (item.userAlias ? 1 : 0); + + if (primaryCount > 1) { + throw brazeServiceError( + `${label} must include at most one primary identifier: externalId, brazeId, or userAlias.` + ); + } + + if (primaryCount === 0 && !item.email && !item.phone) { + throw brazeServiceError( + `${label} must include at least one identifier: externalId, brazeId, userAlias, email, or phone.` + ); + } +}; + export let trackUsers = SlateTool.create(spec, { name: 'Track Users', key: 'track_users', - description: `Record user attributes, custom events, and purchases to Braze user profiles. Supports batch operations with up to 75 attributes, 75 events, and 75 purchases per call. Users are identified by external ID, Braze ID, user alias, email, or phone.`, + description: `Record user attributes, custom events, and purchases to Braze user profiles. Supports batch operations with up to 75 total objects per request across attributes, events, and purchases. Users are identified by external ID, Braze ID, user alias, email, or phone.`, instructions: [ 'At least one of attributes, events, or purchases must be provided.', 'Each user object must include at least one identifier (externalId, brazeId, userAlias, email, or phone).', + 'Each object can include only one primary identifier: externalId, brazeId, or userAlias.', 'Custom attributes are merged with existing data. Set a custom attribute to null to remove it.' ], constraints: [ - 'Maximum 75 attributes, 75 events, and 75 purchases per request (225 total user updates).', + 'Maximum 75 total attributes, events, and purchases per request on current Braze rate limits.', 'Rate limited to 3,000 requests per 3 seconds.' ], tags: { @@ -107,6 +134,23 @@ export let trackUsers = SlateTool.create(spec, { instanceUrl: ctx.config.instanceUrl }); + let totalObjects = + (ctx.input.attributes?.length ?? 0) + + (ctx.input.events?.length ?? 0) + + (ctx.input.purchases?.length ?? 0); + + if (totalObjects === 0) { + throw brazeServiceError( + 'At least one attribute, event, or purchase object is required.' + ); + } + + if (totalObjects > 75) { + throw brazeServiceError( + 'Braze /users/track accepts at most 75 total objects across attributes, events, and purchases.' + ); + } + let body: { attributes?: Record[]; events?: Record[]; @@ -114,10 +158,13 @@ export let trackUsers = SlateTool.create(spec, { } = {}; if (ctx.input.attributes) { - body.attributes = ctx.input.attributes.map(attr => { + body.attributes = ctx.input.attributes.map((attr, index) => { + validateUserIdentifier(attr, `attributes[${index}]`); let mapped: Record = {}; if (attr.externalId) mapped.external_id = attr.externalId; if (attr.brazeId) mapped.braze_id = attr.brazeId; + if (attr.updateExistingOnly !== undefined) + mapped._update_existing_only = attr.updateExistingOnly; if (attr.userAlias) mapped.user_alias = { alias_name: attr.userAlias.aliasName, @@ -145,10 +192,12 @@ export let trackUsers = SlateTool.create(spec, { } if (ctx.input.events) { - body.events = ctx.input.events.map(evt => { + body.events = ctx.input.events.map((evt, index) => { + validateUserIdentifier(evt, `events[${index}]`); let mapped: Record = { name: evt.name, time: evt.time }; if (evt.externalId) mapped.external_id = evt.externalId; if (evt.brazeId) mapped.braze_id = evt.brazeId; + if (evt.appId) mapped.app_id = evt.appId; if (evt.userAlias) mapped.user_alias = { alias_name: evt.userAlias.aliasName, @@ -162,7 +211,8 @@ export let trackUsers = SlateTool.create(spec, { } if (ctx.input.purchases) { - body.purchases = ctx.input.purchases.map(p => { + body.purchases = ctx.input.purchases.map((p, index) => { + validateUserIdentifier(p, `purchases[${index}]`); let mapped: Record = { product_id: p.productId, currency: p.currency, @@ -171,6 +221,7 @@ export let trackUsers = SlateTool.create(spec, { }; if (p.externalId) mapped.external_id = p.externalId; if (p.brazeId) mapped.braze_id = p.brazeId; + if (p.appId) mapped.app_id = p.appId; if (p.userAlias) mapped.user_alias = { alias_name: p.userAlias.aliasName, diff --git a/integrations/braze/src/tools/trigger-campaign.ts b/integrations/braze/src/tools/trigger-campaign.ts index e288bd32de..6eb3720869 100644 --- a/integrations/braze/src/tools/trigger-campaign.ts +++ b/integrations/braze/src/tools/trigger-campaign.ts @@ -1,8 +1,14 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError } from '../lib/errors'; import { spec } from '../spec'; +let userAliasSchema = z.object({ + aliasName: z.string().describe('Alias name'), + aliasLabel: z.string().describe('Alias label') +}); + export let triggerCampaign = SlateTool.create(spec, { name: 'Trigger Campaign', key: 'trigger_campaign', @@ -30,6 +36,7 @@ export let triggerCampaign = SlateTool.create(spec, { .string() .optional() .describe('External user ID of the recipient'), + userAlias: userAliasSchema.optional().describe('User alias of the recipient'), triggerProperties: z .record(z.string(), z.any()) .optional() @@ -71,6 +78,22 @@ export let triggerCampaign = SlateTool.create(spec, { instanceUrl: ctx.config.instanceUrl }); + if (ctx.input.broadcast && (ctx.input.recipients?.length ?? 0) > 0) { + throw brazeServiceError('broadcast cannot be used with recipients.'); + } + + if (!ctx.input.broadcast && (ctx.input.recipients?.length ?? 0) === 0) { + throw brazeServiceError('Provide recipients or set broadcast to true.'); + } + + for (let [index, recipient] of (ctx.input.recipients ?? []).entries()) { + if (!recipient.externalUserId && !recipient.userAlias) { + throw brazeServiceError( + `recipients[${index}] must include externalUserId or userAlias.` + ); + } + } + let result = await client.triggerCampaignSend({ campaignId: ctx.input.campaignId, recipients: ctx.input.recipients, diff --git a/integrations/braze/src/tools/trigger-canvas.ts b/integrations/braze/src/tools/trigger-canvas.ts index 25575fc5a0..5939c0d4fc 100644 --- a/integrations/braze/src/tools/trigger-canvas.ts +++ b/integrations/braze/src/tools/trigger-canvas.ts @@ -1,8 +1,14 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrazeClient } from '../lib/client'; +import { brazeServiceError } from '../lib/errors'; import { spec } from '../spec'; +let userAliasSchema = z.object({ + aliasName: z.string().describe('Alias name'), + aliasLabel: z.string().describe('Alias label') +}); + export let triggerCanvas = SlateTool.create(spec, { name: 'Trigger Canvas', key: 'trigger_canvas', @@ -30,6 +36,7 @@ export let triggerCanvas = SlateTool.create(spec, { .string() .optional() .describe('External user ID of the recipient'), + userAlias: userAliasSchema.optional().describe('User alias of the recipient'), canvasEntryProperties: z .record(z.string(), z.any()) .optional() @@ -65,6 +72,22 @@ export let triggerCanvas = SlateTool.create(spec, { instanceUrl: ctx.config.instanceUrl }); + if (ctx.input.broadcast && (ctx.input.recipients?.length ?? 0) > 0) { + throw brazeServiceError('broadcast cannot be used with recipients.'); + } + + if (!ctx.input.broadcast && (ctx.input.recipients?.length ?? 0) === 0) { + throw brazeServiceError('Provide recipients or set broadcast to true.'); + } + + for (let [index, recipient] of (ctx.input.recipients ?? []).entries()) { + if (!recipient.externalUserId && !recipient.userAlias) { + throw brazeServiceError( + `recipients[${index}] must include externalUserId or userAlias.` + ); + } + } + let result = await client.triggerCanvasSend({ canvasId: ctx.input.canvasId, recipients: ctx.input.recipients, diff --git a/integrations/braze/vitest.config.ts b/integrations/braze/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/braze/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/brevo/package.json b/integrations/brevo/package.json index c3380fc03a..7b5d22970b 100644 --- a/integrations/brevo/package.json +++ b/integrations/brevo/package.json @@ -7,12 +7,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/brevo/src/auth.ts b/integrations/brevo/src/auth.ts index 47b0a0a890..87054799a9 100644 --- a/integrations/brevo/src/auth.ts +++ b/integrations/brevo/src/auth.ts @@ -1,16 +1,22 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { brevoApiError, brevoServiceError } from './lib/errors'; -let axios = createAxios({ +let apiAxios = createAxios({ baseURL: 'https://api.brevo.com/v3' }); +let oauthAxios = createAxios({ + baseURL: 'https://oauth.brevo.com/realms/partner/oauth' +}); + export let auth = SlateAuth.create() .output( z.object({ token: z.string(), refreshToken: z.string().optional(), expiresAt: z.string().optional(), + scope: z.string().optional(), authType: z.enum(['api_key', 'oauth']).optional() }) ) @@ -30,22 +36,26 @@ export let auth = SlateAuth.create() }; }, getProfile: async (ctx: any) => { - let response = await axios.get('/account', { - headers: { - 'api-key': ctx.output.token - } - }); - let account = response.data; - return { - profile: { - id: String(account.email), - email: account.email, - name: - `${account.firstName ?? ''} ${account.lastName ?? ''}`.trim() || - account.companyName || - account.email - } - }; + try { + let response = await apiAxios.get('/account', { + headers: { + 'api-key': ctx.output.token + } + }); + let account = response.data; + return { + profile: { + id: String(account.email), + email: account.email, + name: + `${account.firstName ?? ''} ${account.lastName ?? ''}`.trim() || + account.companyName || + account.email + } + }; + } catch (error) { + throw brevoApiError(error, 'get profile'); + } } }) .addOauth({ @@ -54,24 +64,64 @@ export let auth = SlateAuth.create() key: 'oauth', scopes: [ { - title: 'OpenID', - description: 'OpenID Connect authentication', - scope: 'openid' + title: 'Account read', + description: 'Read account details and configured senders', + scope: 'account:read' + }, + { + title: 'Contacts read', + description: 'Read contacts, lists, segments, attributes, and folders', + scope: 'contacts:read' + }, + { + title: 'Contacts write', + description: 'Create, update, and delete contacts, lists, and folders', + scope: 'contacts:write' + }, + { + title: 'CRM read', + description: 'Read companies, deals, tasks, notes, and pipelines', + scope: 'crm:read' + }, + { + title: 'CRM write', + description: 'Create, update, and delete companies and deals', + scope: 'crm:write' + }, + { + title: 'Email campaigns read', + description: 'Read email campaigns and statistics', + scope: 'campaigns.email:read' + }, + { + title: 'Email campaigns write', + description: 'Create, update, send, and delete email campaigns', + scope: 'campaigns.email:write' + }, + { + title: 'Transactional email write', + description: 'Send transactional emails', + scope: 'transactional.email:write' + }, + { + title: 'Transactional SMS write', + description: 'Send transactional SMS messages', + scope: 'transactional.sms:write' }, { - title: 'Email', - description: 'Access to email address', - scope: 'email' + title: 'Events write', + description: 'Track contact events', + scope: 'events:write' }, { - title: 'Profile', - description: 'Access to user profile information', - scope: 'profile' + title: 'Webhooks read', + description: 'List webhook subscriptions', + scope: 'webhooks:read' }, { - title: 'Meta Info', - description: 'Access to account meta information', - scope: 'metaInfo' + title: 'Webhooks write', + description: 'Create, update, and delete webhook subscriptions', + scope: 'webhooks:write' } ], getAuthorizationUrl: async ctx => { @@ -83,82 +133,102 @@ export let auth = SlateAuth.create() scope: ctx.scopes.join(' ') }); return { - url: `https://auth.brevo.com/realms/apiv3/protocol/openid-connect/auth?${params.toString()}` + url: `https://oauth.brevo.com/realms/partner/oauth/authorize?${params.toString()}` }; }, handleCallback: async ctx => { - let response = await axios.post( - '/token', - new URLSearchParams({ - grant_type: 'authorization_code', - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - code: ctx.code, - redirect_uri: ctx.redirectUri - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + try { + let response = await oauthAxios.post( + '/token', + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + code: ctx.code, + redirect_uri: ctx.redirectUri + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } } - } - ); - let data = response.data; - let expiresAt = data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined; - return { - output: { - token: data.access_token, - refreshToken: data.refresh_token, - expiresAt, - authType: 'oauth' as const - } - }; + ); + let data = response.data; + let expiresAt = data.expires_in + ? new Date(Date.now() + data.expires_in * 1000).toISOString() + : undefined; + return { + output: { + token: data.access_token, + refreshToken: data.refresh_token, + expiresAt, + scope: data.scope, + authType: 'oauth' as const + } + }; + } catch (error) { + throw brevoApiError(error, 'exchange OAuth authorization code'); + } }, handleTokenRefresh: async (ctx: any) => { - let response = await axios.post( - '/token', - new URLSearchParams({ - grant_type: 'refresh_token', - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - refresh_token: ctx.output.refreshToken ?? '' - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + if (!ctx.output.refreshToken) { + throw brevoServiceError( + 'Brevo OAuth refresh token is missing. Reconnect the account.' + ); + } + + try { + let response = await oauthAxios.post( + '/token', + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + refresh_token: ctx.output.refreshToken + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } } - } - ); - let data = response.data; - let expiresAt = data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined; - return { - output: { - token: data.access_token, - refreshToken: data.refresh_token ?? ctx.output.refreshToken, - expiresAt, - authType: 'oauth' as const - } - }; + ); + let data = response.data; + let expiresAt = data.expires_in + ? new Date(Date.now() + data.expires_in * 1000).toISOString() + : undefined; + return { + output: { + token: data.access_token, + refreshToken: data.refresh_token ?? ctx.output.refreshToken, + expiresAt, + scope: data.scope ?? ctx.output.scope, + authType: 'oauth' as const + } + }; + } catch (error) { + throw brevoApiError(error, 'refresh OAuth token'); + } }, getProfile: async (ctx: any) => { - let response = await axios.get('/account', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); - let account = response.data; - return { - profile: { - id: String(account.email), - email: account.email, - name: - `${account.firstName ?? ''} ${account.lastName ?? ''}`.trim() || - account.companyName || - account.email - } - }; + try { + let response = await apiAxios.get('/account', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); + let account = response.data; + return { + profile: { + id: String(account.email), + email: account.email, + name: + `${account.firstName ?? ''} ${account.lastName ?? ''}`.trim() || + account.companyName || + account.email + } + }; + } catch (error) { + throw brevoApiError(error, 'get profile'); + } } }); diff --git a/integrations/brevo/src/index.ts b/integrations/brevo/src/index.ts index c29eb7ae4a..7578d2972a 100644 --- a/integrations/brevo/src/index.ts +++ b/integrations/brevo/src/index.ts @@ -2,28 +2,50 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { addContactsToList, + createCompany, + createContactFolder, createContactList, createDeal, createEmailCampaign, createOrUpdateContact, + createWebhook, + deleteCompany, deleteContact, + deleteContactFolder, + deleteContactList, deleteDeal, + deleteEmailCampaign, + deleteWebhook, getAccount, + getCompany, getContact, + getContactFolder, + getContactList, getDeal, getEmailCampaign, + getWebhook, + listCompanies, + listContactFolders, listContactLists, + listContactListsInFolder, listContacts, listDeals, listEmailCampaigns, + listPipelines, listSenders, + listWebhooks, removeContactsFromList, sendEmailCampaignNow, sendTransactionalEmail, sendTransactionalSms, trackEvent, + updateCompany, updateContact, - updateDeal + updateContactFolder, + updateContactList, + updateDeal, + updateEmailCampaign, + updateWebhook } from './tools'; import { inboundEmailEvents, @@ -42,19 +64,41 @@ export let provider = Slate.create({ updateContact.build(), deleteContact.build(), listContacts.build(), + listContactFolders.build(), + createContactFolder.build(), + getContactFolder.build(), + updateContactFolder.build(), + deleteContactFolder.build(), + listContactListsInFolder.build(), listContactLists.build(), + getContactList.build(), createContactList.build(), + updateContactList.build(), + deleteContactList.build(), addContactsToList.build(), removeContactsFromList.build(), + createCompany.build(), + getCompany.build(), + updateCompany.build(), + deleteCompany.build(), + listCompanies.build(), + listPipelines.build(), createDeal.build(), getDeal.build(), updateDeal.build(), deleteDeal.build(), listDeals.build(), createEmailCampaign.build(), + updateEmailCampaign.build(), getEmailCampaign.build(), listEmailCampaigns.build(), sendEmailCampaignNow.build(), + deleteEmailCampaign.build(), + createWebhook.build(), + listWebhooks.build(), + getWebhook.build(), + updateWebhook.build(), + deleteWebhook.build(), getAccount.build(), listSenders.build(), trackEvent.build() diff --git a/integrations/brevo/src/lib/client.ts b/integrations/brevo/src/lib/client.ts index 1a4d191787..b425b11223 100644 --- a/integrations/brevo/src/lib/client.ts +++ b/integrations/brevo/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { brevoApiError } from './errors'; export class Client { private axios: ReturnType; @@ -9,6 +10,7 @@ export class Client { }); this.axios.interceptors.request.use((reqConfig: any) => { + reqConfig.headers ??= {}; if (config.authType === 'oauth') { reqConfig.headers.Authorization = `Bearer ${config.token}`; } else { @@ -17,6 +19,11 @@ export class Client { reqConfig.headers.Accept = 'application/json'; return reqConfig; }); + + this.axios.interceptors.response.use( + (response: any) => response, + (error: unknown) => Promise.reject(brevoApiError(error)) + ); } // ---- Account ---- @@ -36,6 +43,8 @@ export class Client { emailBlacklisted?: boolean; smsBlacklisted?: boolean; updateEnabled?: boolean; + forceMerge?: boolean; + getId?: boolean; }): Promise<{ contactId: number }> { let body: Record = {}; if (params.email) body.email = params.email; @@ -45,6 +54,8 @@ export class Client { if (params.emailBlacklisted !== undefined) body.emailBlacklisted = params.emailBlacklisted; if (params.smsBlacklisted !== undefined) body.smsBlacklisted = params.smsBlacklisted; if (params.updateEnabled !== undefined) body.updateEnabled = params.updateEnabled; + if (params.forceMerge !== undefined) body.forceMerge = params.forceMerge; + if (params.getId !== undefined) body.getId = params.getId; let response = await this.axios.post('/contacts', body); return { contactId: response.data.id }; @@ -269,6 +280,26 @@ export class Client { return { campaignId: response.data.id }; } + async updateEmailCampaign( + campaignId: number, + params: { + name?: string; + sender?: { name?: string; email?: string; id?: number }; + subject?: string; + htmlContent?: string; + htmlUrl?: string; + templateId?: number; + scheduledAt?: string; + replyTo?: string; + recipients?: { listIds?: number[]; exclusionListIds?: number[] }; + tag?: string; + inlineImageActivation?: boolean; + params?: Record; + } + ): Promise { + await this.axios.put(`/emailCampaigns/${campaignId}`, params); + } + async getEmailCampaign(campaignId: number): Promise { let response = await this.axios.get(`/emailCampaigns/${campaignId}`); return response.data; @@ -302,6 +333,10 @@ export class Client { await this.axios.post(`/emailCampaigns/${campaignId}/sendNow`); } + async deleteEmailCampaign(campaignId: number): Promise { + await this.axios.delete(`/emailCampaigns/${campaignId}`); + } + async sendTestEmail(campaignId: number, emailTo: string[]): Promise { await this.axios.post(`/emailCampaigns/${campaignId}/sendTest`, { emailTo }); } @@ -369,6 +404,7 @@ export class Client { async createCompany(params: { name: string; attributes?: Record; + countryCode?: number; linkedContactsIds?: number[]; linkedDealsIds?: string[]; }): Promise<{ companyId: string }> { @@ -376,6 +412,32 @@ export class Client { return { companyId: response.data.id }; } + async listCompanies(params: { + limit?: number; + page?: number; + sort?: string; + sortBy?: string; + modifiedSince?: string; + createdSince?: string; + name?: string; + linkedContactId?: number; + linkedDealId?: string; + }): Promise { + let query: Record = {}; + if (params.limit !== undefined) query.limit = params.limit; + if (params.page !== undefined) query.page = params.page; + if (params.sort) query.sort = params.sort; + if (params.sortBy) query.sortBy = params.sortBy; + if (params.modifiedSince) query.modifiedSince = params.modifiedSince; + if (params.createdSince) query.createdSince = params.createdSince; + if (params.name) query['filters[attributes.name]'] = params.name; + if (params.linkedContactId !== undefined) query.linkedContactsIds = params.linkedContactId; + if (params.linkedDealId) query.linkedDealsIds = params.linkedDealId; + + let response = await this.axios.get('/companies', { params: query }); + return response.data; + } + async getCompany(companyId: string): Promise { let response = await this.axios.get(`/companies/${companyId}`); return response.data; @@ -421,6 +483,11 @@ export class Client { return response.data; } + async getWebhook(webhookId: number): Promise { + let response = await this.axios.get(`/webhooks/${webhookId}`); + return response.data; + } + async deleteWebhook(webhookId: number): Promise { await this.axios.delete(`/webhooks/${webhookId}`); } @@ -430,7 +497,13 @@ export class Client { params: { url?: string; events?: string[]; + type?: string; + channel?: string; description?: string; + batched?: boolean; + auth?: { type: string; token: string }; + headers?: { key: string; value: string }[]; + domain?: string; } ): Promise { await this.axios.put(`/webhooks/${webhookId}`, params); @@ -447,11 +520,35 @@ export class Client { async trackEvent(params: { email?: string; + contactId?: number; + extId?: string; + phone?: string; + whatsapp?: string; + landlineNumber?: string; eventName: string; - eventData?: Record; - properties?: Record; + eventDate?: string; + contactProperties?: Record; + eventProperties?: Record; + object?: Record; }): Promise { - await this.axios.post('/events', params); + let identifiers: Record = {}; + if (params.email) identifiers.email_id = params.email; + if (params.contactId !== undefined) identifiers.contact_id = params.contactId; + if (params.extId) identifiers.ext_id = params.extId; + if (params.phone) identifiers.phone_id = params.phone; + if (params.whatsapp) identifiers.whatsapp_id = params.whatsapp; + if (params.landlineNumber) identifiers.landline_number_id = params.landlineNumber; + + let body: Record = { + event_name: params.eventName, + identifiers + }; + if (params.contactProperties) body.contact_properties = params.contactProperties; + if (params.eventDate) body.event_date = params.eventDate; + if (params.eventProperties) body.event_properties = params.eventProperties; + if (params.object) body.object = params.object; + + await this.axios.post('/events', body); } // ---- Folders ---- @@ -471,4 +568,34 @@ export class Client { let response = await this.axios.post('/contacts/folders', { name }); return { folderId: response.data.id }; } + + async getFolder(folderId: number): Promise { + let response = await this.axios.get(`/contacts/folders/${folderId}`); + return response.data; + } + + async updateFolder(folderId: number, name: string): Promise { + await this.axios.put(`/contacts/folders/${folderId}`, { name }); + } + + async deleteFolder(folderId: number): Promise { + await this.axios.delete(`/contacts/folders/${folderId}`); + } + + async getListsInFolder(params: { + folderId: number; + limit?: number; + offset?: number; + sort?: string; + }): Promise<{ lists: any[]; count: number }> { + let query: Record = {}; + if (params.limit !== undefined) query.limit = params.limit; + if (params.offset !== undefined) query.offset = params.offset; + if (params.sort) query.sort = params.sort; + + let response = await this.axios.get(`/contacts/folders/${params.folderId}/lists`, { + params: query + }); + return response.data; + } } diff --git a/integrations/brevo/src/lib/errors.ts b/integrations/brevo/src/lib/errors.ts new file mode 100644 index 0000000000..b4d78d4ef4 --- /dev/null +++ b/integrations/brevo/src/lib/errors.ts @@ -0,0 +1,98 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.error_description); + pushDetail(details, value.error); + pushDetail(details, value.code); + pushDetail(details, value.reason); + collectDetails(value.errors, details); +}; + +let extractBrevoMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + if (typeof response.data.code === 'string') return response.data.code; + if (typeof response.data.error === 'string') return response.data.error; + + return undefined; +}; + +export let brevoServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let brevoApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = brevoServiceError( + `Brevo API ${operation} failed: ${statusLabelFor(response)}${extractBrevoMessage(error)}` + ); + serviceError.data.reason = 'brevo_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/brevo/src/tools.schema.test.ts b/integrations/brevo/src/tools.schema.test.ts new file mode 100644 index 0000000000..4ba86bbb6c --- /dev/null +++ b/integrations/brevo/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Brevo tool input schemas', provider.actions); diff --git a/integrations/brevo/src/tools/index.ts b/integrations/brevo/src/tools/index.ts index 02f79c3cff..8fee3d3e40 100644 --- a/integrations/brevo/src/tools/index.ts +++ b/integrations/brevo/src/tools/index.ts @@ -1,9 +1,13 @@ export * from './get-account'; +export * from './list-pipelines'; export * from './list-senders'; +export * from './manage-company'; export * from './manage-contact'; +export * from './manage-contact-folder'; export * from './manage-contact-list'; export * from './manage-deal'; export * from './manage-email-campaign'; +export * from './manage-webhook'; export * from './send-transactional-email'; export * from './send-transactional-sms'; export * from './track-event'; diff --git a/integrations/brevo/src/tools/list-pipelines.ts b/integrations/brevo/src/tools/list-pipelines.ts new file mode 100644 index 0000000000..c7ef72004a --- /dev/null +++ b/integrations/brevo/src/tools/list-pipelines.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listPipelines = SlateTool.create(spec, { + name: 'List Pipelines', + key: 'list_pipelines', + description: `Retrieve all Brevo CRM deal pipelines and stages. Use this before creating or updating deals with pipeline or stage attributes.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + pipelines: z + .array( + z.object({ + pipelineId: z.string().describe('Pipeline ID'), + name: z.string().describe('Pipeline name'), + stages: z + .array( + z.object({ + stageId: z.string().describe('Stage ID'), + name: z.string().describe('Stage name') + }) + ) + .describe('Pipeline stages') + }) + ) + .describe('Deal pipelines') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let result = await client.listPipelines(); + let pipelines = (result ?? []).map((pipeline: any) => ({ + pipelineId: pipeline.pipeline, + name: pipeline.pipeline_name, + stages: (pipeline.stages ?? []).map((stage: any) => ({ + stageId: stage.id, + name: stage.name + })) + })); + + return { + output: { pipelines }, + message: `Retrieved **${pipelines.length}** pipelines.` + }; + }); diff --git a/integrations/brevo/src/tools/manage-company.ts b/integrations/brevo/src/tools/manage-company.ts new file mode 100644 index 0000000000..387385fd82 --- /dev/null +++ b/integrations/brevo/src/tools/manage-company.ts @@ -0,0 +1,247 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let companyOutputSchema = z.object({ + companyId: z.string().describe('Company ID'), + attributes: z.record(z.string(), z.any()).optional().describe('Company attributes'), + linkedContactIds: z.array(z.number()).optional().describe('Linked contact IDs'), + linkedDealIds: z.array(z.string()).optional().describe('Linked deal IDs') +}); + +let mapCompany = (company: any) => ({ + companyId: company.id, + attributes: company.attributes, + linkedContactIds: company.linkedContactsIds, + linkedDealIds: company.linkedDealsIds +}); + +export let createCompany = SlateTool.create(spec, { + name: 'Create Company', + key: 'create_company', + description: `Create a new company in the Brevo CRM. Optionally set attributes and link contacts or deals.`, + instructions: [ + 'Use attributes.name for the company display name if you need it available in list results.', + 'If you pass a phone_number attribute, countryCode can help Brevo normalize the phone number.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + name: z.string().describe('Company name'), + attributes: z.record(z.string(), z.any()).optional().describe('Company attributes'), + countryCode: z + .number() + .optional() + .describe('Country code used when phone_number is passed in attributes'), + linkedContactIds: z.array(z.number()).optional().describe('Contact IDs to link'), + linkedDealIds: z.array(z.string()).optional().describe('Deal IDs to link') + }) + ) + .output( + z.object({ + companyId: z.string().describe('ID of the newly created company') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let result = await client.createCompany({ + name: ctx.input.name, + attributes: ctx.input.attributes, + countryCode: ctx.input.countryCode, + linkedContactsIds: ctx.input.linkedContactIds, + linkedDealsIds: ctx.input.linkedDealIds + }); + + return { + output: result, + message: `Company **${ctx.input.name}** created. Company ID: **${result.companyId}**` + }; + }); + +export let getCompany = SlateTool.create(spec, { + name: 'Get Company', + key: 'get_company', + description: `Retrieve details of a Brevo CRM company, including attributes and linked contacts or deals.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + companyId: z.string().describe('ID of the company to retrieve') + }) + ) + .output(companyOutputSchema) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let company = await client.getCompany(ctx.input.companyId); + + return { + output: mapCompany(company), + message: `Retrieved company **${company.attributes?.name || company.id}**.` + }; + }); + +export let updateCompany = SlateTool.create(spec, { + name: 'Update Company', + key: 'update_company', + description: `Update a Brevo CRM company's name, attributes, or linked contacts and deals. +Updating linkedContactIds or linkedDealIds replaces the entire association list.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + companyId: z.string().describe('ID of the company to update'), + name: z.string().optional().describe('New company name'), + attributes: z.record(z.string(), z.any()).optional().describe('Attributes to update'), + linkedContactIds: z + .array(z.number()) + .optional() + .describe('Full list of contact IDs to link'), + linkedDealIds: z.array(z.string()).optional().describe('Full list of deal IDs to link') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the update completed successfully') + }) + ) + .handleInvocation(async ctx => { + if ( + !ctx.input.name && + !ctx.input.attributes && + !ctx.input.linkedContactIds && + !ctx.input.linkedDealIds + ) { + throw brevoServiceError( + 'Provide at least one of name, attributes, linkedContactIds, or linkedDealIds.' + ); + } + + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.updateCompany(ctx.input.companyId, { + name: ctx.input.name, + attributes: ctx.input.attributes, + linkedContactsIds: ctx.input.linkedContactIds, + linkedDealsIds: ctx.input.linkedDealIds + }); + + return { + output: { success: true }, + message: `Company **${ctx.input.companyId}** updated successfully.` + }; + }); + +export let deleteCompany = SlateTool.create(spec, { + name: 'Delete Company', + key: 'delete_company', + description: `Permanently delete a Brevo CRM company. This action is irreversible.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + companyId: z.string().describe('ID of the company to delete') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the deletion completed successfully') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.deleteCompany(ctx.input.companyId); + + return { + output: { success: true }, + message: `Company **${ctx.input.companyId}** deleted permanently.` + }; + }); + +export let listCompanies = SlateTool.create(spec, { + name: 'List Companies', + key: 'list_companies', + description: `Retrieve Brevo CRM companies with optional filtering by name, linked contact, linked deal, creation date, or modification date.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + limit: z.number().optional().describe('Number of companies per page'), + page: z.number().optional().describe('Page index'), + sort: z.enum(['asc', 'desc']).optional().describe('Sort order'), + sortBy: z.string().optional().describe('Attribute used for sorting'), + modifiedSince: z + .string() + .optional() + .describe('Filter companies modified after this UTC date-time'), + createdSince: z + .string() + .optional() + .describe('Filter companies created after this UTC date-time'), + name: z.string().optional().describe('Filter by company name'), + linkedContactId: z.number().optional().describe('Filter by linked contact ID'), + linkedDealId: z.string().optional().describe('Filter by linked deal ID') + }) + ) + .output( + z.object({ + companies: z.array(companyOutputSchema).describe('List of companies') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let result = await client.listCompanies({ + limit: ctx.input.limit, + page: ctx.input.page, + sort: ctx.input.sort, + sortBy: ctx.input.sortBy, + modifiedSince: ctx.input.modifiedSince, + createdSince: ctx.input.createdSince, + name: ctx.input.name, + linkedContactId: ctx.input.linkedContactId, + linkedDealId: ctx.input.linkedDealId + }); + + let companies = (result.items ?? []).map(mapCompany); + + return { + output: { companies }, + message: `Retrieved **${companies.length}** companies.` + }; + }); diff --git a/integrations/brevo/src/tools/manage-contact-folder.ts b/integrations/brevo/src/tools/manage-contact-folder.ts new file mode 100644 index 0000000000..2e5019a3b1 --- /dev/null +++ b/integrations/brevo/src/tools/manage-contact-folder.ts @@ -0,0 +1,252 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let folderOutputSchema = z.object({ + folderId: z.number().describe('Folder ID'), + name: z.string().describe('Folder name'), + totalBlacklisted: z.number().optional().describe('Number of blacklisted contacts'), + totalSubscribers: z.number().optional().describe('Total subscriber count'), + uniqueSubscribers: z.number().optional().describe('Unique subscriber count') +}); + +let listOutputSchema = z.object({ + listId: z.number().describe('List ID'), + name: z.string().describe('List name'), + totalSubscribers: z.number().describe('Total subscriber count'), + uniqueSubscribers: z.number().describe('Unique subscriber count'), + folderId: z.number().describe('Associated folder ID') +}); + +let mapFolder = (folder: any) => ({ + folderId: folder.id, + name: folder.name, + totalBlacklisted: folder.totalBlacklisted, + totalSubscribers: folder.totalSubscribers, + uniqueSubscribers: folder.uniqueSubscribers +}); + +let mapList = (list: any) => ({ + listId: list.id, + name: list.name, + totalSubscribers: list.totalSubscribers ?? 0, + uniqueSubscribers: list.uniqueSubscribers ?? 0, + folderId: list.folderId +}); + +export let listContactFolders = SlateTool.create(spec, { + name: 'List Contact Folders', + key: 'list_contact_folders', + description: `Retrieve Brevo contact folders. Folders organize contact lists and are required when creating new lists.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + limit: z.number().optional().describe('Number of folders per page'), + offset: z.number().optional().describe('Index of the first folder') + }) + ) + .output( + z.object({ + folders: z.array(folderOutputSchema).describe('Contact folders'), + count: z.number().describe('Total number of folders') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let result = await client.listFolders({ + limit: ctx.input.limit, + offset: ctx.input.offset + }); + let folders = (result.folders ?? []).map(mapFolder); + + return { + output: { folders, count: result.count }, + message: `Retrieved **${folders.length}** contact folders (${result.count} total).` + }; + }); + +export let createContactFolder = SlateTool.create(spec, { + name: 'Create Contact Folder', + key: 'create_contact_folder', + description: `Create a Brevo contact folder for organizing contact lists.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + name: z.string().describe('Folder name') + }) + ) + .output( + z.object({ + folderId: z.number().describe('ID of the newly created folder') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let result = await client.createFolder(ctx.input.name); + + return { + output: result, + message: `Contact folder **${ctx.input.name}** created. Folder ID: **${result.folderId}**` + }; + }); + +export let getContactFolder = SlateTool.create(spec, { + name: 'Get Contact Folder', + key: 'get_contact_folder', + description: `Retrieve details for a specific Brevo contact folder.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + folderId: z.number().describe('ID of the contact folder to retrieve') + }) + ) + .output(folderOutputSchema) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let folder = await client.getFolder(ctx.input.folderId); + + return { + output: mapFolder(folder), + message: `Retrieved contact folder **${folder.name}**.` + }; + }); + +export let updateContactFolder = SlateTool.create(spec, { + name: 'Update Contact Folder', + key: 'update_contact_folder', + description: `Rename a Brevo contact folder.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + folderId: z.number().describe('ID of the contact folder to update'), + name: z.string().describe('New folder name') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the update completed successfully') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.updateFolder(ctx.input.folderId, ctx.input.name); + + return { + output: { success: true }, + message: `Contact folder **${ctx.input.folderId}** updated successfully.` + }; + }); + +export let deleteContactFolder = SlateTool.create(spec, { + name: 'Delete Contact Folder', + key: 'delete_contact_folder', + description: `Delete a Brevo contact folder and all lists inside it. Contacts themselves are not deleted.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + folderId: z.number().describe('ID of the contact folder to delete') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the deletion completed successfully') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.deleteFolder(ctx.input.folderId); + + return { + output: { success: true }, + message: `Contact folder **${ctx.input.folderId}** deleted successfully.` + }; + }); + +export let listContactListsInFolder = SlateTool.create(spec, { + name: 'List Contact Lists in Folder', + key: 'list_contact_lists_in_folder', + description: `Retrieve the contact lists inside a specific Brevo folder.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + folderId: z.number().describe('ID of the contact folder'), + limit: z.number().optional().describe('Number of lists per page'), + offset: z.number().optional().describe('Index of the first list'), + sort: z.enum(['asc', 'desc']).optional().describe('Sort order by creation date') + }) + ) + .output( + z.object({ + lists: z.array(listOutputSchema).describe('Contact lists in the folder'), + count: z.number().describe('Total number of lists in the folder') + }) + ) + .handleInvocation(async ctx => { + if (ctx.input.limit !== undefined && ctx.input.limit < 0) { + throw brevoServiceError('limit must be greater than or equal to 0.'); + } + + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let result = await client.getListsInFolder({ + folderId: ctx.input.folderId, + limit: ctx.input.limit, + offset: ctx.input.offset, + sort: ctx.input.sort + }); + let lists = (result.lists ?? []).map(mapList); + + return { + output: { lists, count: result.count }, + message: `Retrieved **${lists.length}** lists from folder **${ctx.input.folderId}**.` + }; + }); diff --git a/integrations/brevo/src/tools/manage-contact-list.ts b/integrations/brevo/src/tools/manage-contact-list.ts index 9db297d146..fcc39b2ef4 100644 --- a/integrations/brevo/src/tools/manage-contact-list.ts +++ b/integrations/brevo/src/tools/manage-contact-list.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listContactLists = SlateTool.create(spec, { @@ -61,6 +62,49 @@ export let listContactLists = SlateTool.create(spec, { }; }); +export let getContactList = SlateTool.create(spec, { + name: 'Get Contact List', + key: 'get_contact_list', + description: `Retrieve details for a specific Brevo contact list, including folder association and subscriber counts.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + listId: z.number().describe('ID of the contact list to retrieve') + }) + ) + .output( + z.object({ + listId: z.number().describe('List ID'), + name: z.string().describe('List name'), + totalSubscribers: z.number().describe('Total subscriber count'), + uniqueSubscribers: z.number().describe('Unique subscriber count'), + folderId: z.number().describe('Associated folder ID') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let list = await client.getContactList(ctx.input.listId); + + return { + output: { + listId: list.id, + name: list.name, + totalSubscribers: list.totalSubscribers ?? 0, + uniqueSubscribers: list.uniqueSubscribers ?? 0, + folderId: list.folderId + }, + message: `Retrieved contact list **${list.name}**.` + }; + }); + export let createContactList = SlateTool.create(spec, { name: 'Create Contact List', key: 'create_contact_list', @@ -98,6 +142,81 @@ export let createContactList = SlateTool.create(spec, { }; }); +export let updateContactList = SlateTool.create(spec, { + name: 'Update Contact List', + key: 'update_contact_list', + description: `Update a Brevo contact list's name or move it to another folder.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + listId: z.number().describe('ID of the contact list to update'), + name: z.string().optional().describe('New list name'), + folderId: z.number().optional().describe('New parent folder ID') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the update completed successfully') + }) + ) + .handleInvocation(async ctx => { + if (!ctx.input.name && ctx.input.folderId === undefined) { + throw brevoServiceError('Provide at least one of name or folderId.'); + } + + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.updateContactList(ctx.input.listId, { + name: ctx.input.name, + folderId: ctx.input.folderId + }); + + return { + output: { success: true }, + message: `Contact list **${ctx.input.listId}** updated successfully.` + }; + }); + +export let deleteContactList = SlateTool.create(spec, { + name: 'Delete Contact List', + key: 'delete_contact_list', + description: `Delete a Brevo contact list. Contacts are not deleted, but the list itself is removed.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + listId: z.number().describe('ID of the contact list to delete') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the deletion completed successfully') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.deleteContactList(ctx.input.listId); + + return { + output: { success: true }, + message: `Contact list **${ctx.input.listId}** deleted successfully.` + }; + }); + export let addContactsToList = SlateTool.create(spec, { name: 'Add Contacts to List', key: 'add_contacts_to_list', @@ -120,6 +239,10 @@ export let addContactsToList = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (!ctx.input.emails?.length && !ctx.input.contactIds?.length) { + throw brevoServiceError('Provide at least one email or contactId to add.'); + } + let client = new Client({ token: ctx.auth.token, authType: ctx.auth.authType @@ -159,6 +282,10 @@ export let removeContactsFromList = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (!ctx.input.emails?.length && !ctx.input.contactIds?.length) { + throw brevoServiceError('Provide at least one email or contactId to remove.'); + } + let client = new Client({ token: ctx.auth.token, authType: ctx.auth.authType diff --git a/integrations/brevo/src/tools/manage-contact.ts b/integrations/brevo/src/tools/manage-contact.ts index 13c171f985..c7763871bd 100644 --- a/integrations/brevo/src/tools/manage-contact.ts +++ b/integrations/brevo/src/tools/manage-contact.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createOrUpdateContact = SlateTool.create(spec, { @@ -38,7 +39,17 @@ When updating, identify the contact by email, contact ID, or external ID.`, updateEnabled: z .boolean() .optional() - .describe('If true, update existing contact if already present') + .describe('If true, update existing contact if already present'), + forceMerge: z + .boolean() + .optional() + .describe( + 'If true, merge with an existing contact sharing email, SMS, external ID, WhatsApp, or landline identifiers' + ), + getId: z + .boolean() + .optional() + .describe('If true, return the surviving contact ID after a force merge') }) ) .output( @@ -47,6 +58,10 @@ When updating, identify the contact by email, contact ID, or external ID.`, }) ) .handleInvocation(async ctx => { + if (!ctx.input.email && !ctx.input.extId && !ctx.input.attributes?.SMS) { + throw brevoServiceError('Provide at least one of email, extId, or attributes.SMS.'); + } + let client = new Client({ token: ctx.auth.token, authType: ctx.auth.authType @@ -59,7 +74,9 @@ When updating, identify the contact by email, contact ID, or external ID.`, listIds: ctx.input.listIds, emailBlacklisted: ctx.input.emailBlacklisted, smsBlacklisted: ctx.input.smsBlacklisted, - updateEnabled: ctx.input.updateEnabled + updateEnabled: ctx.input.updateEnabled, + forceMerge: ctx.input.forceMerge, + getId: ctx.input.getId }); let action = ctx.input.updateEnabled ? 'created/updated' : 'created'; @@ -312,6 +329,10 @@ export let listContacts = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.segmentId !== undefined && ctx.input.listIds?.length) { + throw brevoServiceError('segmentId and listIds are mutually exclusive.'); + } + let client = new Client({ token: ctx.auth.token, authType: ctx.auth.authType diff --git a/integrations/brevo/src/tools/manage-deal.ts b/integrations/brevo/src/tools/manage-deal.ts index fd5238f4a3..73b6e6c265 100644 --- a/integrations/brevo/src/tools/manage-deal.ts +++ b/integrations/brevo/src/tools/manage-deal.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createDeal = SlateTool.create(spec, { @@ -39,6 +40,17 @@ export let createDeal = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if ( + !ctx.input.name && + !ctx.input.attributes && + !ctx.input.linkedContactIds && + !ctx.input.linkedCompanyIds + ) { + throw brevoServiceError( + 'Provide at least one of name, attributes, linkedContactIds, or linkedCompanyIds.' + ); + } + let client = new Client({ token: ctx.auth.token, authType: ctx.auth.authType diff --git a/integrations/brevo/src/tools/manage-email-campaign.ts b/integrations/brevo/src/tools/manage-email-campaign.ts index 340d384279..53cccdcd1f 100644 --- a/integrations/brevo/src/tools/manage-email-campaign.ts +++ b/integrations/brevo/src/tools/manage-email-campaign.ts @@ -1,8 +1,62 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; import { spec } from '../spec'; +let buildSender = (input: { + senderId?: number; + senderEmail?: string; + senderName?: string; +}) => { + let hasSenderId = input.senderId !== undefined; + let hasSenderEmail = Boolean(input.senderEmail); + + if (hasSenderId === hasSenderEmail) { + throw brevoServiceError('Provide exactly one of senderId or senderEmail.'); + } + + let sender: { name?: string; email?: string; id?: number } = {}; + if (input.senderId !== undefined) { + sender.id = input.senderId; + } else { + sender.email = input.senderEmail; + if (input.senderName) sender.name = input.senderName; + } + + return sender; +}; + +let buildRecipients = (input: { + recipientListIds?: number[]; + exclusionListIds?: number[]; +}) => { + if (!input.recipientListIds && !input.exclusionListIds) { + return undefined; + } + + return { + listIds: input.recipientListIds, + exclusionListIds: input.exclusionListIds + }; +}; + +let assertSingleContentSource = (input: { + htmlContent?: string; + htmlUrl?: string; + templateId?: number; +}) => { + let contentSources = [ + Boolean(input.htmlContent), + Boolean(input.htmlUrl), + input.templateId !== undefined + ].filter(Boolean).length; + + if (contentSources !== 1) { + throw brevoServiceError('Provide exactly one of htmlContent, htmlUrl, or templateId.'); + } +}; + export let createEmailCampaign = SlateTool.create(spec, { name: 'Create Email Campaign', key: 'create_email_campaign', @@ -44,26 +98,15 @@ export let createEmailCampaign = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + assertSingleContentSource(ctx.input); + let client = new Client({ token: ctx.auth.token, authType: ctx.auth.authType }); - let sender: { name?: string; email?: string; id?: number } = {}; - if (ctx.input.senderId) { - sender.id = ctx.input.senderId; - } else { - if (ctx.input.senderEmail) sender.email = ctx.input.senderEmail; - if (ctx.input.senderName) sender.name = ctx.input.senderName; - } - - let recipients: { listIds?: number[]; exclusionListIds?: number[] } | undefined; - if (ctx.input.recipientListIds || ctx.input.exclusionListIds) { - recipients = { - listIds: ctx.input.recipientListIds, - exclusionListIds: ctx.input.exclusionListIds - }; - } + let sender = buildSender(ctx.input); + let recipients = buildRecipients(ctx.input); let result = await client.createEmailCampaign({ name: ctx.input.name, @@ -85,6 +128,105 @@ export let createEmailCampaign = SlateTool.create(spec, { }; }); +export let updateEmailCampaign = SlateTool.create(spec, { + name: 'Update Email Campaign', + key: 'update_email_campaign', + description: `Update an existing draft or scheduled Brevo email campaign's name, sender, subject, content, recipients, schedule, reply-to, tag, or template parameters.`, + instructions: [ + 'Only draft or scheduled campaigns can be modified.', + 'If changing campaign content, provide exactly one of htmlContent, htmlUrl, or templateId.', + 'If changing sender, provide exactly one of senderEmail or senderId.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + campaignId: z.number().describe('ID of the campaign to update'), + name: z.string().optional().describe('New campaign name'), + senderEmail: z.string().optional().describe('Verified sender email address'), + senderName: z.string().optional().describe('Sender display name'), + senderId: z.number().optional().describe('Sender ID'), + subject: z.string().optional().describe('Email subject line'), + htmlContent: z.string().optional().describe('HTML email content'), + htmlUrl: z.string().optional().describe('URL to fetch HTML content from'), + templateId: z.number().optional().describe('Template ID to use'), + recipientListIds: z.array(z.number()).optional().describe('Contact list IDs to send to'), + exclusionListIds: z.array(z.number()).optional().describe('Contact list IDs to exclude'), + scheduledAt: z.string().optional().describe('ISO 8601 date-time to schedule sending'), + replyTo: z.string().optional().describe('Reply-to email address'), + tag: z.string().optional().describe('Campaign tag'), + templateParams: z + .record(z.string(), z.any()) + .optional() + .describe('Dynamic template parameters') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the update completed successfully') + }) + ) + .handleInvocation(async ctx => { + let hasContentUpdate = + Boolean(ctx.input.htmlContent) || + Boolean(ctx.input.htmlUrl) || + ctx.input.templateId !== undefined; + if (hasContentUpdate) { + assertSingleContentSource(ctx.input); + } + + if (ctx.input.senderName && ctx.input.senderId === undefined && !ctx.input.senderEmail) { + throw brevoServiceError('senderName can only be used with senderEmail.'); + } + + let sender = + ctx.input.senderId !== undefined || ctx.input.senderEmail + ? buildSender(ctx.input) + : undefined; + let recipients = buildRecipients(ctx.input); + + if ( + !ctx.input.name && + !sender && + !ctx.input.subject && + !hasContentUpdate && + !recipients && + !ctx.input.scheduledAt && + !ctx.input.replyTo && + !ctx.input.tag && + !ctx.input.templateParams + ) { + throw brevoServiceError('Provide at least one campaign field to update.'); + } + + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.updateEmailCampaign(ctx.input.campaignId, { + name: ctx.input.name, + sender, + subject: ctx.input.subject, + htmlContent: ctx.input.htmlContent, + htmlUrl: ctx.input.htmlUrl, + templateId: ctx.input.templateId, + scheduledAt: ctx.input.scheduledAt, + replyTo: ctx.input.replyTo, + recipients, + tag: ctx.input.tag, + params: ctx.input.templateParams + }); + + return { + output: { success: true }, + message: `Email campaign **${ctx.input.campaignId}** updated successfully.` + }; + }); + export let getEmailCampaign = SlateTool.create(spec, { name: 'Get Email Campaign', key: 'get_email_campaign', @@ -263,3 +405,36 @@ export let sendEmailCampaignNow = SlateTool.create(spec, { message: `Campaign **${ctx.input.campaignId}** is now being sent.` }; }); + +export let deleteEmailCampaign = SlateTool.create(spec, { + name: 'Delete Email Campaign', + key: 'delete_email_campaign', + description: `Delete a Brevo email campaign that has not been scheduled or sent.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + campaignId: z.number().describe('ID of the campaign to delete') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the deletion completed successfully') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.deleteEmailCampaign(ctx.input.campaignId); + + return { + output: { success: true }, + message: `Email campaign **${ctx.input.campaignId}** deleted successfully.` + }; + }); diff --git a/integrations/brevo/src/tools/manage-webhook.ts b/integrations/brevo/src/tools/manage-webhook.ts new file mode 100644 index 0000000000..c56e80f23c --- /dev/null +++ b/integrations/brevo/src/tools/manage-webhook.ts @@ -0,0 +1,291 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let webhookEventSchema = z.enum([ + 'sent', + 'request', + 'delivered', + 'hardBounce', + 'softBounce', + 'blocked', + 'spam', + 'invalid', + 'deferred', + 'click', + 'opened', + 'uniqueOpened', + 'unsubscribed', + 'listAddition', + 'contactUpdated', + 'contactDeleted', + 'inboundEmailProcessed' +]); + +let webhookTypeSchema = z.enum(['transactional', 'marketing', 'inbound']); +let webhookChannelSchema = z.enum(['email', 'sms']); + +let webhookOutputSchema = z.object({ + webhookId: z.number().describe('Webhook ID'), + url: z.string().optional().describe('Webhook URL'), + type: z.string().optional().describe('Webhook type'), + channel: z.string().optional().describe('Webhook channel'), + description: z.string().optional().describe('Webhook description'), + events: z.array(z.string()).optional().describe('Subscribed events'), + batched: z.boolean().optional().describe('Whether webhooks are batched'), + domain: z.string().optional().describe('Inbound domain') +}); + +let webhookInputFields = { + url: z.string().describe('Webhook endpoint URL'), + type: webhookTypeSchema.optional().describe('Webhook type (default: transactional)'), + channel: webhookChannelSchema.optional().describe('Webhook channel (default: email)'), + events: z + .array(webhookEventSchema) + .optional() + .describe( + 'Events that trigger the webhook. Required for transactional and marketing webhooks; inbound defaults to inboundEmailProcessed.' + ), + description: z.string().optional().describe('Webhook description'), + batched: z.boolean().optional().describe('Whether Brevo should send batched webhooks'), + authType: z + .enum(['basic', 'bearer']) + .optional() + .describe('Authentication type Brevo should use when calling the webhook URL'), + authToken: z.string().optional().describe('Authentication token or credential value'), + headers: z + .array( + z.object({ + key: z.string().describe('Header name'), + value: z.string().describe('Header value') + }) + ) + .optional() + .describe('Custom headers Brevo should include with webhook requests'), + domain: z.string().optional().describe('Inbound domain, required for inbound webhooks') +}; + +let mapWebhook = (webhook: any) => ({ + webhookId: webhook.id, + url: webhook.url, + type: webhook.type, + channel: webhook.channel, + description: webhook.description, + events: webhook.events, + batched: webhook.batched, + domain: webhook.domain +}); + +let buildWebhookPayload = ( + input: { + url?: string; + type?: 'transactional' | 'marketing' | 'inbound'; + channel?: 'email' | 'sms'; + events?: string[]; + description?: string; + batched?: boolean; + authType?: 'basic' | 'bearer'; + authToken?: string; + headers?: { key: string; value: string }[]; + domain?: string; + }, + options: { requireCreateFields?: boolean } = {} +) => { + let type = input.type ?? (options.requireCreateFields ? 'transactional' : undefined); + if (type && type !== 'inbound' && !input.events?.length) { + throw brevoServiceError('events is required for transactional and marketing webhooks.'); + } + if (type === 'inbound' && !input.domain) { + throw brevoServiceError('domain is required for inbound webhooks.'); + } + if (Boolean(input.authType) !== Boolean(input.authToken)) { + throw brevoServiceError('authType and authToken must be provided together.'); + } + + let payload: Record = {}; + if (input.url) payload.url = input.url; + if (input.type) payload.type = input.type; + if (input.channel) payload.channel = input.channel; + if (input.events) payload.events = input.events; + if (input.description) payload.description = input.description; + if (input.batched !== undefined) payload.batched = input.batched; + if (input.authType && input.authToken) { + payload.auth = { + type: input.authType, + token: input.authToken + }; + } + if (input.headers) payload.headers = input.headers; + if (input.domain) payload.domain = input.domain; + + return payload; +}; + +export let createWebhook = SlateTool.create(spec, { + name: 'Create Webhook', + key: 'create_webhook', + description: `Create a Brevo webhook subscription for transactional, marketing, or inbound email events.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input(z.object(webhookInputFields)) + .output( + z.object({ + webhookId: z.number().describe('ID of the newly created webhook') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let result = await client.createWebhook( + buildWebhookPayload(ctx.input, { requireCreateFields: true }) as any + ); + + return { + output: result, + message: `Webhook created. Webhook ID: **${result.webhookId}**` + }; + }); + +export let listWebhooks = SlateTool.create(spec, { + name: 'List Webhooks', + key: 'list_webhooks', + description: `Retrieve Brevo webhook subscriptions, optionally filtered by webhook type.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + type: webhookTypeSchema.optional().describe('Filter by webhook type') + }) + ) + .output( + z.object({ + webhooks: z.array(webhookOutputSchema).describe('Webhook subscriptions') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let result = await client.getWebhooks(ctx.input.type); + let webhooks = (result.webhooks ?? []).map(mapWebhook); + + return { + output: { webhooks }, + message: `Retrieved **${webhooks.length}** webhooks.` + }; + }); + +export let getWebhook = SlateTool.create(spec, { + name: 'Get Webhook', + key: 'get_webhook', + description: `Retrieve details for a specific Brevo webhook subscription.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + webhookId: z.number().describe('ID of the webhook to retrieve') + }) + ) + .output(webhookOutputSchema) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let webhook = await client.getWebhook(ctx.input.webhookId); + + return { + output: mapWebhook(webhook), + message: `Retrieved webhook **${ctx.input.webhookId}**.` + }; + }); + +export let updateWebhook = SlateTool.create(spec, { + name: 'Update Webhook', + key: 'update_webhook', + description: `Update a Brevo webhook subscription's URL, events, type, channel, description, batching, auth, headers, or inbound domain.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + webhookId: z.number().describe('ID of the webhook to update'), + ...webhookInputFields, + url: webhookInputFields.url.optional() + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the update completed successfully') + }) + ) + .handleInvocation(async ctx => { + let payload = buildWebhookPayload(ctx.input); + if (Object.keys(payload).length === 0) { + throw brevoServiceError('Provide at least one webhook field to update.'); + } + + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.updateWebhook(ctx.input.webhookId, payload); + + return { + output: { success: true }, + message: `Webhook **${ctx.input.webhookId}** updated successfully.` + }; + }); + +export let deleteWebhook = SlateTool.create(spec, { + name: 'Delete Webhook', + key: 'delete_webhook', + description: `Delete a Brevo webhook subscription.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + webhookId: z.number().describe('ID of the webhook to delete') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the deletion completed successfully') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + await client.deleteWebhook(ctx.input.webhookId); + + return { + output: { success: true }, + message: `Webhook **${ctx.input.webhookId}** deleted successfully.` + }; + }); diff --git a/integrations/brevo/src/tools/send-transactional-email.ts b/integrations/brevo/src/tools/send-transactional-email.ts index aab1d77db4..900545b8a1 100644 --- a/integrations/brevo/src/tools/send-transactional-email.ts +++ b/integrations/brevo/src/tools/send-transactional-email.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; import { spec } from '../spec'; let recipientSchema = z.object({ @@ -66,6 +67,27 @@ Use for automated messages like order confirmations, password resets, or any tri }) ) .handleInvocation(async ctx => { + if (ctx.input.htmlContent && ctx.input.templateId !== undefined) { + throw brevoServiceError('Provide either htmlContent or templateId, not both.'); + } + + if (!ctx.input.htmlContent && ctx.input.templateId === undefined) { + throw brevoServiceError('Provide either htmlContent or templateId.'); + } + + if (ctx.input.templateId === undefined && !ctx.input.subject) { + throw brevoServiceError('subject is required when sending without a templateId.'); + } + + for (let attachment of ctx.input.attachments ?? []) { + if (!attachment.url && !attachment.content) { + throw brevoServiceError('Each attachment must include either url or base64 content.'); + } + if (attachment.url && attachment.content) { + throw brevoServiceError('Each attachment must include only one of url or content.'); + } + } + let client = new Client({ token: ctx.auth.token, authType: ctx.auth.authType diff --git a/integrations/brevo/src/tools/send-transactional-sms.ts b/integrations/brevo/src/tools/send-transactional-sms.ts index 2a54b4f0f7..98333bb5d2 100644 --- a/integrations/brevo/src/tools/send-transactional-sms.ts +++ b/integrations/brevo/src/tools/send-transactional-sms.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let sendTransactionalSms = SlateTool.create(spec, { @@ -52,6 +53,14 @@ Use for automated SMS communications like verification codes, order updates, or }) ) .handleInvocation(async ctx => { + if (!ctx.input.content && ctx.input.templateId === undefined) { + throw brevoServiceError('Provide either content or templateId.'); + } + + if (ctx.input.content && ctx.input.templateId !== undefined) { + throw brevoServiceError('Provide either content or templateId, not both.'); + } + let client = new Client({ token: ctx.auth.token, authType: ctx.auth.authType diff --git a/integrations/brevo/src/tools/track-event.ts b/integrations/brevo/src/tools/track-event.ts index 80f0922cdd..16a3149bc6 100644 --- a/integrations/brevo/src/tools/track-event.ts +++ b/integrations/brevo/src/tools/track-event.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { brevoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let trackEvent = SlateTool.create(spec, { @@ -18,15 +19,37 @@ export let trackEvent = SlateTool.create(spec, { .string() .optional() .describe('Email address of the contact to associate the event with'), + contactId: z + .number() + .optional() + .describe('Brevo contact ID to associate the event with'), + extId: z.string().optional().describe('External ID to associate the event with'), + phone: z + .string() + .optional() + .describe('Phone number identifier to associate the event with'), + whatsapp: z + .string() + .optional() + .describe('WhatsApp identifier to associate the event with'), + landlineNumber: z + .string() + .optional() + .describe('Landline number identifier to associate the event with'), eventName: z.string().describe('Name of the event to track'), - eventData: z + eventDate: z.string().optional().describe('ISO 8601 timestamp when the event occurred'), + contactProperties: z .record(z.string(), z.any()) .optional() - .describe('Structured event data (key-value pairs)'), - properties: z + .describe('Contact attributes to update while tracking the event'), + eventProperties: z .record(z.string(), z.any()) .optional() - .describe('Additional properties for the event') + .describe('Properties of the event for segmentation and personalization'), + object: z + .record(z.string(), z.any()) + .optional() + .describe('Optional object record identifiers associated with the event') }) ) .output( @@ -35,6 +58,19 @@ export let trackEvent = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if ( + !ctx.input.email && + ctx.input.contactId === undefined && + !ctx.input.extId && + !ctx.input.phone && + !ctx.input.whatsapp && + !ctx.input.landlineNumber + ) { + throw brevoServiceError( + 'Provide at least one identifier: email, contactId, extId, phone, whatsapp, or landlineNumber.' + ); + } + let client = new Client({ token: ctx.auth.token, authType: ctx.auth.authType @@ -42,9 +78,16 @@ export let trackEvent = SlateTool.create(spec, { await client.trackEvent({ email: ctx.input.email, + contactId: ctx.input.contactId, + extId: ctx.input.extId, + phone: ctx.input.phone, + whatsapp: ctx.input.whatsapp, + landlineNumber: ctx.input.landlineNumber, eventName: ctx.input.eventName, - eventData: ctx.input.eventData, - properties: ctx.input.properties + eventDate: ctx.input.eventDate, + contactProperties: ctx.input.contactProperties, + eventProperties: ctx.input.eventProperties, + object: ctx.input.object }); return { diff --git a/integrations/browserbase-tool/README.md b/integrations/browserbase-tool/README.md index 048cd5a125..da02d63b41 100644 --- a/integrations/browserbase-tool/README.md +++ b/integrations/browserbase-tool/README.md @@ -1,6 +1,6 @@ # Browserbase -Create and manage cloud-hosted headless browser sessions for web automation. Configure sessions with proxy settings, regional selection, timeouts, and stealth mode for anti-detection. Persist browser state (cookies, localStorage, authentication tokens) across sessions using contexts. Upload Chrome extensions and files to sessions, and retrieve downloaded files. Monitor and debug sessions via CDP logs, session recordings, and live debugger URLs. Retrieve project usage data including browser minutes and proxy bytes consumed. +Create and manage cloud-hosted headless browser sessions for web automation. Configure sessions with proxy settings, regional selection, timeouts, and stealth mode for anti-detection. Persist browser state across sessions using contexts. Upload Chrome extensions and files to sessions, list and retrieve session downloads through Slate attachments, search and fetch web pages through Browserbase, inspect live sessions, and retrieve project usage data. ## License diff --git a/integrations/browserbase-tool/docs/SPEC.md b/integrations/browserbase-tool/docs/SPEC.md index 1bdf7ea81d..26a66824f6 100644 --- a/integrations/browserbase-tool/docs/SPEC.md +++ b/integrations/browserbase-tool/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Browserbase is a cloud platform for running headless browsers. It provides infrastructure for running headless browsers, enabling automations that interact with websites, fill out forms, or replicate user actions, so users don't have to maintain their own fleet of headless browsers. It supports Playwright, Puppeteer, and Selenium at scale with stealth mode, session persistence, and debugging tools. +Browserbase is a cloud platform for running browser agents and headless browser automation. It provides cloud browsers, page fetching, web search, persistent contexts, session observability, file uploads/downloads, and debugging tools so users do not have to maintain their own browser fleet. ## Authentication @@ -26,6 +26,7 @@ A browser session is the fundamental building block — it represents a single b - **Proxy configuration** can be set to `true` for a default proxy, or provided as an array of proxy configurations with geolocation options. - Arbitrary **user metadata** can be attached to sessions. - Sessions expose both a **WebSocket URL** and an **HTTP URL** for connecting via browser automation frameworks. +- Sessions can receive uploaded files through the session uploads endpoint. ### Session Observability @@ -51,7 +52,11 @@ Custom Chrome extensions can be uploaded and loaded into browser sessions. Creat ### File Uploads and Downloads -The API supports file uploads, downloads, and custom browser extensions. Files can be uploaded to sessions and downloads from sessions can be retrieved. +The API supports file uploads, downloads, and custom browser extensions. Files can be uploaded to sessions and downloads from sessions can be listed, retrieved, and deleted. Downloaded file bytes must be returned through Slate attachments, with output fields limited to metadata such as filename, MIME type, size, checksum, and attachment count. + +### Search and Fetch + +Browserbase exposes Search and Fetch APIs alongside browser sessions. Search returns structured result metadata. Fetch retrieves a page as raw content, markdown, or JSON extracted by a caller-provided schema. ### Project Management diff --git a/integrations/browserbase-tool/package.json b/integrations/browserbase-tool/package.json index 8376dff05f..facf75a516 100644 --- a/integrations/browserbase-tool/package.json +++ b/integrations/browserbase-tool/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/browserbase-tool/slate.json b/integrations/browserbase-tool/slate.json index ee32a6130f..415752d328 100644 --- a/integrations/browserbase-tool/slate.json +++ b/integrations/browserbase-tool/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/browserbase", - "description": "Create and manage cloud-hosted headless browser sessions for web automation. Configure sessions with proxy settings, regional selection, timeouts, and stealth mode for anti-detection. Persist browser state (cookies, localStorage, authentication tokens) across sessions using contexts. Upload Chrome extensions and files to sessions, and retrieve downloaded files. Monitor and debug sessions via CDP logs, session recordings, and live debugger URLs. Retrieve project usage data including browser minutes and proxy bytes consumed.", + "description": "Create and manage cloud-hosted headless browser sessions for web automation. Configure sessions with proxy settings, regional selection, timeouts, and stealth mode for anti-detection. Persist browser state using contexts. Upload Chrome extensions and files to sessions, retrieve downloaded files through Slate attachments, search and fetch web pages through Browserbase, inspect live sessions, and retrieve project usage data.", "categories": ["apis-and-http-requests", "task-and-project-management", "web-search"], "skills": [ "create browser sessions", @@ -9,6 +9,8 @@ "retrieve session logs", "upload browser extensions", "manage file uploads/downloads", + "search the web", + "fetch page markdown or structured JSON", "view session recordings", "monitor project usage", "inspect live sessions", diff --git a/integrations/browserbase-tool/src/index.ts b/integrations/browserbase-tool/src/index.ts index 43ccb91c47..2966e0eefb 100644 --- a/integrations/browserbase-tool/src/index.ts +++ b/integrations/browserbase-tool/src/index.ts @@ -5,17 +5,25 @@ import { createContext, createSession, deleteContext, + deleteDownload, deleteExtension, fetchPage, getContext, + getDownload, getExtension, + getProject, getProjectUsage, getSession, getSessionDebugInfo, getSessionLogs, getSessionRecording, + listDownloads, listProjects, - listSessions + listSessions, + updateContext, + uploadExtension, + uploadSessionFile, + webSearch } from './tools'; import { inboundWebhook, sessionStatusChange } from './triggers'; @@ -31,12 +39,20 @@ export let provider = Slate.create({ getSessionRecording, createContext, getContext, + updateContext, deleteContext, + uploadExtension, getExtension, deleteExtension, listProjects, + getProject, getProjectUsage, - fetchPage + fetchPage, + webSearch, + uploadSessionFile, + listDownloads, + getDownload, + deleteDownload ], triggers: [inboundWebhook, sessionStatusChange] }); diff --git a/integrations/browserbase-tool/src/lib/client.ts b/integrations/browserbase-tool/src/lib/client.ts index f476375857..aaa45e9d6e 100644 --- a/integrations/browserbase-tool/src/lib/client.ts +++ b/integrations/browserbase-tool/src/lib/client.ts @@ -1,19 +1,119 @@ +import { Buffer } from 'node:buffer'; import { createAxios } from 'slates'; +import { browserbaseApiError, browserbaseServiceError } from './errors'; import type { Context, ContextCreateResponse, + ContextUpdateResponse, CreateSessionParams, + Download, + DownloadContent, Extension, FetchPageParams, FetchPageResponse, + ListDownloadsParams, + ListDownloadsResponse, Project, ProjectUsage, Session, SessionDebugUrls, SessionLog, - SessionRecordingEvent + SessionRecordingEvent, + SessionUploadResponse, + UploadFileParams, + WebSearchParams, + WebSearchResponse, + WebSearchResult } from './types'; +let sanitizeMultipartHeader = (value: string) => value.replace(/[\r\n"]/g, '_'); + +let appendMultipartFile = ( + parts: Buffer[], + boundary: string, + name: string, + filename: string, + content: Buffer, + mimeType: string +) => { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${sanitizeMultipartHeader(name)}"; filename="${sanitizeMultipartHeader(filename)}"\r\nContent-Type: ${mimeType}\r\n\r\n` + ) + ); + parts.push(content); + parts.push(Buffer.from('\r\n')); +}; + +let buildMultipartFileBody = (params: { + fileField: string; + filename: string; + fileContent: Buffer; + mimeType?: string; +}) => { + let boundary = `----SlatesBrowserbaseBoundary${Date.now()}${Math.random().toString(16).slice(2)}`; + let parts: Buffer[] = []; + + appendMultipartFile( + parts, + boundary, + params.fileField, + params.filename, + params.fileContent, + params.mimeType ?? 'application/octet-stream' + ); + parts.push(Buffer.from(`--${boundary}--\r\n`)); + + return { + body: Buffer.concat(parts), + contentType: `multipart/form-data; boundary=${boundary}` + }; +}; + +let decodeBase64File = (contentBase64: string, fieldName = 'contentBase64') => { + let normalized = contentBase64.replace(/\s+/g, ''); + + if ( + !normalized || + normalized.length % 4 === 1 || + !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) + ) { + throw browserbaseServiceError(`${fieldName} must be valid non-empty base64 data.`); + } + + let content = Buffer.from(normalized, 'base64'); + if (content.length === 0) { + throw browserbaseServiceError(`${fieldName} must contain at least one byte.`); + } + + let canonical = content.toString('base64').replace(/=+$/, ''); + if (canonical !== normalized.replace(/=+$/, '')) { + throw browserbaseServiceError(`${fieldName} must be valid base64 data.`); + } + + return content; +}; + +let responseDataToBase64 = (data: unknown) => { + if (Buffer.isBuffer(data)) { + return data.toString('base64'); + } + + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString('base64'); + } + + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('base64'); + } + + if (typeof data === 'string') { + return Buffer.from(data, 'binary').toString('base64'); + } + + throw browserbaseServiceError('Browserbase returned file content in an unsupported format.'); +}; + export class Client { private http: ReturnType; @@ -27,10 +127,18 @@ export class Client { }); } - // ── Sessions ────────────────────────────────────────────────────── + private async request(operation: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw browserbaseApiError(error, operation); + } + } + + // Sessions async createSession(params: CreateSessionParams): Promise { - let res = await this.http.post('/sessions', params); + let res = await this.request('create session', () => this.http.post('/sessions', params)); return this.mapSession(res.data); } @@ -38,100 +146,239 @@ export class Client { let params: Record = {}; if (options?.status) params.status = options.status; if (options?.query) params.q = options.query; - let res = await this.http.get('/sessions', { params }); + let res = await this.request('list sessions', () => + this.http.get('/sessions', { params }) + ); return (res.data as unknown[]).map((s: unknown) => this.mapSession(s)); } async getSession(sessionId: string): Promise { - let res = await this.http.get(`/sessions/${sessionId}`); + let res = await this.request('get session', () => this.http.get(`/sessions/${sessionId}`)); return this.mapSession(res.data); } - async completeSession(sessionId: string, projectId: string): Promise { - let res = await this.http.post(`/sessions/${sessionId}`, { - status: 'REQUEST_RELEASE', - projectId - }); + async completeSession(sessionId: string): Promise { + let res = await this.request('complete session', () => + this.http.post(`/sessions/${sessionId}`, { + status: 'REQUEST_RELEASE' + }) + ); return this.mapSession(res.data); } - // ── Session Observability ───────────────────────────────────────── + async uploadSessionFile( + sessionId: string, + params: UploadFileParams + ): Promise { + let content = decodeBase64File(params.contentBase64); + let multipart = buildMultipartFileBody({ + fileField: 'file', + filename: params.fileName, + fileContent: content, + mimeType: params.mimeType + }); + + let res = await this.request('upload session file', () => + this.http.post(`/sessions/${sessionId}/uploads`, multipart.body, { + headers: { 'Content-Type': multipart.contentType } + }) + ); + return { message: String((res.data as Record).message ?? '') }; + } + + // Session observability async getSessionDebugUrls(sessionId: string): Promise { - let res = await this.http.get(`/sessions/${sessionId}/debug`); + let res = await this.request('get session live URLs', () => + this.http.get(`/sessions/${sessionId}/debug`) + ); return res.data as SessionDebugUrls; } async getSessionLogs(sessionId: string): Promise { - let res = await this.http.get(`/sessions/${sessionId}/logs`); + let res = await this.request('get session logs', () => + this.http.get(`/sessions/${sessionId}/logs`) + ); return res.data as SessionLog[]; } async getSessionRecording(sessionId: string): Promise { - let res = await this.http.get(`/sessions/${sessionId}/recording`); + let res = await this.request('get session recording', () => + this.http.get(`/sessions/${sessionId}/recording`) + ); return res.data as SessionRecordingEvent[]; } - // ── Contexts ────────────────────────────────────────────────────── + // Contexts async createContext(projectId: string): Promise { - let res = await this.http.post('/contexts', { projectId }); + let res = await this.request('create context', () => + this.http.post('/contexts', { projectId }) + ); return this.mapContextCreateResponse(res.data); } async getContext(contextId: string): Promise { - let res = await this.http.get(`/contexts/${contextId}`); + let res = await this.request('get context', () => this.http.get(`/contexts/${contextId}`)); return this.mapContext(res.data); } + async updateContext(contextId: string): Promise { + let res = await this.request('update context', () => + this.http.put(`/contexts/${contextId}`) + ); + return this.mapContextCreateResponse(res.data); + } + async deleteContext(contextId: string): Promise { - await this.http.delete(`/contexts/${contextId}`); + await this.request('delete context', () => this.http.delete(`/contexts/${contextId}`)); } - // ── Extensions ──────────────────────────────────────────────────── + // Extensions + + async uploadExtension(params: UploadFileParams): Promise { + let content = decodeBase64File(params.contentBase64); + let multipart = buildMultipartFileBody({ + fileField: 'file', + filename: params.fileName, + fileContent: content, + mimeType: params.mimeType ?? 'application/zip' + }); + + let res = await this.request('upload extension', () => + this.http.post('/extensions', multipart.body, { + headers: { 'Content-Type': multipart.contentType } + }) + ); + return this.mapExtension(res.data); + } async getExtension(extensionId: string): Promise { - let res = await this.http.get(`/extensions/${extensionId}`); + let res = await this.request('get extension', () => + this.http.get(`/extensions/${extensionId}`) + ); return this.mapExtension(res.data); } async deleteExtension(extensionId: string): Promise { - await this.http.delete(`/extensions/${extensionId}`); + await this.request('delete extension', () => + this.http.delete(`/extensions/${extensionId}`) + ); } - // ── Projects ────────────────────────────────────────────────────── + // Projects async listProjects(): Promise { - let res = await this.http.get('/projects'); + let res = await this.request('list projects', () => this.http.get('/projects')); return (res.data as unknown[]).map((p: unknown) => this.mapProject(p)); } async getProject(projectId: string): Promise { - let res = await this.http.get(`/projects/${projectId}`); + let res = await this.request('get project', () => this.http.get(`/projects/${projectId}`)); return this.mapProject(res.data); } async getProjectUsage(projectId: string): Promise { - let res = await this.http.get(`/projects/${projectId}/usage`); + let res = await this.request('get project usage', () => + this.http.get(`/projects/${projectId}/usage`) + ); return res.data as ProjectUsage; } - // ── Fetch ───────────────────────────────────────────────────────── + // Fetch and search async fetchPage(params: FetchPageParams): Promise { - let res = await this.http.post('/fetch', params); + let res = await this.request('fetch page', () => this.http.post('/fetch', params)); let d = res.data as Record; return { fetchId: d.id as string, statusCode: d.statusCode as number, headers: d.headers as Record, - content: d.content as string, + content: d.content, contentType: d.contentType as string, encoding: d.encoding as string }; } - // ── Mappers ─────────────────────────────────────────────────────── + async webSearch(params: WebSearchParams): Promise { + let res = await this.request('web search', () => this.http.post('/search', params)); + let d = res.data as Record; + return { + requestId: d.requestId as string, + query: d.query as string, + results: ((d.results as unknown[]) ?? []).map(result => this.mapWebSearchResult(result)) + }; + } + + // Downloads + + async listDownloads(options: ListDownloadsParams): Promise { + let params: Record = { + sessionId: options.sessionId + }; + + for (let key of [ + 'filename', + 'mimeType', + 'minSize', + 'maxSize', + 'createdAfter', + 'createdBefore', + 'limit', + 'offset' + ] as const) { + let value = options[key]; + if (value !== undefined) { + params[key] = value; + } + } + + let res = await this.request('list downloads', () => + this.http.get('/downloads', { params }) + ); + let d = res.data as Record; + + return { + downloads: ((d.downloads as unknown[]) ?? []).map(download => + this.mapDownload(download) + ), + total: d.total as number, + limit: d.limit as number, + offset: d.offset as number + }; + } + + async getDownload(downloadId: string): Promise { + let res = await this.request('get download metadata', () => + this.http.get(`/downloads/${downloadId}`, { + headers: { Accept: 'application/json' } + }) + ); + return this.mapDownload(res.data); + } + + async getDownloadContent(downloadId: string): Promise { + let metadata = await this.getDownload(downloadId); + let res = await this.request('get download content', () => + this.http.get(`/downloads/${downloadId}`, { + headers: { Accept: 'application/octet-stream' }, + responseType: 'arraybuffer' + }) + ); + let contentBase64 = responseDataToBase64(res.data); + + return { + ...metadata, + contentBase64, + byteLength: Buffer.from(contentBase64, 'base64').byteLength + }; + } + + async deleteDownload(downloadId: string): Promise { + await this.request('delete download', () => this.http.delete(`/downloads/${downloadId}`)); + } + + // Mappers private mapSession(data: unknown): Session { let d = data as Record; @@ -199,4 +446,30 @@ export class Client { concurrency: d.concurrency as number }; } + + private mapWebSearchResult(data: unknown): WebSearchResult { + let d = data as Record; + return { + resultId: d.id as string, + url: d.url as string, + title: d.title as string, + author: d.author as string | undefined, + publishedDate: d.publishedDate as string | undefined, + image: d.image as string | undefined, + favicon: d.favicon as string | undefined + }; + } + + private mapDownload(data: unknown): Download { + let d = data as Record; + return { + downloadId: d.id as string, + sessionId: d.sessionId as string, + filename: d.filename as string, + mimeType: d.mimeType as string, + size: d.size as number, + checksum: d.checksum as string, + createdAt: d.createdAt as string + }; + } } diff --git a/integrations/browserbase-tool/src/lib/errors.ts b/integrations/browserbase-tool/src/lib/errors.ts new file mode 100644 index 0000000000..738a30859a --- /dev/null +++ b/integrations/browserbase-tool/src/lib/errors.ts @@ -0,0 +1,86 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.error); + addDetail(details, value.detail); + addDetail(details, value.title); + addDetail(details, value.reason); + collectDetails(value.errors, details); +}; + +let extractBrowserbaseMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let browserbaseServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let browserbaseApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = browserbaseServiceError( + `Browserbase API ${operation} failed: ${statusLabelFor(response)}${extractBrowserbaseMessage(error)}` + ); + serviceError.data.reason = 'browserbase_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/browserbase-tool/src/lib/types.ts b/integrations/browserbase-tool/src/lib/types.ts index 8437a43df6..eaba490586 100644 --- a/integrations/browserbase-tool/src/lib/types.ts +++ b/integrations/browserbase-tool/src/lib/types.ts @@ -6,7 +6,7 @@ export interface Session { startedAt: string; endedAt: string | null; expiresAt: string; - status: 'RUNNING' | 'ERROR' | 'TIMED_OUT' | 'COMPLETED'; + status: 'PENDING' | 'RUNNING' | 'ERROR' | 'TIMED_OUT' | 'COMPLETED'; proxyBytes: number; keepAlive: boolean; contextId: string | null; @@ -129,6 +129,8 @@ export interface ContextCreateResponse { initializationVectorSize: number; } +export type ContextUpdateResponse = ContextCreateResponse; + export interface Extension { extensionId: string; createdAt: string; @@ -152,18 +154,87 @@ export interface ProjectUsage { proxyBytes: number; } +export type FetchFormat = 'raw' | 'markdown' | 'json'; + export interface FetchPageParams { url: string; allowRedirects?: boolean; allowInsecureSsl?: boolean; proxies?: boolean; + format?: FetchFormat; + schema?: Record; } export interface FetchPageResponse { fetchId: string; statusCode: number; headers: Record; - content: string; + content: unknown; contentType: string; encoding: string; } + +export interface WebSearchParams { + query: string; + numResults?: number; +} + +export interface WebSearchResult { + resultId: string; + url: string; + title: string; + author?: string; + publishedDate?: string; + image?: string; + favicon?: string; +} + +export interface WebSearchResponse { + requestId: string; + query: string; + results: WebSearchResult[]; +} + +export interface SessionUploadResponse { + message: string; +} + +export interface UploadFileParams { + fileName: string; + contentBase64: string; + mimeType?: string; +} + +export interface Download { + downloadId: string; + sessionId: string; + filename: string; + mimeType: string; + size: number; + checksum: string; + createdAt: string; +} + +export interface ListDownloadsParams { + sessionId: string; + filename?: string; + mimeType?: string; + minSize?: number; + maxSize?: number; + createdAfter?: string; + createdBefore?: string; + limit?: number; + offset?: number; +} + +export interface ListDownloadsResponse { + downloads: Download[]; + total: number; + limit: number; + offset: number; +} + +export interface DownloadContent extends Download { + contentBase64: string; + byteLength: number; +} diff --git a/integrations/browserbase-tool/src/tools.schema.test.ts b/integrations/browserbase-tool/src/tools.schema.test.ts new file mode 100644 index 0000000000..4628f51970 --- /dev/null +++ b/integrations/browserbase-tool/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Browserbase tool input schemas', provider.actions); diff --git a/integrations/browserbase-tool/src/tools/complete-session.ts b/integrations/browserbase-tool/src/tools/complete-session.ts index 4cd4795ce3..df9b12f4ea 100644 --- a/integrations/browserbase-tool/src/tools/complete-session.ts +++ b/integrations/browserbase-tool/src/tools/complete-session.ts @@ -26,7 +26,7 @@ export let completeSession = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); - let session = await client.completeSession(ctx.input.sessionId, ctx.config.projectId); + let session = await client.completeSession(ctx.input.sessionId); return { output: { diff --git a/integrations/browserbase-tool/src/tools/create-session.ts b/integrations/browserbase-tool/src/tools/create-session.ts index ba384f3ce3..8f0ce30552 100644 --- a/integrations/browserbase-tool/src/tools/create-session.ts +++ b/integrations/browserbase-tool/src/tools/create-session.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { browserbaseServiceError } from '../lib/errors'; import type { ProxyConfig } from '../lib/types'; import { spec } from '../spec'; @@ -129,10 +130,14 @@ export let createSession = SlateTool.create(spec, { }; } - if (ctx.input.viewportWidth || ctx.input.viewportHeight) { + if ((ctx.input.viewportWidth === undefined) !== (ctx.input.viewportHeight === undefined)) { + throw browserbaseServiceError('Provide both viewportWidth and viewportHeight together.'); + } + + if (ctx.input.viewportWidth !== undefined && ctx.input.viewportHeight !== undefined) { browserSettings.viewport = { - ...(ctx.input.viewportWidth ? { width: ctx.input.viewportWidth } : {}), - ...(ctx.input.viewportHeight ? { height: ctx.input.viewportHeight } : {}) + width: ctx.input.viewportWidth, + height: ctx.input.viewportHeight }; } diff --git a/integrations/browserbase-tool/src/tools/fetch-page.ts b/integrations/browserbase-tool/src/tools/fetch-page.ts index 4bae0198dd..eff2e235b4 100644 --- a/integrations/browserbase-tool/src/tools/fetch-page.ts +++ b/integrations/browserbase-tool/src/tools/fetch-page.ts @@ -1,12 +1,13 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { browserbaseServiceError } from '../lib/errors'; import { spec } from '../spec'; export let fetchPage = SlateTool.create(spec, { name: 'Fetch Page', key: 'fetch_page', - description: `Fetch a web page through Browserbase's cloud infrastructure. Returns the page content, headers, status code, and content type. Supports proxy routing and redirect following.`, + description: `Fetch a web page through Browserbase's cloud infrastructure. Returns raw content, markdown, or JSON extracted with a provided schema, plus headers, status code, and content type.`, tags: { readOnly: true } @@ -22,7 +23,17 @@ export let fetchPage = SlateTool.create(spec, { .boolean() .optional() .describe('Bypass TLS certificate verification (default: false)'), - enableProxy: z.boolean().optional().describe('Enable proxy support (default: false)') + enableProxy: z.boolean().optional().describe('Enable proxy support (default: false)'), + format: z + .enum(['raw', 'markdown', 'json']) + .optional() + .describe( + 'Output format. Use raw for unchanged response body, markdown for page markdown, or json with schema for structured extraction.' + ), + schema: z + .record(z.string(), z.unknown()) + .optional() + .describe('JSON Schema object used only when format is json.') }) ) .output( @@ -30,7 +41,9 @@ export let fetchPage = SlateTool.create(spec, { fetchId: z.string().describe('Unique fetch request identifier'), statusCode: z.number().describe('HTTP status code of the fetched response'), headers: z.record(z.string(), z.string()).describe('Response headers'), - content: z.string().describe('Response body content'), + content: z + .unknown() + .describe('Response body content as string, or structured JSON for json format'), contentType: z.string().describe('MIME type of the response'), encoding: z.string().describe('Character encoding') }) @@ -38,11 +51,21 @@ export let fetchPage = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); + if (ctx.input.format === 'json' && !ctx.input.schema) { + throw browserbaseServiceError('schema is required when format is "json".'); + } + + if (ctx.input.schema && ctx.input.format !== 'json') { + throw browserbaseServiceError('schema can only be provided when format is "json".'); + } + let result = await client.fetchPage({ url: ctx.input.url, allowRedirects: ctx.input.allowRedirects, allowInsecureSsl: ctx.input.allowInsecureSsl, - proxies: ctx.input.enableProxy + proxies: ctx.input.enableProxy, + format: ctx.input.format, + schema: ctx.input.schema }); return { diff --git a/integrations/browserbase-tool/src/tools/get-project-info.ts b/integrations/browserbase-tool/src/tools/get-project-info.ts index d723332c08..76869f150d 100644 --- a/integrations/browserbase-tool/src/tools/get-project-info.ts +++ b/integrations/browserbase-tool/src/tools/get-project-info.ts @@ -49,6 +49,54 @@ export let listProjects = SlateTool.create(spec, { }) .build(); +export let getProject = SlateTool.create(spec, { + name: 'Get Project', + key: 'get_project', + description: `Retrieve a Browserbase project by ID. Defaults to the configured project and returns timeout and concurrency settings.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z + .string() + .optional() + .describe('Project ID to retrieve. Defaults to the configured project.') + }) + ) + .output( + z.object({ + projectId: z.string().describe('Project identifier'), + name: z.string().describe('Project name'), + ownerId: z.string().describe('Owner identifier'), + defaultTimeout: z.number().describe('Default session timeout in seconds'), + concurrency: z.number().describe('Maximum concurrent sessions'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().describe('Last update timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let projectId = ctx.input.projectId || ctx.config.projectId; + let project = await client.getProject(projectId); + + return { + output: { + projectId: project.projectId, + name: project.name, + ownerId: project.ownerId, + defaultTimeout: project.defaultTimeout, + concurrency: project.concurrency, + createdAt: project.createdAt, + updatedAt: project.updatedAt + }, + message: `Retrieved project **${project.name}** (${project.projectId}).` + }; + }) + .build(); + export let getProjectUsage = SlateTool.create(spec, { name: 'Get Project Usage', key: 'get_project_usage', diff --git a/integrations/browserbase-tool/src/tools/get-session.ts b/integrations/browserbase-tool/src/tools/get-session.ts index 646a8f439b..3dde8b8481 100644 --- a/integrations/browserbase-tool/src/tools/get-session.ts +++ b/integrations/browserbase-tool/src/tools/get-session.ts @@ -19,7 +19,9 @@ export let getSession = SlateTool.create(spec, { .output( z.object({ sessionId: z.string().describe('Session identifier'), - status: z.string().describe('Session status (RUNNING, COMPLETED, ERROR, TIMED_OUT)'), + status: z + .string() + .describe('Session status (PENDING, RUNNING, COMPLETED, ERROR, TIMED_OUT)'), region: z.string().describe('Session region'), createdAt: z.string().describe('Creation timestamp'), startedAt: z.string().describe('Start timestamp'), diff --git a/integrations/browserbase-tool/src/tools/index.ts b/integrations/browserbase-tool/src/tools/index.ts index 9d75eb2610..fbd41f2798 100644 --- a/integrations/browserbase-tool/src/tools/index.ts +++ b/integrations/browserbase-tool/src/tools/index.ts @@ -8,4 +8,8 @@ export * from './get-session-logs'; export * from './get-session-recording'; export * from './list-sessions'; export * from './manage-context'; +export * from './manage-downloads'; export * from './manage-extension'; +export * from './upload-extension'; +export * from './upload-session-file'; +export * from './web-search'; diff --git a/integrations/browserbase-tool/src/tools/list-sessions.ts b/integrations/browserbase-tool/src/tools/list-sessions.ts index 36b44eafd8..79e6d52a4d 100644 --- a/integrations/browserbase-tool/src/tools/list-sessions.ts +++ b/integrations/browserbase-tool/src/tools/list-sessions.ts @@ -14,7 +14,7 @@ export let listSessions = SlateTool.create(spec, { .input( z.object({ status: z - .enum(['RUNNING', 'ERROR', 'TIMED_OUT', 'COMPLETED']) + .enum(['PENDING', 'RUNNING', 'ERROR', 'TIMED_OUT', 'COMPLETED']) .optional() .describe('Filter sessions by status'), query: z.string().optional().describe('Search sessions by user metadata') diff --git a/integrations/browserbase-tool/src/tools/manage-context.ts b/integrations/browserbase-tool/src/tools/manage-context.ts index f2681449a4..40dd00e791 100644 --- a/integrations/browserbase-tool/src/tools/manage-context.ts +++ b/integrations/browserbase-tool/src/tools/manage-context.ts @@ -78,6 +78,46 @@ export let getContext = SlateTool.create(spec, { }) .build(); +export let updateContext = SlateTool.create(spec, { + name: 'Update Context', + key: 'update_context', + description: `Refresh upload credentials for a persistent browser context so a new encrypted user-data-directory can be uploaded.`, + tags: { + readOnly: false + } +}) + .input( + z.object({ + contextId: z.string().describe('The context ID to update') + }) + ) + .output( + z.object({ + contextId: z.string().describe('Context identifier'), + uploadUrl: z.string().describe('URL to upload custom user-data-directory'), + publicKey: z.string().describe('Public key for encrypting user-data-directory'), + cipherAlgorithm: z.string().describe('Encryption algorithm'), + initializationVectorSize: z.number().describe('IV size for encryption') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let context = await client.updateContext(ctx.input.contextId); + + return { + output: { + contextId: context.contextId, + uploadUrl: context.uploadUrl, + publicKey: context.publicKey, + cipherAlgorithm: context.cipherAlgorithm, + initializationVectorSize: context.initializationVectorSize + }, + message: `Updated context **${context.contextId}** upload credentials.` + }; + }) + .build(); + export let deleteContext = SlateTool.create(spec, { name: 'Delete Context', key: 'delete_context', diff --git a/integrations/browserbase-tool/src/tools/manage-downloads.ts b/integrations/browserbase-tool/src/tools/manage-downloads.ts new file mode 100644 index 0000000000..ebcd955736 --- /dev/null +++ b/integrations/browserbase-tool/src/tools/manage-downloads.ts @@ -0,0 +1,169 @@ +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let downloadOutputSchema = z.object({ + downloadId: z.string().describe('Download identifier'), + sessionId: z.string().describe('Session ID that owns the download'), + filename: z.string().describe('Downloaded filename'), + mimeType: z.string().describe('MIME type'), + size: z.number().describe('File size in bytes'), + checksum: z.string().describe('SHA256 checksum'), + createdAt: z.string().describe('Download timestamp') +}); + +export let listDownloads = SlateTool.create(spec, { + name: 'List Downloads', + key: 'list_downloads', + description: `List Browserbase downloads for a session with optional filtering by filename, MIME type, size, creation time, and pagination.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + sessionId: z.string().describe('Session ID to list downloads for'), + filename: z.string().optional().describe('Filter by exact filename match'), + mimeType: z.string().optional().describe('Filter by MIME type'), + minSize: z.number().min(0).optional().describe('Minimum file size in bytes'), + maxSize: z.number().min(0).optional().describe('Maximum file size in bytes'), + createdAfter: z + .string() + .optional() + .describe('Filter downloads created after this ISO timestamp'), + createdBefore: z + .string() + .optional() + .describe('Filter downloads created before this ISO timestamp'), + limit: z.number().int().min(1).max(100).optional().describe('Maximum results to return'), + offset: z.number().int().min(0).optional().describe('Number of results to skip') + }) + ) + .output( + z.object({ + downloads: z.array(downloadOutputSchema).describe('Matching downloads'), + total: z.number().describe('Total count of matching downloads'), + limit: z.number().describe('Page size used by Browserbase'), + offset: z.number().describe('Pagination offset') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listDownloads({ + sessionId: ctx.input.sessionId, + filename: ctx.input.filename, + mimeType: ctx.input.mimeType, + minSize: ctx.input.minSize, + maxSize: ctx.input.maxSize, + createdAfter: ctx.input.createdAfter, + createdBefore: ctx.input.createdBefore, + limit: ctx.input.limit, + offset: ctx.input.offset + }); + + return { + output: result, + message: `Found **${result.downloads.length}** download(s) for session **${ctx.input.sessionId}**.` + }; + }) + .build(); + +export let getDownload = SlateTool.create(spec, { + name: 'Get Download', + key: 'get_download', + description: `Retrieve Browserbase download metadata, and optionally return the downloaded file bytes as a Slate attachment.`, + instructions: [ + 'Set includeContent to true to receive file bytes through response attachments.', + 'File bytes are never returned inline in output fields.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + downloadId: z.string().describe('Download ID to retrieve'), + includeContent: z + .boolean() + .optional() + .describe('When true, return file bytes through a Slate attachment.') + }) + ) + .output( + downloadOutputSchema.extend({ + attachmentCount: z + .number() + .describe('Number of Slate attachments returned for downloaded file content') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + if (ctx.input.includeContent) { + let download = await client.getDownloadContent(ctx.input.downloadId); + return { + output: { + downloadId: download.downloadId, + sessionId: download.sessionId, + filename: download.filename, + mimeType: download.mimeType, + size: download.size, + checksum: download.checksum, + createdAt: download.createdAt, + attachmentCount: 1 + }, + attachments: [createBase64Attachment(download.contentBase64, download.mimeType)], + message: `Retrieved download **${download.filename}** as a Slate attachment.` + }; + } + + let download = await client.getDownload(ctx.input.downloadId); + return { + output: { + downloadId: download.downloadId, + sessionId: download.sessionId, + filename: download.filename, + mimeType: download.mimeType, + size: download.size, + checksum: download.checksum, + createdAt: download.createdAt, + attachmentCount: 0 + }, + message: `Retrieved download **${download.filename}** metadata.` + }; + }) + .build(); + +export let deleteDownload = SlateTool.create(spec, { + name: 'Delete Download', + key: 'delete_download', + description: `Delete a Browserbase download file from storage and mark it as deleted.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + downloadId: z.string().describe('Download ID to delete') + }) + ) + .output( + z.object({ + downloadId: z.string().describe('Deleted download ID'), + deleted: z.boolean().describe('Whether deletion succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + await client.deleteDownload(ctx.input.downloadId); + + return { + output: { + downloadId: ctx.input.downloadId, + deleted: true + }, + message: `Deleted download **${ctx.input.downloadId}**.` + }; + }) + .build(); diff --git a/integrations/browserbase-tool/src/tools/upload-extension.ts b/integrations/browserbase-tool/src/tools/upload-extension.ts new file mode 100644 index 0000000000..c58cc57b7d --- /dev/null +++ b/integrations/browserbase-tool/src/tools/upload-extension.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let uploadExtension = SlateTool.create(spec, { + name: 'Upload Extension', + key: 'upload_extension', + description: `Upload a Chrome extension ZIP to Browserbase so it can be loaded into future browser sessions via extensionId.`, + instructions: [ + 'Provide a ZIP file with manifest.json at the archive root.', + 'Use the returned extensionId in create_session.' + ], + tags: { + readOnly: false + } +}) + .input( + z.object({ + fileName: z.string().describe('Extension ZIP filename, for example extension.zip'), + fileContentBase64: z.string().describe('Base64-encoded extension ZIP file bytes'), + mimeType: z + .string() + .optional() + .describe('Extension MIME type. Defaults to application/zip.') + }) + ) + .output( + z.object({ + extensionId: z.string().describe('Extension identifier'), + fileName: z.string().describe('Uploaded file name'), + projectId: z.string().describe('Linked project ID'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().describe('Last update timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let extension = await client.uploadExtension({ + fileName: ctx.input.fileName, + contentBase64: ctx.input.fileContentBase64, + mimeType: ctx.input.mimeType + }); + + return { + output: { + extensionId: extension.extensionId, + fileName: extension.fileName, + projectId: extension.projectId, + createdAt: extension.createdAt, + updatedAt: extension.updatedAt + }, + message: `Uploaded extension **${extension.extensionId}** (${extension.fileName}).` + }; + }) + .build(); diff --git a/integrations/browserbase-tool/src/tools/upload-session-file.ts b/integrations/browserbase-tool/src/tools/upload-session-file.ts new file mode 100644 index 0000000000..e3be1e2ce5 --- /dev/null +++ b/integrations/browserbase-tool/src/tools/upload-session-file.ts @@ -0,0 +1,51 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let uploadSessionFile = SlateTool.create(spec, { + name: 'Upload Session File', + key: 'upload_session_file', + description: `Upload a file into a running Browserbase session so browser automation can attach it to forms or otherwise use it inside the remote browser.`, + tags: { + readOnly: false + } +}) + .input( + z.object({ + sessionId: z.string().describe('Session ID to upload the file into'), + fileName: z.string().describe('Filename to expose in the session'), + fileContentBase64: z.string().describe('Base64-encoded file bytes'), + mimeType: z + .string() + .optional() + .describe('File MIME type. Defaults to application/octet-stream.') + }) + ) + .output( + z.object({ + sessionId: z.string().describe('Session identifier'), + fileName: z.string().describe('Uploaded filename'), + uploaded: z.boolean().describe('Whether Browserbase accepted the upload'), + message: z.string().describe('Browserbase upload response message') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.uploadSessionFile(ctx.input.sessionId, { + fileName: ctx.input.fileName, + contentBase64: ctx.input.fileContentBase64, + mimeType: ctx.input.mimeType + }); + + return { + output: { + sessionId: ctx.input.sessionId, + fileName: ctx.input.fileName, + uploaded: true, + message: result.message + }, + message: `Uploaded **${ctx.input.fileName}** to session **${ctx.input.sessionId}**.` + }; + }) + .build(); diff --git a/integrations/browserbase-tool/src/tools/web-search.ts b/integrations/browserbase-tool/src/tools/web-search.ts new file mode 100644 index 0000000000..7db214cdd3 --- /dev/null +++ b/integrations/browserbase-tool/src/tools/web-search.ts @@ -0,0 +1,57 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let webSearch = SlateTool.create(spec, { + name: 'Web Search', + key: 'web_search', + description: `Search the web using Browserbase Search and return structured result metadata that can be passed to fetch_page or browser sessions.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + query: z.string().min(1).max(200).describe('Search query string'), + numResults: z + .number() + .int() + .min(1) + .max(25) + .optional() + .describe('Number of results to return (1-25). Defaults to 10.') + }) + ) + .output( + z.object({ + requestId: z.string().describe('Browserbase search request identifier'), + query: z.string().describe('Search query that was executed'), + results: z + .array( + z.object({ + resultId: z.string().describe('Search result identifier'), + url: z.string().describe('Result URL'), + title: z.string().describe('Result title'), + author: z.string().optional().describe('Result author, when available'), + publishedDate: z.string().optional().describe('Published date, when available'), + image: z.string().optional().describe('Preview image URL, when available'), + favicon: z.string().optional().describe('Favicon URL, when available') + }) + ) + .describe('Structured search results') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let search = await client.webSearch({ + query: ctx.input.query, + numResults: ctx.input.numResults + }); + + return { + output: search, + message: `Found **${search.results.length}** result(s) for **${search.query}**.` + }; + }) + .build(); diff --git a/integrations/browserbase-tool/src/triggers/session-status-change.ts b/integrations/browserbase-tool/src/triggers/session-status-change.ts index 1135c5199f..8bbd348620 100644 --- a/integrations/browserbase-tool/src/triggers/session-status-change.ts +++ b/integrations/browserbase-tool/src/triggers/session-status-change.ts @@ -13,7 +13,7 @@ export let sessionStatusChange = SlateTrigger.create(spec, { z.object({ sessionId: z.string().describe('Session identifier'), status: z - .enum(['RUNNING', 'COMPLETED', 'ERROR', 'TIMED_OUT']) + .enum(['PENDING', 'RUNNING', 'COMPLETED', 'ERROR', 'TIMED_OUT']) .describe('Current session status'), region: z.string().describe('Session region'), createdAt: z.string().describe('Creation timestamp'), @@ -49,7 +49,7 @@ export let sessionStatusChange = SlateTrigger.create(spec, { (ctx.state as Record | null) || {}; let inputs: Array<{ sessionId: string; - status: 'RUNNING' | 'COMPLETED' | 'ERROR' | 'TIMED_OUT'; + status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'ERROR' | 'TIMED_OUT'; region: string; createdAt: string; startedAt: string; diff --git a/integrations/browserbase-tool/vitest.config.ts b/integrations/browserbase-tool/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/browserbase-tool/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/browserless/README.md b/integrations/browserless/README.md index 860a12ba8a..77dbca7f9f 100644 --- a/integrations/browserless/README.md +++ b/integrations/browserless/README.md @@ -1,6 +1,6 @@ # Browserless -Automate headless Chrome/Chromium browsers in the cloud for web scraping, content extraction, and browser automation tasks. Scrape structured data from web pages using CSS selectors, retrieve fully rendered HTML after JavaScript execution, generate PDFs from URLs or raw HTML, capture screenshots in JPEG or PNG, download files triggered by browser interactions, and execute custom Puppeteer code for multi-step workflows. Perform web searches with optional result scraping returning LLM-ready markdown or HTML. Unblock protected websites and bypass bot detection using stealth mode and CAPTCHA solving via BrowserQL. Run Lighthouse performance audits, record browser sessions as video, and manage persistent browser sessions with configurable TTL. Supports residential proxy routing for geo-targeted requests. +Automate headless Chrome/Chromium browsers in the cloud for web scraping, content extraction, and browser automation tasks. Scrape pages with CSS selectors, Smart Scrape cascading strategies, URL mapping, asynchronous crawls, rendered HTML retrieval, web search, Lighthouse audits, and custom Puppeteer functions. Generate PDFs, screenshots, URL exports, and browser-triggered downloads with file bytes returned through Slate attachments. Unblock protected pages with optional content, cookies, screenshot attachments, and Browserless WebSocket handoff metadata. BrowserQL and BaaS session management remain separate long-lived browser control surfaces and are not exposed as REST tools in this integration. ## License diff --git a/integrations/browserless/docs/SPEC.md b/integrations/browserless/docs/SPEC.md index 473c7e0524..9909833c7b 100644 --- a/integrations/browserless/docs/SPEC.md +++ b/integrations/browserless/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Browserless is a cloud-hosted headless browser service that provides managed Chrome/Chromium instances for browser automation tasks. It offers HTTP endpoints for common browser tasks like screenshots, PDFs, content scraping, file downloads, function execution, and website unblocking. It also provides BrowserQL (BQL), a GraphQL API for browser automation with built-in stealth and CAPTCHA solving, and BaaS v2, which lets you connect Puppeteer or Playwright to managed browsers over WebSocket. +Browserless is a cloud-hosted headless browser service that provides managed Chrome/Chromium instances for browser automation tasks. It offers REST endpoints for common browser tasks like Smart Scrape, rendered content retrieval, CSS-selector scraping, screenshots, PDFs, website unblocking, function execution, browser-triggered downloads, native URL exports, Lighthouse audits, search, site mapping, and asynchronous crawls. It also provides BrowserQL (BQL), a GraphQL API for browser automation with built-in stealth and CAPTCHA solving, and BaaS v2, which lets you connect Puppeteer or Playwright to managed browsers over WebSocket. ## Authentication @@ -20,27 +20,36 @@ All Browserless APIs require an API token. Include your token as a query paramet Extract structured data from web pages using CSS selectors. You provide a URL and one or more CSS selectors, and Browserless returns matching elements with their text, HTML, attributes, and position. Browserless loads the page, runs client-side JavaScript, and then waits (up to 30 seconds by default) for your selectors before scraping. +### Smart Scrape + +Smart Scrape intelligently scrapes any HTTP or HTTPS URL using cascading strategies. Browserless starts with fast HTTP fetching and can escalate through proxying, headless browser rendering, and page-gating CAPTCHA solving as needed. The tool supports HTML, markdown, links, screenshot, and PDF formats. Screenshot and PDF bytes are returned through Slate attachments. + ### Full Page HTML Retrieval Retrieve the fully rendered HTML content of a web page after JavaScript execution. Useful for getting the complete DOM of single-page applications or dynamically rendered pages. ### PDF Generation -The PDF API can render dynamically generated content, ideal for dashboard and report exports. You can supply a URL or raw HTML and configure options such as `printBackground`, page size, margins, headers/footers, and media type emulation. +The PDF API can render dynamically generated content, ideal for dashboard and report exports. You can supply a URL or raw HTML and configure options such as `printBackground`, page size, margins, headers/footers, and media type emulation. PDF bytes are returned through Slate attachments, with only MIME type, byte length, filename, and attachment count in structured output. ### Screenshot Capture -The screenshot API navigates to a page then captures a JPEG or PNG, with options to set the HTML of the page to render dynamically generated content. Supports full-page captures, custom viewport sizes, and various image formats. +The screenshot API navigates to a page then captures a PNG, JPEG, or WebP image, with options to set the HTML of the page to render dynamically generated content. Supports full-page captures, clipping, transparency, resource blocking, and custom navigation waits. Image bytes are returned through Slate attachments. ### File Download -Allows executing browser automation code that triggers file downloads and returns the downloaded files. You provide JavaScript code that navigates and interacts with a page, and Browserless captures any files downloaded during execution. +Allows executing browser automation code that triggers file downloads and returns the downloaded file bytes. You provide JavaScript code that navigates and interacts with a page, and Browserless captures files downloaded during execution. Downloaded bytes are returned through Slate attachments. + +### URL Export + +The export API fetches a URL and streams the result in its native content type, such as HTML, PDF, image, or another binary response. It can also bundle rendered HTML and linked resources into a ZIP when `includeResources` is enabled. Exported bytes are returned through Slate attachments. ### Custom Function Execution A JavaScript content-type API for running Puppeteer code in the browser's context. Browserless sets up a blank page, injects your code, and runs it. You can optionally load external libraries via the "import" module. This enables multi-step browser interactions within a single request, such as navigating, filling forms, and extracting data. -- Supports both raw JavaScript and JSON with context data payloads. +- Supports JSON with code and context data payloads. +- Supports explicit JSON or attachment response modes. File-producing function responses are returned through Slate attachments. - REST APIs are stateless, single-action endpoints. Each request launches a browser, performs one task, and closes the session. ### Website Unblocking @@ -70,9 +79,17 @@ Browserless provides a search endpoint that performs web searches and optionally - When scraping is enabled, each result URL is fetched and processed into your preferred format: clean markdown, raw HTML, extracted links, or a screenshot. You can further refine scraping output with main content extraction, tag filtering, and base64 image removal. - Only available on Cloud plans. +### URL Mapping + +The map API discovers URLs on a website and returns a deduplicated list of links with optional title and description metadata. It supports relevance search, sitemap behavior, geo-targeting, proxy selection, subdomain inclusion, query-parameter deduplication, and result limits. + +### Crawl Management + +The crawl API is an asynchronous, beta Browserless Cloud API for discovering and scraping pages from a site. The integration exposes a single `manage_crawl` tool with `start`, `get`, `list`, and `cancel` actions. Start options include depth, page limit, retries, sitemap handling, path filters, per-page scrape formats, proxy, and optional webhook notifications. Get and list actions return crawl status and metadata; full page content remains available through Browserless-provided short-lived `contentUrl` values. + ### Session Management -The sessions API is for creating and managing persistent browser sessions via REST endpoints. You can create sessions with configurable TTL, stealth mode, and browser arguments, then connect to them via WebSocket for multi-step workflows. +The BaaS sessions API is for creating and managing persistent browser sessions, then connecting Puppeteer or Playwright over WebSocket for long-lived workflows. This integration focuses on Browserless REST tools; BaaS session management is not exposed as a Slate tool here. ### Residential Proxy Support diff --git a/integrations/browserless/package.json b/integrations/browserless/package.json index dbceeaf936..d915d13c02 100644 --- a/integrations/browserless/package.json +++ b/integrations/browserless/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --dir . --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/browserless/src/index.ts b/integrations/browserless/src/index.ts index 8e283dbf83..596f52b516 100644 --- a/integrations/browserless/src/index.ts +++ b/integrations/browserless/src/index.ts @@ -1,11 +1,16 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + downloadFile, + exportUrl, generatePdf, getPageContent, + manageCrawl, + mapSite, runFunction, runPerformanceAudit, scrapePage, + smartScrape, takeScreenshot, unblockPage, webSearch @@ -18,12 +23,17 @@ export let provider = Slate.create({ tools: [ scrapePage, getPageContent, + smartScrape, generatePdf, takeScreenshot, + exportUrl, + downloadFile, unblockPage, runPerformanceAudit, webSearch, - runFunction + runFunction, + mapSite, + manageCrawl ], triggers: [inboundWebhook] }); diff --git a/integrations/browserless/src/lib/client.ts b/integrations/browserless/src/lib/client.ts index b26721170e..f4f10108cd 100644 --- a/integrations/browserless/src/lib/client.ts +++ b/integrations/browserless/src/lib/client.ts @@ -1,7 +1,37 @@ import { createAxios } from 'slates'; +import { browserlessApiError } from './errors'; + +type QueryValue = string | number | boolean | undefined; + +let headerValue = (value: unknown) => { + if (Array.isArray(value)) { + return value[0]; + } + + return typeof value === 'string' ? value : undefined; +}; + +let filenameFromContentDisposition = (contentDisposition?: string) => { + if (!contentDisposition) { + return undefined; + } + + let utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1].replace(/^"|"$/g, '')); + } + + let match = contentDisposition.match(/filename="?([^";]+)"?/i); + return match?.[1]; +}; export interface GotoOptions { - waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; + waitUntil?: + | 'load' + | 'domcontentloaded' + | 'networkidle0' + | 'networkidle2' + | Array<'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'>; timeout?: number; } @@ -152,6 +182,92 @@ export interface FunctionRequest { context?: Record; } +export interface FileResponse { + contentBase64: string; + mimeType: string; + byteLength: number; + filename?: string; +} + +export interface SmartScrapeRequest { + url: string; + formats?: Array<'html' | 'markdown' | 'screenshot' | 'pdf' | 'links'>; + proxy?: 'residential' | 'datacenter'; +} + +export interface SmartScrapeResponse { + ok?: boolean; + statusCode?: number | null; + content?: unknown; + contentType?: string | null; + headers?: Record; + strategy?: string; + attempted?: string[]; + message?: string | null; + screenshot?: string | null; + pdf?: string | null; + markdown?: string | null; + links?: string[] | null; +} + +export interface ExportRequest extends SharedRequestOptions { + url: string; + includeResources?: boolean; +} + +export interface DownloadRequest { + code: string; + context?: Record; +} + +export interface MapRequest { + url: string; + search?: string; + limit?: number; + timeout?: number; + sitemap?: 'include' | 'skip' | 'only'; + includeSubdomains?: boolean; + ignoreQueryParameters?: boolean; + location?: { + country?: string; + languages?: string[]; + }; + proxy?: 'residential' | 'datacenter'; +} + +export interface CrawlStartRequest { + url: string; + limit?: number; + maxDepth?: number; + maxRetries?: number; + allowExternalLinks?: boolean; + allowSubdomains?: boolean; + sitemap?: 'auto' | 'force' | 'skip'; + includePaths?: string[]; + excludePaths?: string[]; + delay?: number; + scrapeOptions?: { + formats?: Array<'markdown' | 'html' | 'rawText'>; + onlyMainContent?: boolean; + includeTags?: string[]; + excludeTags?: string[]; + waitFor?: number; + headers?: Record; + timeout?: number; + proxy?: 'residential' | 'datacenter'; + }; + webhook?: { + url: string; + events?: Array<'page' | 'completed' | 'failed'>; + }; +} + +export interface CrawlListQuery { + limit?: number; + cursor?: string; + status?: 'in-progress' | 'completed' | 'failed' | 'cancelled'; +} + export class BrowserlessClient { private baseUrl: string; private token: string; @@ -165,67 +281,172 @@ export class BrowserlessClient { }); } - private buildUrl(path: string): string { - return `${path}?token=${this.token}`; + private buildUrl(path: string, query: Record = {}): string { + let params = new URLSearchParams({ token: this.token }); + for (let [key, value] of Object.entries(query)) { + if (value !== undefined) { + params.set(key, String(value)); + } + } + + return `${path}?${params.toString()}`; + } + + private async postJson( + operation: string, + path: string, + request: unknown, + query?: Record + ): Promise { + try { + let response = await this.http.post(this.buildUrl(path, query), request, { + headers: { + 'Cache-Control': 'no-cache', + 'Content-Type': 'application/json' + } + }); + return response.data; + } catch (error) { + throw browserlessApiError(error, operation); + } + } + + private async getJson( + operation: string, + path: string, + query?: Record + ): Promise { + try { + let response = await this.http.get(this.buildUrl(path, query)); + return response.data; + } catch (error) { + throw browserlessApiError(error, operation); + } + } + + private async deleteJson(operation: string, path: string): Promise { + try { + let response = await this.http.delete(this.buildUrl(path)); + return response.data; + } catch (error) { + throw browserlessApiError(error, operation); + } + } + + private async postBinary( + operation: string, + path: string, + request: unknown, + query?: Record + ): Promise { + try { + let response = await this.http.post(this.buildUrl(path, query), request, { + headers: { + 'Cache-Control': 'no-cache', + 'Content-Type': 'application/json' + }, + responseType: 'arraybuffer' + }); + let buffer = Buffer.from(response.data); + let mimeType = + headerValue(response.headers?.['content-type']) ?? 'application/octet-stream'; + let filename = filenameFromContentDisposition( + headerValue(response.headers?.['content-disposition']) + ); + + return { + contentBase64: buffer.toString('base64'), + mimeType, + byteLength: buffer.byteLength, + filename + }; + } catch (error) { + throw browserlessApiError(error, operation); + } } async scrape(request: ScrapeRequest): Promise { - let response = await this.http.post(this.buildUrl('/scrape'), request, { - headers: { 'Content-Type': 'application/json' } - }); - return response.data; + return await this.postJson('scrape', '/scrape', request); } async getContent(request: ContentRequest): Promise { - let response = await this.http.post(this.buildUrl('/content'), request, { - headers: { 'Content-Type': 'application/json' } - }); - return response.data; + return await this.postJson('content', '/content', request); } - async generatePdf(request: PdfRequest): Promise { - let response = await this.http.post(this.buildUrl('/pdf'), request, { - headers: { 'Content-Type': 'application/json' }, - responseType: 'arraybuffer' - }); - let buffer = Buffer.from(response.data); - return buffer.toString('base64'); + async generatePdf(request: PdfRequest): Promise { + return await this.postBinary('pdf', '/pdf', request); } - async takeScreenshot(request: ScreenshotRequest): Promise { - let response = await this.http.post(this.buildUrl('/screenshot'), request, { - headers: { 'Content-Type': 'application/json' }, - responseType: 'arraybuffer' - }); - let buffer = Buffer.from(response.data); - return buffer.toString('base64'); + async takeScreenshot(request: ScreenshotRequest): Promise { + return await this.postBinary('screenshot', '/screenshot', request); } - async unblock(request: UnblockRequest): Promise { - let response = await this.http.post(this.buildUrl('/unblock'), request, { - headers: { 'Content-Type': 'application/json' } - }); - return response.data; + async unblock( + request: UnblockRequest, + query?: { proxy?: 'residential' | 'datacenter' } + ): Promise { + return await this.postJson('unblock', '/unblock', request, query); } async runPerformanceAudit(request: PerformanceRequest): Promise { - let response = await this.http.post(this.buildUrl('/performance'), request, { - headers: { 'Content-Type': 'application/json' } - }); - return response.data; + return await this.postJson('performance', '/performance', request); } async search(request: SearchRequest): Promise { - let response = await this.http.post(this.buildUrl('/search'), request, { - headers: { 'Content-Type': 'application/json' } - }); - return response.data; + return await this.postJson('search', '/search', request); } async runFunction(request: FunctionRequest): Promise { - let response = await this.http.post(this.buildUrl('/function'), request, { - headers: { 'Content-Type': 'application/json' } + return await this.postJson('function', '/function', request); + } + + async runFunctionFile(request: FunctionRequest): Promise { + return await this.postBinary('function', '/function', request); + } + + async smartScrape( + request: SmartScrapeRequest, + query?: { timeout?: number; profile?: string } + ) { + return await this.postJson( + 'smart scrape', + '/smart-scrape', + request, + query + ); + } + + async exportUrl(request: ExportRequest): Promise { + return await this.postBinary('export', '/export', request); + } + + async download(request: DownloadRequest): Promise { + return await this.postBinary('download', '/download', request); + } + + async map(request: MapRequest) { + return await this.postJson('map', '/map', request, { timeout: request.timeout }); + } + + async startCrawl(request: CrawlStartRequest, query?: { profile?: string }) { + return await this.postJson('start crawl', '/crawl', request, query); + } + + async getCrawl(crawlId: string, skip?: number) { + return await this.getJson('get crawl', `/crawl/${encodeURIComponent(crawlId)}`, { + skip }); - return response.data; + } + + async listCrawls(query: CrawlListQuery) { + return await this.getJson('list crawls', '/crawl', { + limit: query.limit, + cursor: query.cursor, + status: query.status + }); + } + + async cancelCrawl(crawlId: string) { + return await this.deleteJson('cancel crawl', `/crawl/${encodeURIComponent(crawlId)}`); } } diff --git a/integrations/browserless/src/lib/errors.ts b/integrations/browserless/src/lib/errors.ts new file mode 100644 index 0000000000..18b4b21a50 --- /dev/null +++ b/integrations/browserless/src/lib/errors.ts @@ -0,0 +1,93 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let bufferToMessage = (value: unknown) => { + if (!Buffer.isBuffer(value)) { + return undefined; + } + + let text = value.toString('utf8').trim(); + return text || undefined; +}; + +let extractBrowserlessMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + addDetail(details, bufferToMessage(data)); + + if (isRecord(data)) { + for (let key of ['message', 'error', 'detail', 'code']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getBrowserlessErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return response?.status ?? error.status; +}; + +export let browserlessServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let browserlessApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getBrowserlessErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = browserlessServiceError( + `Browserless API ${operation} failed: ${statusLabel}${extractBrowserlessMessage(error)}` + ); + serviceError.data.reason = 'browserless_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/browserless/src/tools.schema.test.ts b/integrations/browserless/src/tools.schema.test.ts new file mode 100644 index 0000000000..5b9afbd068 --- /dev/null +++ b/integrations/browserless/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Browserless tool input schemas', provider.actions); diff --git a/integrations/browserless/src/tools/download-file.ts b/integrations/browserless/src/tools/download-file.ts new file mode 100644 index 0000000000..ecbd682f4e --- /dev/null +++ b/integrations/browserless/src/tools/download-file.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BrowserlessClient } from '../lib/client'; +import { browserlessServiceError } from '../lib/errors'; +import { spec } from '../spec'; +import { fileAttachment, fileOutput, fileOutputSchema } from './shared'; + +export let downloadFile = SlateTool.create(spec, { + name: 'Download File', + key: 'download_file', + description: `Run custom Puppeteer code and return the file that Chrome downloads during execution. Use this for sites where the target file is produced after clicks, form interactions, or client-side Blob creation.`, + instructions: [ + 'Export a default async function, e.g. `export default async ({ page, context }) => { ... }`.', + 'Your code should trigger a browser download and wait until it is available.', + 'The downloaded bytes are returned through Slate attachments, not inline output fields.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + code: z + .string() + .describe( + 'JavaScript code that navigates, interacts with the page, and triggers a download' + ), + context: z + .record(z.string(), z.any()) + .optional() + .describe('Key-value context data passed to the download function') + }) + ) + .output(fileOutputSchema) + .handleInvocation(async ctx => { + if (!ctx.input.code.trim()) { + throw browserlessServiceError('code cannot be empty.'); + } + + let client = new BrowserlessClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let file = await client.download({ + code: ctx.input.code, + context: ctx.input.context + }); + + return { + output: fileOutput(file), + attachments: [fileAttachment(file)], + message: `Downloaded file from browser automation (${file.byteLength} bytes).` + }; + }) + .build(); diff --git a/integrations/browserless/src/tools/export-url.ts b/integrations/browserless/src/tools/export-url.ts new file mode 100644 index 0000000000..9ec49076fc --- /dev/null +++ b/integrations/browserless/src/tools/export-url.ts @@ -0,0 +1,77 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BrowserlessClient } from '../lib/client'; +import { spec } from '../spec'; +import { + fileAttachment, + fileOutput, + fileOutputSchema, + gotoOptionsSchema, + requireHttpUrl, + waitForSelectorSchema +} from './shared'; + +export let exportUrl = SlateTool.create(spec, { + name: 'Export URL', + key: 'export_url', + description: `Fetch a URL through Browserless and return its native content type as a Slate attachment. Use this to download unknown file types, PDFs, images, rendered HTML, or a ZIP containing a page and its linked resources.`, + instructions: [ + 'Use includeResources to package rendered HTML plus linked assets into a ZIP file.', + 'The exported bytes are returned through Slate attachments, not inline output fields.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + url: z.string().describe('HTTP or HTTPS URL to export'), + includeResources: z + .boolean() + .optional() + .describe('Return a ZIP with HTML and linked resources for offline use'), + gotoOptions: gotoOptionsSchema, + waitForSelector: waitForSelectorSchema, + waitForTimeout: z.number().optional().describe('Wait a fixed number of milliseconds'), + bestAttempt: z.boolean().optional().describe('Proceed even when async events fail'), + rejectResourceTypes: z.array(z.string()).optional().describe('Resource types to block'), + rejectRequestPattern: z + .array(z.string()) + .optional() + .describe('Request URL patterns to block'), + userAgent: z.string().optional().describe('Custom User-Agent string'), + headers: z + .record(z.string(), z.string()) + .optional() + .describe('Additional request headers') + }) + ) + .output(fileOutputSchema) + .handleInvocation(async ctx => { + requireHttpUrl(ctx.input.url); + + let client = new BrowserlessClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let file = await client.exportUrl({ + url: ctx.input.url, + includeResources: ctx.input.includeResources, + gotoOptions: ctx.input.gotoOptions, + waitForSelector: ctx.input.waitForSelector, + waitForTimeout: ctx.input.waitForTimeout, + bestAttempt: ctx.input.bestAttempt, + rejectResourceTypes: ctx.input.rejectResourceTypes, + rejectRequestPattern: ctx.input.rejectRequestPattern, + userAgent: ctx.input.userAgent, + headers: ctx.input.headers + }); + + return { + output: fileOutput(file), + attachments: [fileAttachment(file)], + message: `Exported ${ctx.input.url} as ${file.mimeType} (${file.byteLength} bytes).` + }; + }) + .build(); diff --git a/integrations/browserless/src/tools/generate-pdf.ts b/integrations/browserless/src/tools/generate-pdf.ts index 9c8fcf40bf..2361316c90 100644 --- a/integrations/browserless/src/tools/generate-pdf.ts +++ b/integrations/browserless/src/tools/generate-pdf.ts @@ -2,14 +2,22 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrowserlessClient } from '../lib/client'; import { spec } from '../spec'; +import { + fileAttachment, + fileOutput, + fileOutputSchema, + gotoOptionsSchema, + requireExactlyOneSource, + waitForSelectorSchema +} from './shared'; export let generatePdf = SlateTool.create(spec, { name: 'Generate PDF', key: 'generate_pdf', - description: `Generate a PDF from a web page or raw HTML. Navigate to a URL or render provided HTML in a headless browser and export it as a PDF. Supports page formatting options including paper size, margins, headers/footers, background printing, and landscape orientation. Returns the PDF as a base64-encoded string.`, + description: `Generate a PDF from a web page or raw HTML. Navigate to a URL or render provided HTML in a headless browser and export it as a PDF. Supports page formatting options including paper size, margins, headers/footers, background printing, and landscape orientation. Returns the PDF bytes as a Slate attachment with metadata in the tool output.`, instructions: [ 'Provide either a URL or raw HTML, but not both.', - 'The returned PDF is base64-encoded.' + 'The returned PDF content is in response attachments, not inline output fields.' ], tags: { readOnly: true @@ -52,31 +60,13 @@ export let generatePdf = SlateTool.create(spec, { .enum(['screen', 'print']) .optional() .describe('Emulate media type for CSS media queries'), - gotoOptions: z - .object({ - waitUntil: z - .enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2']) - .optional(), - timeout: z.number().optional() - }) - .optional() - .describe('Navigation options'), - waitForSelector: z - .object({ - selector: z.string(), - timeout: z.number().optional() - }) - .optional() - .describe('Wait for a CSS selector before generating the PDF'), + gotoOptions: gotoOptionsSchema, + waitForSelector: waitForSelectorSchema, waitForTimeout: z.number().optional().describe('Wait a fixed number of milliseconds'), bestAttempt: z.boolean().optional().describe('Proceed even when async events fail') }) ) - .output( - z.object({ - pdfBase64: z.string().describe('Base64-encoded PDF content') - }) - ) + .output(fileOutputSchema) .handleInvocation(async ctx => { let client = new BrowserlessClient({ token: ctx.auth.token, @@ -84,7 +74,9 @@ export let generatePdf = SlateTool.create(spec, { }); let input = ctx.input; - let pdfBase64 = await client.generatePdf({ + requireExactlyOneSource(input); + + let file = await client.generatePdf({ url: input.url, html: input.html, emulateMediaType: input.emulateMediaType, @@ -113,8 +105,9 @@ export let generatePdf = SlateTool.create(spec, { let source = input.url ?? 'provided HTML'; return { - output: { pdfBase64 }, - message: `Generated PDF from ${source}. The PDF is base64-encoded (${pdfBase64.length} characters).` + output: fileOutput(file), + attachments: [fileAttachment(file)], + message: `Generated PDF from ${source} (${file.byteLength} bytes).` }; }) .build(); diff --git a/integrations/browserless/src/tools/get-page-content.ts b/integrations/browserless/src/tools/get-page-content.ts index f7d81597d1..eb648bd57d 100644 --- a/integrations/browserless/src/tools/get-page-content.ts +++ b/integrations/browserless/src/tools/get-page-content.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrowserlessClient } from '../lib/client'; import { spec } from '../spec'; +import { gotoOptionsSchema, requireExactlyOneSource, waitForSelectorSchema } from './shared'; export let getPageContent = SlateTool.create(spec, { name: 'Get Page Content', @@ -18,23 +19,8 @@ export let getPageContent = SlateTool.create(spec, { .string() .optional() .describe('Raw HTML to render in the browser instead of navigating to a URL'), - gotoOptions: z - .object({ - waitUntil: z - .enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2']) - .optional(), - timeout: z.number().optional() - }) - .optional() - .describe('Navigation options'), - waitForSelector: z - .object({ - selector: z.string(), - timeout: z.number().optional(), - visible: z.boolean().optional() - }) - .optional() - .describe('Wait for a CSS selector before capturing content'), + gotoOptions: gotoOptionsSchema, + waitForSelector: waitForSelectorSchema, waitForTimeout: z.number().optional().describe('Wait a fixed number of milliseconds'), bestAttempt: z .boolean() @@ -50,6 +36,8 @@ export let getPageContent = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireExactlyOneSource(ctx.input); + let client = new BrowserlessClient({ token: ctx.auth.token, region: ctx.config.region diff --git a/integrations/browserless/src/tools/index.ts b/integrations/browserless/src/tools/index.ts index e8183ff910..cc58c8af73 100644 --- a/integrations/browserless/src/tools/index.ts +++ b/integrations/browserless/src/tools/index.ts @@ -1,8 +1,13 @@ +export * from './download-file'; +export * from './export-url'; export * from './generate-pdf'; export * from './get-page-content'; +export * from './manage-crawl'; +export * from './map-site'; export * from './run-function'; export * from './run-performance-audit'; export * from './scrape-page'; +export * from './smart-scrape'; export * from './take-screenshot'; export * from './unblock-page'; export * from './web-search'; diff --git a/integrations/browserless/src/tools/manage-crawl.ts b/integrations/browserless/src/tools/manage-crawl.ts new file mode 100644 index 0000000000..b60f6bfd29 --- /dev/null +++ b/integrations/browserless/src/tools/manage-crawl.ts @@ -0,0 +1,293 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BrowserlessClient } from '../lib/client'; +import { browserlessServiceError } from '../lib/errors'; +import { spec } from '../spec'; +import { requireHttpUrl } from './shared'; + +let crawlStatusSchema = z.enum(['in-progress', 'completed', 'failed', 'cancelled']); +let crawlScrapeFormatSchema = z.enum(['markdown', 'html', 'rawText']); + +let requireCrawlId = (crawlId: string | undefined, action: string) => { + if (!crawlId?.trim()) { + throw browserlessServiceError(`crawlId is required for ${action}.`); + } +}; + +export let manageCrawl = SlateTool.create(spec, { + name: 'Manage Crawls', + key: 'manage_crawl', + description: `Start, inspect, list, or cancel Browserless Crawl jobs. Crawls asynchronously discover site URLs and scrape pages into structured, LLM-ready results.`, + instructions: [ + 'Use action "start" with url to create a crawl job.', + 'Use action "get" with crawlId to poll status and retrieve paginated page metadata.', + 'Use action "list" to inspect recent crawl jobs.', + 'Use action "cancel" with crawlId to stop an in-progress crawl.' + ], + constraints: [ + 'The Crawl API is beta and available on Browserless Cloud plans.', + 'Crawl page content is exposed by Browserless as short-lived contentUrl values.' + ], + tags: { + readOnly: false + } +}) + .input( + z.object({ + action: z + .enum(['start', 'get', 'list', 'cancel']) + .describe('Crawl operation to perform'), + url: z.string().optional().describe('HTTP or HTTPS URL to crawl for action "start"'), + crawlId: z.string().optional().describe('Crawl job ID for actions "get" and "cancel"'), + skip: z + .number() + .min(0) + .optional() + .describe('Number of page results to skip for action "get" pagination'), + limit: z + .number() + .min(1) + .max(5000) + .optional() + .describe('Maximum crawls to list, or maximum pages to crawl when starting'), + cursor: z.string().optional().describe('Pagination cursor for action "list"'), + status: crawlStatusSchema.optional().describe('Status filter for action "list"'), + maxDepth: z.number().min(0).max(20).optional().describe('Maximum link-follow depth'), + maxRetries: z + .number() + .min(0) + .max(5) + .optional() + .describe('Retry attempts per failed page'), + allowExternalLinks: z.boolean().optional().describe('Follow links to external domains'), + allowSubdomains: z.boolean().optional().describe('Follow links to subdomains'), + sitemap: z + .enum(['auto', 'force', 'skip']) + .optional() + .describe('Sitemap handling strategy for action "start"'), + includePaths: z + .array(z.string()) + .optional() + .describe('Regex URL path patterns to include for action "start"'), + excludePaths: z + .array(z.string()) + .optional() + .describe('Regex URL path patterns to exclude for action "start"'), + delay: z + .number() + .min(0) + .max(10_000) + .optional() + .describe('Delay between crawl requests in milliseconds'), + scrapeFormats: z + .array(crawlScrapeFormatSchema) + .optional() + .describe('Per-page scrape formats for action "start"'), + onlyMainContent: z + .boolean() + .optional() + .describe('Extract only main content for each crawled page'), + includeTags: z + .array(z.string()) + .optional() + .describe('HTML selectors or tags to include in crawled content'), + excludeTags: z + .array(z.string()) + .optional() + .describe('HTML selectors or tags to exclude from crawled content'), + waitFor: z + .number() + .min(0) + .max(30_000) + .optional() + .describe('Milliseconds to wait after page load before scraping'), + pageTimeout: z + .number() + .min(1_000) + .max(180_000) + .optional() + .describe('Per-page navigation timeout in milliseconds'), + headers: z + .record(z.string(), z.string()) + .optional() + .describe('Custom HTTP headers for page requests'), + proxy: z + .enum(['residential', 'datacenter']) + .optional() + .describe('Proxy network for page fetches'), + webhookUrl: z + .string() + .optional() + .describe('HTTPS webhook URL for Browserless crawl notifications'), + webhookEvents: z + .array(z.enum(['page', 'completed', 'failed'])) + .optional() + .describe('Webhook events to send'), + profile: z + .string() + .optional() + .describe('Saved Browserless authenticated profile name for action "start"') + }) + ) + .output( + z.object({ + action: z.string().describe('Action that was performed'), + success: z.boolean().optional().describe('Whether Browserless reported success'), + crawlId: z.string().optional().describe('Crawl job ID'), + pollUrl: z.string().optional().describe('Browserless status URL for the crawl'), + status: crawlStatusSchema.optional().describe('Current crawl status'), + total: z.number().optional().describe('Total pages discovered'), + completed: z.number().optional().describe('Pages successfully scraped'), + failed: z.number().optional().describe('Pages that failed to scrape'), + expiresAt: z.string().nullable().optional().describe('Result expiration timestamp'), + next: z.string().nullable().optional().describe('Next page URL for crawl results'), + pages: z + .array( + z.object({ + status: z.string().optional(), + contentUrl: z.string().nullable().optional(), + metadata: z.any().optional() + }) + ) + .optional() + .describe('Crawled page result metadata for action "get"'), + crawls: z + .array( + z.object({ + id: z.string().optional(), + url: z.string().optional(), + status: z.string().optional(), + total: z.number().optional(), + completed: z.number().optional(), + createdAt: z.string().optional(), + completedAt: z.string().nullable().optional() + }) + ) + .optional() + .describe('Crawl job summaries for action "list"'), + nextCursor: z.string().nullable().optional().describe('Cursor for the next list page') + }) + ) + .handleInvocation(async ctx => { + let client = new BrowserlessClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + if (ctx.input.action === 'start') { + requireHttpUrl(ctx.input.url, 'url'); + if (ctx.input.webhookUrl) { + let parsed: URL; + try { + parsed = new URL(ctx.input.webhookUrl); + } catch { + throw browserlessServiceError('webhookUrl must be a valid URL.'); + } + if (parsed.protocol !== 'https:') { + throw browserlessServiceError('webhookUrl must use https://.'); + } + } + + let result = await client.startCrawl( + { + url: ctx.input.url!, + limit: ctx.input.limit, + maxDepth: ctx.input.maxDepth, + maxRetries: ctx.input.maxRetries, + allowExternalLinks: ctx.input.allowExternalLinks, + allowSubdomains: ctx.input.allowSubdomains, + sitemap: ctx.input.sitemap, + includePaths: ctx.input.includePaths, + excludePaths: ctx.input.excludePaths, + delay: ctx.input.delay, + scrapeOptions: + ctx.input.scrapeFormats || + ctx.input.onlyMainContent !== undefined || + ctx.input.includeTags || + ctx.input.excludeTags || + ctx.input.waitFor !== undefined || + ctx.input.pageTimeout !== undefined || + ctx.input.headers || + ctx.input.proxy + ? { + formats: ctx.input.scrapeFormats, + onlyMainContent: ctx.input.onlyMainContent, + includeTags: ctx.input.includeTags, + excludeTags: ctx.input.excludeTags, + waitFor: ctx.input.waitFor, + timeout: ctx.input.pageTimeout, + headers: ctx.input.headers, + proxy: ctx.input.proxy + } + : undefined, + webhook: ctx.input.webhookUrl + ? { + url: ctx.input.webhookUrl, + events: ctx.input.webhookEvents + } + : undefined + }, + { profile: ctx.input.profile } + ); + + return { + output: { + action: ctx.input.action, + success: result?.success, + crawlId: result?.id, + pollUrl: result?.url + }, + message: `Started crawl ${result?.id ?? ''} for ${ctx.input.url}.` + }; + } + + if (ctx.input.action === 'get') { + requireCrawlId(ctx.input.crawlId, 'get'); + let result = await client.getCrawl(ctx.input.crawlId!, ctx.input.skip); + return { + output: { + action: ctx.input.action, + status: result?.status, + total: result?.total, + completed: result?.completed, + failed: result?.failed, + expiresAt: result?.expiresAt, + next: result?.next, + pages: Array.isArray(result?.data) ? result.data : [] + }, + message: `Crawl ${ctx.input.crawlId} is ${result?.status ?? 'unknown'}.` + }; + } + + if (ctx.input.action === 'list') { + if (ctx.input.limit && ctx.input.limit > 100) { + throw browserlessServiceError('limit cannot exceed 100 for list crawls.'); + } + + let result = await client.listCrawls({ + limit: ctx.input.limit, + cursor: ctx.input.cursor, + status: ctx.input.status + }); + let crawls = Array.isArray(result?.crawls) ? result.crawls : []; + return { + output: { + action: ctx.input.action, + crawls, + nextCursor: result?.nextCursor + }, + message: `Listed **${crawls.length}** crawl job(s).` + }; + } + + requireCrawlId(ctx.input.crawlId, 'cancel'); + let result = await client.cancelCrawl(ctx.input.crawlId!); + return { + output: { + action: ctx.input.action, + status: result?.status + }, + message: `Cancelled crawl ${ctx.input.crawlId}.` + }; + }) + .build(); diff --git a/integrations/browserless/src/tools/map-site.ts b/integrations/browserless/src/tools/map-site.ts new file mode 100644 index 0000000000..3d36ffd94f --- /dev/null +++ b/integrations/browserless/src/tools/map-site.ts @@ -0,0 +1,100 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BrowserlessClient } from '../lib/client'; +import { spec } from '../spec'; +import { requireHttpUrl } from './shared'; + +export let mapSite = SlateTool.create(spec, { + name: 'Map Site URLs', + key: 'map_site', + description: `Discover URLs on a website with Browserless Map. Returns a deduplicated list of pages with optional title and description metadata, with search relevance, sitemap behavior, geo-targeting, and URL filtering controls.`, + constraints: ['The Map API is available on Browserless Cloud plans.'], + tags: { + readOnly: true + } +}) + .input( + z.object({ + url: z.string().describe('Base HTTP or HTTPS URL to discover links from'), + search: z.string().optional().describe('Search query to order results by relevance'), + limit: z + .number() + .min(1) + .max(5000) + .optional() + .describe('Maximum number of URLs to return'), + timeout: z.number().optional().describe('Request timeout in milliseconds'), + sitemap: z + .enum(['include', 'skip', 'only']) + .optional() + .describe('Whether to include, skip, or exclusively use sitemap URLs'), + includeSubdomains: z.boolean().optional().describe('Include URLs from subdomains'), + ignoreQueryParameters: z + .boolean() + .optional() + .describe('Deduplicate URLs ignoring query strings'), + country: z + .string() + .optional() + .describe('Country code for proxy routing, e.g. "us", "gb", or "de"'), + languages: z + .array(z.string()) + .optional() + .describe('Preferred languages for the request'), + proxy: z + .enum(['residential', 'datacenter']) + .optional() + .describe('Proxy network to route through') + }) + ) + .output( + z.object({ + success: z.boolean().optional().describe('Whether Browserless reported success'), + links: z + .array( + z.object({ + url: z.string().describe('Discovered URL'), + title: z.string().optional().describe('Page title, when available'), + description: z.string().optional().describe('Page description, when available') + }) + ) + .describe('Discovered URLs') + }) + ) + .handleInvocation(async ctx => { + requireHttpUrl(ctx.input.url); + + let client = new BrowserlessClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await client.map({ + url: ctx.input.url, + search: ctx.input.search, + limit: ctx.input.limit, + timeout: ctx.input.timeout, + sitemap: ctx.input.sitemap, + includeSubdomains: ctx.input.includeSubdomains, + ignoreQueryParameters: ctx.input.ignoreQueryParameters, + location: + ctx.input.country || ctx.input.languages + ? { + country: ctx.input.country, + languages: ctx.input.languages + } + : undefined, + proxy: ctx.input.proxy + }); + + let links = Array.isArray(result?.links) ? result.links : []; + + return { + output: { + success: result?.success, + links + }, + message: `Mapped **${links.length}** URL(s) from ${ctx.input.url}.` + }; + }) + .build(); diff --git a/integrations/browserless/src/tools/run-function.ts b/integrations/browserless/src/tools/run-function.ts index 26fa56e1f0..23232d09ae 100644 --- a/integrations/browserless/src/tools/run-function.ts +++ b/integrations/browserless/src/tools/run-function.ts @@ -1,16 +1,18 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrowserlessClient } from '../lib/client'; +import { browserlessServiceError } from '../lib/errors'; import { spec } from '../spec'; +import { fileAttachment, fileOutput } from './shared'; export let runFunction = SlateTool.create(spec, { name: 'Run Browser Function', key: 'run_function', description: `Execute custom JavaScript/Puppeteer code in a headless browser context. Browserless sets up a browser and page, then runs your code with access to the Puppeteer \`page\` object. Use this for multi-step browser interactions like navigating, filling forms, clicking buttons, and extracting data within a single request.`, instructions: [ - 'Your code receives a `page` object (Puppeteer Page) as a parameter.', - 'Return data from your function to receive it in the response.', - 'Use the context parameter to pass dynamic data into your function.' + 'Export a default async function, e.g. `export default async ({ page, context }) => { ... }`.', + 'For responseMode "json", return `{ data, type: "application/json" }` or another JSON-compatible value.', + 'For responseMode "attachment", return file bytes or a Browserless file response; bytes are returned through Slate attachments.' ], constraints: [ 'Code runs in a sandboxed environment. Each request launches a new browser session.' @@ -29,27 +31,62 @@ export let runFunction = SlateTool.create(spec, { context: z .record(z.string(), z.any()) .optional() - .describe('Key-value context data passed to your function') + .describe('Key-value context data passed to your function'), + responseMode: z + .enum(['json', 'attachment']) + .optional() + .default('json') + .describe('Use "json" for structured data or "attachment" for file/binary responses') }) ) .output( z.object({ - result: z.any().describe('Return value from the executed function') + result: z + .any() + .optional() + .describe('Return value from the executed function in json mode'), + mimeType: z.string().optional().describe('MIME type of the returned Slate attachment'), + byteLength: z + .number() + .optional() + .describe('Decoded byte length of the returned attachment'), + filename: z + .string() + .optional() + .describe('Filename reported by Browserless, when available'), + attachmentCount: z.number().describe('Number of Slate attachments returned') }) ) .handleInvocation(async ctx => { + if (!ctx.input.code.trim()) { + throw browserlessServiceError('code cannot be empty.'); + } + let client = new BrowserlessClient({ token: ctx.auth.token, region: ctx.config.region }); + if (ctx.input.responseMode === 'attachment') { + let file = await client.runFunctionFile({ + code: ctx.input.code, + context: ctx.input.context + }); + + return { + output: fileOutput(file), + attachments: [fileAttachment(file)], + message: `Browser function executed successfully and returned an attachment (${file.byteLength} bytes).` + }; + } + let result = await client.runFunction({ code: ctx.input.code, context: ctx.input.context }); return { - output: { result }, + output: { result, attachmentCount: 0 }, message: `Browser function executed successfully.` }; }) diff --git a/integrations/browserless/src/tools/run-performance-audit.ts b/integrations/browserless/src/tools/run-performance-audit.ts index 4dba59eea4..7f0f2b06ac 100644 --- a/integrations/browserless/src/tools/run-performance-audit.ts +++ b/integrations/browserless/src/tools/run-performance-audit.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrowserlessClient } from '../lib/client'; import { spec } from '../spec'; +import { requireHttpUrl } from './shared'; export let runPerformanceAudit = SlateTool.create(spec, { name: 'Run Performance Audit', @@ -61,6 +62,8 @@ export let runPerformanceAudit = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireHttpUrl(ctx.input.url); + let client = new BrowserlessClient({ token: ctx.auth.token, region: ctx.config.region diff --git a/integrations/browserless/src/tools/scrape-page.ts b/integrations/browserless/src/tools/scrape-page.ts index 0a1ad2db0d..f6d37f7ca9 100644 --- a/integrations/browserless/src/tools/scrape-page.ts +++ b/integrations/browserless/src/tools/scrape-page.ts @@ -2,27 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrowserlessClient } from '../lib/client'; import { spec } from '../spec'; - -let gotoOptionsSchema = z - .object({ - waitUntil: z - .enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2']) - .optional() - .describe('When to consider navigation complete'), - timeout: z.number().optional().describe('Navigation timeout in milliseconds') - }) - .optional() - .describe('Navigation options'); - -let waitForSelectorSchema = z - .object({ - selector: z.string().describe('CSS selector to wait for'), - timeout: z.number().optional().describe('Timeout in milliseconds'), - visible: z.boolean().optional().describe('Wait for element to be visible'), - hidden: z.boolean().optional().describe('Wait for element to be hidden') - }) - .optional() - .describe('Wait for a CSS selector to appear on the page'); +import { gotoOptionsSchema, requireHttpUrl, waitForSelectorSchema } from './shared'; export let scrapePage = SlateTool.create(spec, { name: 'Scrape Page', @@ -100,6 +80,8 @@ export let scrapePage = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireHttpUrl(ctx.input.url); + let client = new BrowserlessClient({ token: ctx.auth.token, region: ctx.config.region diff --git a/integrations/browserless/src/tools/shared.ts b/integrations/browserless/src/tools/shared.ts new file mode 100644 index 0000000000..7595428016 --- /dev/null +++ b/integrations/browserless/src/tools/shared.ts @@ -0,0 +1,89 @@ +import { createBase64Attachment, getBase64ByteLength } from 'slates'; +import { z } from 'zod'; +import type { FileResponse } from '../lib/client'; +import { browserlessServiceError } from '../lib/errors'; + +export let waitUntilSchema = z + .enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2']) + .describe('Browser navigation completion event'); + +export let gotoOptionsSchema = z + .object({ + waitUntil: z + .union([waitUntilSchema, z.array(waitUntilSchema)]) + .optional() + .describe('When to consider navigation complete'), + timeout: z.number().optional().describe('Navigation timeout in milliseconds') + }) + .optional() + .describe('Navigation options'); + +export let waitForSelectorSchema = z + .object({ + selector: z.string().describe('CSS selector to wait for'), + timeout: z.number().optional().describe('Timeout in milliseconds'), + visible: z.boolean().optional().describe('Wait for element to be visible'), + hidden: z.boolean().optional().describe('Wait for element to be hidden') + }) + .optional() + .describe('Wait for a CSS selector before continuing'); + +export let fileOutputSchema = z.object({ + mimeType: z.string().describe('MIME type of the returned Slate attachment'), + byteLength: z.number().describe('Decoded byte length of the returned attachment'), + filename: z.string().optional().describe('Filename reported by Browserless, when available'), + attachmentCount: z.number().describe('Number of Slate attachments returned') +}); + +export let fileOutput = (file: FileResponse) => ({ + mimeType: file.mimeType, + byteLength: file.byteLength, + filename: file.filename, + attachmentCount: 1 +}); + +export let fileAttachment = (file: FileResponse) => + createBase64Attachment(file.contentBase64, file.mimeType); + +export let base64FileAttachment = (contentBase64: string, mimeType: string) => + createBase64Attachment(contentBase64, mimeType); + +export let base64ByteLength = getBase64ByteLength; + +export let requireHttpUrl = (value: string | undefined, label = 'url') => { + if (!value) { + throw browserlessServiceError(`${label} is required.`); + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw browserlessServiceError(`${label} must be a valid URL.`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw browserlessServiceError(`${label} must use http:// or https://.`); + } +}; + +export let requireExactlyOneSource = (input: { url?: string; html?: string }) => { + let hasUrl = Boolean(input.url); + let hasHtml = Boolean(input.html); + + if (hasUrl === hasHtml) { + throw browserlessServiceError('Provide exactly one of url or html.'); + } + + if (input.url) { + requireHttpUrl(input.url); + } +}; + +export let requireSmartScrapeSuccess = (result: { ok?: boolean; message?: string | null }) => { + if (result.ok === false) { + throw browserlessServiceError( + result.message || 'Browserless smart scrape did not succeed.' + ); + } +}; diff --git a/integrations/browserless/src/tools/smart-scrape.ts b/integrations/browserless/src/tools/smart-scrape.ts new file mode 100644 index 0000000000..27f54613ee --- /dev/null +++ b/integrations/browserless/src/tools/smart-scrape.ts @@ -0,0 +1,135 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { BrowserlessClient } from '../lib/client'; +import { spec } from '../spec'; +import { + base64ByteLength, + base64FileAttachment, + requireHttpUrl, + requireSmartScrapeSuccess +} from './shared'; + +export let smartScrape = SlateTool.create(spec, { + name: 'Smart Scrape', + key: 'smart_scrape', + description: `Scrape a URL with Browserless Smart Scrape. Browserless automatically escalates from fast HTTP fetching to proxying, headless browser rendering, and page-gating CAPTCHA solving as needed. Returns requested HTML, markdown, links, and optional file attachments for screenshots or PDFs.`, + instructions: [ + 'Use formats to request only the outputs you need.', + 'Screenshot and PDF formats are returned through Slate attachments, not inline base64 fields.', + 'Use BrowserQL instead for submitting forms behind embedded CAPTCHAs.' + ], + constraints: [ + 'Smart Scrape may consume more units when it escalates to browser or proxy strategies.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + url: z.string().describe('HTTP or HTTPS URL to scrape'), + formats: z + .array(z.enum(['html', 'markdown', 'screenshot', 'pdf', 'links'])) + .optional() + .describe('Output formats to include; defaults to Browserless html output'), + proxy: z + .enum(['residential', 'datacenter']) + .optional() + .describe('Proxy network to route the scrape through'), + timeout: z + .number() + .optional() + .describe('Maximum milliseconds allowed for each Smart Scrape strategy attempt'), + profile: z + .string() + .optional() + .describe('Saved Browserless authenticated profile name to load before navigating') + }) + ) + .output( + z.object({ + ok: z.boolean().optional().describe('Whether Browserless reported success'), + statusCode: z.number().nullable().optional().describe('Target URL HTTP status code'), + content: z.any().optional().describe('Scraped HTML string or parsed JSON object'), + contentType: z.string().nullable().optional().describe('Target content type'), + headers: z.record(z.string(), z.string()).optional().describe('Target response headers'), + strategy: z.string().optional().describe('Strategy that produced the scrape result'), + attempted: z + .array(z.string()) + .optional() + .describe('Strategies attempted by Browserless'), + message: z + .string() + .nullable() + .optional() + .describe('Browserless error or status message'), + markdown: z.string().nullable().optional().describe('Markdown output, when requested'), + links: z + .array(z.string()) + .nullable() + .optional() + .describe('Links output, when requested'), + screenshotMimeType: z.string().optional().describe('Screenshot attachment MIME type'), + screenshotByteLength: z + .number() + .optional() + .describe('Screenshot attachment byte length'), + pdfMimeType: z.string().optional().describe('PDF attachment MIME type'), + pdfByteLength: z.number().optional().describe('PDF attachment byte length'), + attachmentCount: z.number().describe('Number of Slate attachments returned') + }) + ) + .handleInvocation(async ctx => { + requireHttpUrl(ctx.input.url); + + let client = new BrowserlessClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await client.smartScrape( + { + url: ctx.input.url, + formats: ctx.input.formats, + proxy: ctx.input.proxy + }, + { + timeout: ctx.input.timeout, + profile: ctx.input.profile + } + ); + requireSmartScrapeSuccess(result); + + let attachments: ReturnType[] = []; + if (result.screenshot) { + attachments.push(base64FileAttachment(result.screenshot, 'image/png')); + } + if (result.pdf) { + attachments.push(base64FileAttachment(result.pdf, 'application/pdf')); + } + + return { + output: { + ok: result.ok, + statusCode: result.statusCode, + content: result.content, + contentType: result.contentType, + headers: result.headers, + strategy: result.strategy, + attempted: result.attempted, + message: result.message, + markdown: result.markdown, + links: result.links, + screenshotMimeType: result.screenshot ? 'image/png' : undefined, + screenshotByteLength: result.screenshot + ? base64ByteLength(result.screenshot) + : undefined, + pdfMimeType: result.pdf ? 'application/pdf' : undefined, + pdfByteLength: result.pdf ? base64ByteLength(result.pdf) : undefined, + attachmentCount: attachments.length + }, + attachments, + message: `Smart scraped ${ctx.input.url} with strategy ${result.strategy ?? 'unknown'}.` + }; + }) + .build(); diff --git a/integrations/browserless/src/tools/take-screenshot.ts b/integrations/browserless/src/tools/take-screenshot.ts index 544bf5dabd..6717627926 100644 --- a/integrations/browserless/src/tools/take-screenshot.ts +++ b/integrations/browserless/src/tools/take-screenshot.ts @@ -2,14 +2,22 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrowserlessClient } from '../lib/client'; import { spec } from '../spec'; +import { + fileAttachment, + fileOutput, + fileOutputSchema, + gotoOptionsSchema, + requireExactlyOneSource, + waitForSelectorSchema +} from './shared'; export let takeScreenshot = SlateTool.create(spec, { name: 'Take Screenshot', key: 'take_screenshot', - description: `Capture a screenshot of a web page or rendered HTML. Supports full-page captures, custom viewports, clipping regions, and multiple image formats (PNG, JPEG, WebP). Returns the screenshot as a base64-encoded string.`, + description: `Capture a screenshot of a web page or rendered HTML. Supports full-page captures, custom viewports, clipping regions, and multiple image formats (PNG, JPEG, WebP). Returns the image bytes as a Slate attachment with metadata in the tool output.`, instructions: [ 'Provide either a URL or raw HTML to capture.', - 'The returned image is base64-encoded.' + 'The returned image content is in response attachments, not inline output fields.' ], tags: { readOnly: true @@ -43,35 +51,15 @@ export let takeScreenshot = SlateTool.create(spec, { }) .optional() .describe('Clip a specific region of the page'), - gotoOptions: z - .object({ - waitUntil: z - .enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2']) - .optional(), - timeout: z.number().optional() - }) - .optional() - .describe('Navigation options'), - waitForSelector: z - .object({ - selector: z.string(), - timeout: z.number().optional(), - visible: z.boolean().optional() - }) - .optional() - .describe('Wait for a CSS selector before taking the screenshot'), + gotoOptions: gotoOptionsSchema, + waitForSelector: waitForSelectorSchema, waitForTimeout: z.number().optional().describe('Wait a fixed number of milliseconds'), bestAttempt: z.boolean().optional().describe('Proceed even when async events fail'), rejectResourceTypes: z.array(z.string()).optional().describe('Resource types to block'), userAgent: z.string().optional().describe('Custom User-Agent string') }) ) - .output( - z.object({ - screenshotBase64: z.string().describe('Base64-encoded screenshot image'), - imageFormat: z.string().describe('Format of the returned image') - }) - ) + .output(fileOutputSchema) .handleInvocation(async ctx => { let client = new BrowserlessClient({ token: ctx.auth.token, @@ -80,8 +68,9 @@ export let takeScreenshot = SlateTool.create(spec, { let input = ctx.input; let format = input.imageFormat ?? 'png'; + requireExactlyOneSource(input); - let screenshotBase64 = await client.takeScreenshot({ + let file = await client.takeScreenshot({ url: input.url, html: input.html, gotoOptions: input.gotoOptions, @@ -102,7 +91,8 @@ export let takeScreenshot = SlateTool.create(spec, { let source = input.url ?? 'provided HTML'; return { - output: { screenshotBase64, imageFormat: format }, + output: fileOutput(file), + attachments: [fileAttachment(file)], message: `Captured ${format.toUpperCase()} screenshot of ${source}${input.fullPage ? ' (full page)' : ''}.` }; }) diff --git a/integrations/browserless/src/tools/unblock-page.ts b/integrations/browserless/src/tools/unblock-page.ts index dd29fffec4..49d4ca5785 100644 --- a/integrations/browserless/src/tools/unblock-page.ts +++ b/integrations/browserless/src/tools/unblock-page.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrowserlessClient } from '../lib/client'; import { spec } from '../spec'; +import { base64ByteLength, base64FileAttachment, requireHttpUrl } from './shared'; export let unblockPage = SlateTool.create(spec, { name: 'Unblock Page', @@ -19,7 +20,18 @@ export let unblockPage = SlateTool.create(spec, { url: z.string().describe('URL of the protected page to access'), returnContent: z.boolean().optional().describe('Return the full HTML page content'), returnCookies: z.boolean().optional().describe('Return cookies from the session'), - returnScreenshot: z.boolean().optional().describe('Return a base64-encoded screenshot'), + returnScreenshot: z + .boolean() + .optional() + .describe('Return a screenshot as a Slate attachment'), + returnBrowserWSEndpoint: z + .boolean() + .optional() + .describe('Return a Browserless WebSocket endpoint for the unblocked browser session'), + proxy: z + .enum(['residential', 'datacenter']) + .optional() + .describe('Proxy network to route through when unblocking'), ttl: z.number().optional().describe('Session time-to-live in milliseconds') }) ) @@ -41,37 +53,65 @@ export let unblockPage = SlateTool.create(spec, { }) ) .describe('Cookies from the session'), - screenshotBase64: z + browserWSEndpoint: z .string() .nullable() - .describe('Base64-encoded screenshot, or null if not requested') + .describe('Browserless WebSocket endpoint, or null if not requested'), + screenshotMimeType: z + .string() + .optional() + .describe('MIME type of the screenshot attachment, when returned'), + screenshotByteLength: z + .number() + .optional() + .describe('Decoded byte length of the screenshot attachment, when returned'), + attachmentCount: z.number().describe('Number of Slate attachments returned') }) ) .handleInvocation(async ctx => { + requireHttpUrl(ctx.input.url); + let client = new BrowserlessClient({ token: ctx.auth.token, region: ctx.config.region }); - let result = await client.unblock({ - url: ctx.input.url, - content: ctx.input.returnContent, - cookies: ctx.input.returnCookies, - screenshot: ctx.input.returnScreenshot, - ttl: ctx.input.ttl - }); + let result = await client.unblock( + { + url: ctx.input.url, + content: ctx.input.returnContent, + cookies: ctx.input.returnCookies, + screenshot: ctx.input.returnScreenshot, + browserWSEndpoint: ctx.input.returnBrowserWSEndpoint, + ttl: ctx.input.ttl + }, + { + proxy: ctx.input.proxy + } + ); let parts: string[] = []; if (result.content) parts.push('HTML content'); if (result.cookies?.length) parts.push(`${result.cookies.length} cookie(s)`); if (result.screenshot) parts.push('screenshot'); + if (result.browserWSEndpoint) parts.push('browser WebSocket endpoint'); + + let attachments = result.screenshot + ? [base64FileAttachment(result.screenshot, 'image/png')] + : []; return { output: { - pageContent: result.content, + pageContent: result.content ?? null, cookies: result.cookies ?? [], - screenshotBase64: result.screenshot + browserWSEndpoint: result.browserWSEndpoint ?? null, + screenshotMimeType: result.screenshot ? 'image/png' : undefined, + screenshotByteLength: result.screenshot + ? base64ByteLength(result.screenshot) + : undefined, + attachmentCount: attachments.length }, + attachments, message: `Unblocked ${ctx.input.url}. Retrieved: ${parts.length > 0 ? parts.join(', ') : 'no data (enable return options)'}.` }; }) diff --git a/integrations/browserless/src/tools/web-search.ts b/integrations/browserless/src/tools/web-search.ts index edfaae8e5f..96c4d69b0a 100644 --- a/integrations/browserless/src/tools/web-search.ts +++ b/integrations/browserless/src/tools/web-search.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { BrowserlessClient } from '../lib/client'; +import { browserlessServiceError } from '../lib/errors'; import { spec } from '../spec'; export let webSearch = SlateTool.create(spec, { @@ -23,7 +24,12 @@ export let webSearch = SlateTool.create(spec, { .array(z.enum(['web', 'news', 'images'])) .optional() .describe('Search sources to query'), - limit: z.number().optional().describe('Maximum number of results to return'), + limit: z + .number() + .min(1) + .max(20) + .optional() + .describe('Maximum number of results to return'), lang: z .string() .optional() @@ -74,6 +80,10 @@ export let webSearch = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (!ctx.input.query.trim()) { + throw browserlessServiceError('query cannot be empty.'); + } + let client = new BrowserlessClient({ token: ctx.auth.token, region: ctx.config.region diff --git a/integrations/browserless/vitest.config.ts b/integrations/browserless/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/browserless/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/cal-com/README.md b/integrations/cal-com/README.md index 578d27f9bd..0fc5c925dc 100644 --- a/integrations/cal-com/README.md +++ b/integrations/cal-com/README.md @@ -28,6 +28,10 @@ Retrieve detailed information about a specific booking by its UID. For recurring Retrieve busy time windows across all connected calendars for a date range. Useful for understanding availability conflicts before scheduling. +### Get Event Type + +Retrieve a specific event type by ID, including booking URL, duration, locations, schedule, booking fields, and host details. + ### Get Profile Retrieve the authenticated user's profile information including name, email, time zone, and other account details. @@ -46,11 +50,19 @@ Retrieve all event types for the authenticated user. Returns event type details ### Manage Booking -Perform lifecycle actions on an existing booking: cancel, reschedule, confirm, decline, mark as no-show, reassign to another host, or add guests. Select the desired action and provide the required parameters. +Perform lifecycle actions on an existing booking: cancel, reschedule, request a reschedule, confirm, decline, mark absence, reassign to another host, add guests, or update the booking location. + +### Manage Out Of Office + +List, create, update, or delete out-of-office entries for the authenticated Cal.com user. ### Manage Schedule -Create, update, or delete an availability schedule. Schedules define when a user can be booked. Each user can have multiple schedules with one set as default. Use action "create" to make a new schedule, "update" to modify an existing one, "delete" to remove one, or "list" to view all schedules. +Create, update, delete, retrieve, or list availability schedules. Schedules define when a user can be booked. Each user can have multiple schedules with one set as default. + +### Manage Slot Reservation + +Reserve, retrieve, update, or delete a temporary slot reservation for a Cal.com event type before creating a booking. ### Update Event Type diff --git a/integrations/cal-com/package.json b/integrations/cal-com/package.json index cdeb40a645..49594d889b 100644 --- a/integrations/cal-com/package.json +++ b/integrations/cal-com/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/cal-com/src/auth.ts b/integrations/cal-com/src/auth.ts index b2cdc1b77d..807ac6a84c 100644 --- a/integrations/cal-com/src/auth.ts +++ b/integrations/cal-com/src/auth.ts @@ -1,5 +1,65 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { calComApiError, calComServiceError } from './lib/errors'; + +let tokenHttp = createAxios({ + baseURL: 'https://api.cal.com/v2' +}); + +let tokenExpiresAt = (expiresIn: unknown) => { + let seconds = typeof expiresIn === 'number' && Number.isFinite(expiresIn) ? expiresIn : 1800; + return new Date(Date.now() + seconds * 1000).toISOString(); +}; + +let exchangeOAuthToken = async (body: Record, operation: string) => { + try { + let response = await tokenHttp.post('/auth/oauth2/token', body, { + headers: { + 'Content-Type': 'application/json' + } + }); + let data = response.data; + + if (!data?.access_token) { + throw calComServiceError( + 'Cal.com OAuth token response did not include an access token.' + ); + } + + return data; + } catch (error) { + throw calComApiError(error, operation); + } +}; + +let getProfileFromToken = async (token: string, operation: string) => { + let http = createAxios({ + baseURL: 'https://api.cal.com/v2', + headers: { + Authorization: `Bearer ${token}` + } + }); + + try { + let response = await http.get('/me'); + let user = response.data?.data; + + if (!user?.id && !user?.email) { + throw calComServiceError('Cal.com profile response did not include a user id or email.'); + } + + return { + profile: { + id: user?.id?.toString(), + email: user?.email, + name: user?.name, + imageUrl: user?.avatarUrl + } + }; + } catch (error) { + throw calComApiError(error, operation); + } +}; export let auth = SlateAuth.create() .output( @@ -18,16 +78,74 @@ export let auth = SlateAuth.create() type: 'docs.auth.oauth', name: 'OAuth documentation', url: 'https://cal.com/docs/api-reference/v2/oauth' + }, + { + type: 'docs.auth.oauth_scopes', + name: 'OAuth scopes', + url: 'https://cal.com/docs/api-reference/v2/oauth#available-scopes' } ], - scopes: [], + scopes: [ + { + title: 'Read Profile', + description: 'Read the authenticated Cal.com user profile', + scope: 'PROFILE_READ' + }, + { + title: 'Read Bookings', + description: 'List and inspect bookings, booking references, and calendar links', + scope: 'BOOKING_READ' + }, + { + title: 'Write Bookings', + description: 'Create bookings and manage booking lifecycle actions', + scope: 'BOOKING_WRITE' + }, + { + title: 'Read Event Types', + description: 'List and inspect event types', + scope: 'EVENT_TYPE_READ' + }, + { + title: 'Write Event Types', + description: 'Create, update, and delete event types', + scope: 'EVENT_TYPE_WRITE' + }, + { + title: 'Read Schedules', + description: 'Read availability schedules and out-of-office entries', + scope: 'SCHEDULE_READ' + }, + { + title: 'Write Schedules', + description: 'Create, update, and delete schedules and out-of-office entries', + scope: 'SCHEDULE_WRITE' + }, + { + title: 'Read Connected Apps', + description: 'Read calendars, busy times, and connected conferencing apps', + scope: 'APPS_READ' + }, + { + title: 'Read Webhooks', + description: 'Read Cal.com webhooks registered by this integration', + scope: 'WEBHOOK_READ' + }, + { + title: 'Write Webhooks', + description: 'Create, update, and delete Cal.com webhooks for triggers', + scope: 'WEBHOOK_WRITE' + } + ], getAuthorizationUrl: async ctx => { let params = new URLSearchParams({ client_id: ctx.clientId, redirect_uri: ctx.redirectUri, - state: ctx.state + response_type: 'code', + state: ctx.state, + scope: ctx.scopes.join(' ') }); return { @@ -36,78 +154,52 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let http = createAxios({}); - - let response = await http.post('https://app.cal.com/api/auth/oauth/token', { - code: ctx.code, - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - grant_type: 'authorization_code', - redirect_uri: ctx.redirectUri - }); - - let data = response.data; + let data = await exchangeOAuthToken( + { + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + grant_type: 'authorization_code', + code: ctx.code, + redirect_uri: ctx.redirectUri + }, + 'exchange OAuth code' + ); return { output: { token: data.access_token, refreshToken: data.refresh_token, - expiresAt: data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined + expiresAt: tokenExpiresAt(data.expires_in) } }; }, handleTokenRefresh: async (ctx: any) => { - let http = createAxios({}); + if (!ctx.output.refreshToken) { + throw calComServiceError('No Cal.com refresh token available.'); + } - let response = await http.post( - 'https://app.cal.com/api/auth/oauth/refreshToken', + let data = await exchangeOAuthToken( { - grant_type: 'refresh_token', client_id: ctx.clientId, - client_secret: ctx.clientSecret + client_secret: ctx.clientSecret, + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken }, - { - headers: { - Authorization: `Bearer ${ctx.output.refreshToken}` - } - } + 'refresh OAuth token' ); - let data = response.data; - return { output: { token: data.access_token, - refreshToken: data.refresh_token, - expiresAt: data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined + refreshToken: data.refresh_token || ctx.output.refreshToken, + expiresAt: tokenExpiresAt(data.expires_in) } }; }, getProfile: async (ctx: any) => { - let http = createAxios({ - baseURL: 'https://api.cal.com/v2', - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); - - let response = await http.get('/me'); - let user = response.data?.data; - - return { - profile: { - id: user?.id?.toString(), - email: user?.email, - name: user?.name, - imageUrl: user?.avatarUrl - } - }; + return await getProfileFromToken(ctx.output.token, 'get OAuth profile'); } }) .addTokenAuth({ @@ -120,31 +212,17 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { + let apiKey = ctx.input.apiKey.trim(); + if (!apiKey) throw calComServiceError('Cal.com API key is required.'); + return { output: { - token: ctx.input.apiKey + token: apiKey } }; }, getProfile: async (ctx: any) => { - let http = createAxios({ - baseURL: 'https://api.cal.com/v2', - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); - - let response = await http.get('/me'); - let user = response.data?.data; - - return { - profile: { - id: user?.id?.toString(), - email: user?.email, - name: user?.name, - imageUrl: user?.avatarUrl - } - }; + return await getProfileFromToken(ctx.output.token, 'get API key profile'); } }); diff --git a/integrations/cal-com/src/index.ts b/integrations/cal-com/src/index.ts index fdd2bcef4e..3c4042da4f 100644 --- a/integrations/cal-com/src/index.ts +++ b/integrations/cal-com/src/index.ts @@ -7,12 +7,15 @@ import { getAvailableSlots, getBooking, getBusyTimes, + getEventType, getProfile, listBookings, listCalendars, listEventTypes, manageBooking, + manageOutOfOffice, manageSchedule, + manageSlotReservation, updateEventType } from './tools'; import { bookingEvents, formEvents, meetingEvents, noShowEvents, oooEvents } from './triggers'; @@ -25,11 +28,14 @@ export let provider = Slate.create({ createBooking, manageBooking, listEventTypes, + getEventType, createEventType, updateEventType, deleteEventType, manageSchedule, getAvailableSlots, + manageSlotReservation, + manageOutOfOffice, getProfile, listCalendars, getBusyTimes diff --git a/integrations/cal-com/src/lib/client.ts b/integrations/cal-com/src/lib/client.ts index e4d8a1bb5a..a5e0cf6609 100644 --- a/integrations/cal-com/src/lib/client.ts +++ b/integrations/cal-com/src/lib/client.ts @@ -1,4 +1,15 @@ import { createAxios } from 'slates'; +import { calComApiError } from './errors'; + +let BOOKING_CREATE_VERSION = '2024-08-13'; +let BOOKING_LIFECYCLE_VERSION = '2026-02-25'; +let BOOKING_LIST_VERSION = '2026-05-01'; +let EVENT_TYPE_VERSION = '2024-06-14'; +let SCHEDULE_VERSION = '2024-06-11'; +let SLOT_VERSION = '2024-09-04'; + +let hasDataProperty = (value: unknown): value is { data: unknown } => + typeof value === 'object' && value !== null && 'data' in value; export class Client { private http; @@ -13,227 +24,361 @@ export class Client { }); } - // ── Profile ── + private async request(operation: string, request: () => Promise): Promise { + try { + let response = await request(); + let envelope = response.data; + + return (hasDataProperty(envelope) ? envelope.data : envelope) as T; + } catch (error) { + throw calComApiError(error, operation); + } + } + + private async envelope(operation: string, request: () => Promise): Promise { + try { + let response = await request(); + return response.data as T; + } catch (error) { + throw calComApiError(error, operation); + } + } + + // Profile async getMe() { - let response = await this.http.get('/me'); - return response.data?.data; + return await this.request('get profile', () => this.http.get('/me')); } async updateMe(data: Record) { - let response = await this.http.patch('/me', data); - return response.data?.data; + return await this.request('update profile', () => this.http.patch('/me', data)); } - // ── Bookings ── + // Bookings async listBookings(params?: Record) { - let response = await this.http.get('/bookings', { - params, - headers: { 'cal-api-version': '2024-08-13' } - }); - return response.data?.data; + let response = await this.envelope('list bookings', () => + this.http.get('/bookings', { + params, + headers: { 'cal-api-version': BOOKING_LIST_VERSION } + }) + ); + + return { + bookings: Array.isArray(response?.data) ? response.data : [], + pagination: response?.pagination + }; } async getBooking(bookingUid: string) { - let response = await this.http.get(`/bookings/${bookingUid}`, { - headers: { 'cal-api-version': '2024-08-13' } - }); - return response.data?.data; + return await this.request('get booking', () => + this.http.get(`/bookings/${bookingUid}`, { + headers: { 'cal-api-version': BOOKING_CREATE_VERSION } + }) + ); } async createBooking(data: Record) { - let response = await this.http.post('/bookings', data, { - headers: { 'cal-api-version': '2024-08-13' } - }); - return response.data?.data; + return await this.request('create booking', () => + this.http.post('/bookings', data, { + headers: { 'cal-api-version': BOOKING_CREATE_VERSION } + }) + ); } async cancelBooking(bookingUid: string, data?: Record) { - let response = await this.http.post(`/bookings/${bookingUid}/cancel`, data || {}, { - headers: { 'cal-api-version': '2024-08-13' } - }); - return response.data?.data; + return await this.request('cancel booking', () => + this.http.post(`/bookings/${bookingUid}/cancel`, data || {}, { + headers: { 'cal-api-version': BOOKING_LIFECYCLE_VERSION } + }) + ); } async rescheduleBooking(bookingUid: string, data: Record) { - let response = await this.http.post(`/bookings/${bookingUid}/reschedule`, data, { - headers: { 'cal-api-version': '2024-08-13' } - }); - return response.data?.data; + return await this.request('reschedule booking', () => + this.http.post(`/bookings/${bookingUid}/reschedule`, data, { + headers: { 'cal-api-version': BOOKING_LIFECYCLE_VERSION } + }) + ); + } + + async requestRescheduleBooking(bookingUid: string, data: Record) { + return await this.request('request booking reschedule', () => + this.http.post(`/bookings/${bookingUid}/request-reschedule`, data, { + headers: { 'cal-api-version': BOOKING_LIFECYCLE_VERSION } + }) + ); } async confirmBooking(bookingUid: string) { - let response = await this.http.post( - `/bookings/${bookingUid}/confirm`, - {}, - { - headers: { 'cal-api-version': '2024-08-13' } - } + return await this.request('confirm booking', () => + this.http.post( + `/bookings/${bookingUid}/confirm`, + {}, + { + headers: { 'cal-api-version': BOOKING_LIFECYCLE_VERSION } + } + ) ); - return response.data?.data; } async declineBooking(bookingUid: string, data?: Record) { - let response = await this.http.post(`/bookings/${bookingUid}/decline`, data || {}, { - headers: { 'cal-api-version': '2024-08-13' } - }); - return response.data?.data; + return await this.request('decline booking', () => + this.http.post(`/bookings/${bookingUid}/decline`, data || {}, { + headers: { 'cal-api-version': BOOKING_LIFECYCLE_VERSION } + }) + ); } async markNoShow(bookingUid: string, data: Record) { - let response = await this.http.post(`/bookings/${bookingUid}/mark-absent`, data, { - headers: { 'cal-api-version': '2024-08-13' } - }); - return response.data?.data; + return await this.request('mark booking absence', () => + this.http.post(`/bookings/${bookingUid}/mark-absent`, data, { + headers: { 'cal-api-version': BOOKING_LIFECYCLE_VERSION } + }) + ); } async reassignBooking(bookingUid: string, userId?: number) { let url = userId ? `/bookings/${bookingUid}/reassign/${userId}` : `/bookings/${bookingUid}/reassign`; - let response = await this.http.post( - url, - {}, - { - headers: { 'cal-api-version': '2024-08-13' } - } + return await this.request('reassign booking', () => + this.http.post( + url, + {}, + { + headers: { 'cal-api-version': BOOKING_LIFECYCLE_VERSION } + } + ) ); - return response.data?.data; } - async addGuests(bookingUid: string, guests: { email: string; name?: string }[]) { - let response = await this.http.post( - `/bookings/${bookingUid}/guests`, - { guests }, - { - headers: { 'cal-api-version': '2024-08-13' } - } + async addGuests( + bookingUid: string, + guests: { email: string; name?: string; timeZone?: string }[] + ) { + return await this.request('add booking guests', () => + this.http.post( + `/bookings/${bookingUid}/guests`, + { guests }, + { + headers: { 'cal-api-version': BOOKING_CREATE_VERSION } + } + ) ); - return response.data?.data; } - // ── Event Types ── + async updateBookingLocation(bookingUid: string, location: Record) { + return await this.request('update booking location', () => + this.http.patch( + `/bookings/${bookingUid}/location`, + { location }, + { + headers: { 'cal-api-version': BOOKING_CREATE_VERSION } + } + ) + ); + } + + // Event Types async listEventTypes(params?: Record) { - let response = await this.http.get('/event-types', { - params, - headers: { 'cal-api-version': '2024-06-14' } - }); - return response.data?.data; + return await this.request('list event types', () => + this.http.get('/event-types', { + params, + headers: { 'cal-api-version': EVENT_TYPE_VERSION } + }) + ); } async getEventType(eventTypeId: number) { - let response = await this.http.get(`/event-types/${eventTypeId}`, { - headers: { 'cal-api-version': '2024-06-14' } - }); - return response.data?.data; + return await this.request('get event type', () => + this.http.get(`/event-types/${eventTypeId}`, { + headers: { 'cal-api-version': EVENT_TYPE_VERSION } + }) + ); } async createEventType(data: Record) { - let response = await this.http.post('/event-types', data, { - headers: { 'cal-api-version': '2024-06-14' } - }); - return response.data?.data; + return await this.request('create event type', () => + this.http.post('/event-types', data, { + headers: { 'cal-api-version': EVENT_TYPE_VERSION } + }) + ); } async updateEventType(eventTypeId: number, data: Record) { - let response = await this.http.patch(`/event-types/${eventTypeId}`, data, { - headers: { 'cal-api-version': '2024-06-14' } - }); - return response.data?.data; + return await this.request('update event type', () => + this.http.patch(`/event-types/${eventTypeId}`, data, { + headers: { 'cal-api-version': EVENT_TYPE_VERSION } + }) + ); } async deleteEventType(eventTypeId: number) { - let response = await this.http.delete(`/event-types/${eventTypeId}`, { - headers: { 'cal-api-version': '2024-06-14' } - }); - return response.data?.data; + return await this.request('delete event type', () => + this.http.delete(`/event-types/${eventTypeId}`, { + headers: { 'cal-api-version': EVENT_TYPE_VERSION } + }) + ); } - // ── Schedules ── + // Schedules async listSchedules() { - let response = await this.http.get('/schedules', { - headers: { 'cal-api-version': '2024-06-11' } - }); - return response.data?.data; + return await this.request('list schedules', () => + this.http.get('/schedules', { + headers: { 'cal-api-version': SCHEDULE_VERSION } + }) + ); + } + + async getDefaultSchedule() { + return await this.request('get default schedule', () => + this.http.get('/schedules/default', { + headers: { 'cal-api-version': SCHEDULE_VERSION } + }) + ); } async getSchedule(scheduleId: number) { - let response = await this.http.get(`/schedules/${scheduleId}`, { - headers: { 'cal-api-version': '2024-06-11' } - }); - return response.data?.data; + return await this.request('get schedule', () => + this.http.get(`/schedules/${scheduleId}`, { + headers: { 'cal-api-version': SCHEDULE_VERSION } + }) + ); } async createSchedule(data: Record) { - let response = await this.http.post('/schedules', data, { - headers: { 'cal-api-version': '2024-06-11' } - }); - return response.data?.data; + return await this.request('create schedule', () => + this.http.post('/schedules', data, { + headers: { 'cal-api-version': SCHEDULE_VERSION } + }) + ); } async updateSchedule(scheduleId: number, data: Record) { - let response = await this.http.patch(`/schedules/${scheduleId}`, data, { - headers: { 'cal-api-version': '2024-06-11' } - }); - return response.data?.data; + return await this.request('update schedule', () => + this.http.patch(`/schedules/${scheduleId}`, data, { + headers: { 'cal-api-version': SCHEDULE_VERSION } + }) + ); } async deleteSchedule(scheduleId: number) { - let response = await this.http.delete(`/schedules/${scheduleId}`, { - headers: { 'cal-api-version': '2024-06-11' } - }); - return response.data?.data; + return await this.request('delete schedule', () => + this.http.delete(`/schedules/${scheduleId}`, { + headers: { 'cal-api-version': SCHEDULE_VERSION } + }) + ); } - // ── Slots ── + // Slots async getAvailableSlots(params: Record) { - let response = await this.http.get('/slots', { - params, - headers: { 'cal-api-version': '2024-09-04' } - }); - return response.data?.data; + return await this.request('get available slots', () => + this.http.get('/slots', { + params, + headers: { 'cal-api-version': SLOT_VERSION } + }) + ); + } + + async reserveSlot(data: Record) { + return await this.request('reserve slot', () => + this.http.post('/slots/reservations', data, { + headers: { 'cal-api-version': SLOT_VERSION } + }) + ); + } + + async getReservedSlot(reservationUid: string) { + return await this.request('get reserved slot', () => + this.http.get(`/slots/reservations/${reservationUid}`, { + headers: { 'cal-api-version': SLOT_VERSION } + }) + ); + } + + async updateReservedSlot(reservationUid: string, data: Record) { + return await this.request('update reserved slot', () => + this.http.patch(`/slots/reservations/${reservationUid}`, data, { + headers: { 'cal-api-version': SLOT_VERSION } + }) + ); + } + + async deleteReservedSlot(reservationUid: string) { + return await this.request('delete reserved slot', () => + this.http.delete(`/slots/reservations/${reservationUid}`, { + headers: { 'cal-api-version': SLOT_VERSION } + }) + ); } - // ── Webhooks (User-level) ── + // Out of Office + + async listOutOfOffice(params?: Record) { + return await this.request('list out-of-office entries', () => + this.http.get('/me/ooo', { params }) + ); + } + + async createOutOfOffice(data: Record) { + return await this.request('create out-of-office entry', () => + this.http.post('/me/ooo', data) + ); + } + + async updateOutOfOffice(oooId: number, data: Record) { + return await this.request('update out-of-office entry', () => + this.http.patch(`/me/ooo/${oooId}`, data) + ); + } + + async deleteOutOfOffice(oooId: number) { + return await this.request('delete out-of-office entry', () => + this.http.delete(`/me/ooo/${oooId}`) + ); + } + + // Webhooks (User-level) async listWebhooks() { - let response = await this.http.get('/webhooks'); - return response.data?.data; + return await this.request('list webhooks', () => this.http.get('/webhooks')); } async getWebhook(webhookId: number) { - let response = await this.http.get(`/webhooks/${webhookId}`); - return response.data?.data; + return await this.request('get webhook', () => + this.http.get(`/webhooks/${webhookId}`) + ); } async createWebhook(data: Record) { - let response = await this.http.post('/webhooks', data); - return response.data?.data; + return await this.request('create webhook', () => this.http.post('/webhooks', data)); } async updateWebhook(webhookId: number, data: Record) { - let response = await this.http.patch(`/webhooks/${webhookId}`, data); - return response.data?.data; + return await this.request('update webhook', () => + this.http.patch(`/webhooks/${webhookId}`, data) + ); } async deleteWebhook(webhookId: number) { - let response = await this.http.delete(`/webhooks/${webhookId}`); - return response.data?.data; + return await this.request('delete webhook', () => + this.http.delete(`/webhooks/${webhookId}`) + ); } - // ── Calendars ── + // Calendars async listCalendars() { - let response = await this.http.get('/calendars'); - return response.data?.data; + return await this.request('list calendars', () => this.http.get('/calendars')); } async getBusyTimes(params: Record) { - let response = await this.http.get('/calendars/busy-times', { params }); - return response.data?.data; + return await this.request('get busy times', () => + this.http.get('/calendars/busy-times', { params }) + ); } } diff --git a/integrations/cal-com/src/lib/errors.ts b/integrations/cal-com/src/lib/errors.ts new file mode 100644 index 0000000000..dcf848d0e0 --- /dev/null +++ b/integrations/cal-com/src/lib/errors.ts @@ -0,0 +1,78 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) details.push(detail); +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) collectDetails(item, details); + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.error); + pushDetail(details, value.error_description); + pushDetail(details, value.detail); + pushDetail(details, value.code); + collectDetails(value.errors, details); +}; + +let extractCalComMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) return details.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) return undefined; + + let code = response.data.code ?? response.data.error; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +}; + +export let calComServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let calComApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = calComServiceError( + `Cal.com API ${operation} failed: ${statusLabelFor(response)}${extractCalComMessage(error)}` + ); + serviceError.data.reason = 'calcom_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) serviceError.setParent(error); + + return serviceError; +}; diff --git a/integrations/cal-com/src/tools.schema.test.ts b/integrations/cal-com/src/tools.schema.test.ts new file mode 100644 index 0000000000..5c86189ae4 --- /dev/null +++ b/integrations/cal-com/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Cal.com tool input schemas', provider.actions); diff --git a/integrations/cal-com/src/tools/create-booking.ts b/integrations/cal-com/src/tools/create-booking.ts index 1d63c635f7..e1e6144aee 100644 --- a/integrations/cal-com/src/tools/create-booking.ts +++ b/integrations/cal-com/src/tools/create-booking.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { calComServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createBooking = SlateTool.create(spec, { @@ -24,6 +25,16 @@ export let createBooking = SlateTool.create(spec, { .string() .optional() .describe('Username of the event type owner (used with eventTypeSlug)'), + teamSlug: z + .string() + .optional() + .describe( + 'Team slug of the event type owner (used with eventTypeSlug for team event types)' + ), + organizationSlug: z + .string() + .optional() + .describe('Organization slug when booking an organization user or team event type'), start: z.string().describe('Start time of the booking in ISO 8601 UTC format'), attendeeName: z.string().describe('Full name of the attendee'), attendeeEmail: z.string().describe('Email address of the attendee'), @@ -34,13 +45,43 @@ export let createBooking = SlateTool.create(spec, { .string() .optional() .describe('Phone number of the attendee (required if SMS reminders are enabled)'), + attendeeLanguage: z + .string() + .optional() + .describe('Optional attendee language code, such as en or it'), guests: z .array(z.string()) .optional() .describe('List of guest email addresses to include'), - location: z.string().optional().describe('Meeting location or URL'), + location: z + .any() + .optional() + .describe( + 'Cal.com location object, for example { "type": "address", "address": "..." }' + ), meetingUrl: z.string().optional().describe('Custom meeting URL'), notes: z.string().optional().describe('Additional notes or comments for the booking'), + lengthInMinutes: z + .number() + .optional() + .describe('Requested duration for event types that support variable durations'), + routing: z + .any() + .optional() + .describe('Routing form response details for routed bookings'), + emailVerificationCode: z + .string() + .optional() + .describe('Email verification code when the event type requires email verification'), + allowConflicts: z.boolean().optional().describe('Allow booking even if conflicts exist'), + allowBookingOutOfBounds: z + .boolean() + .optional() + .describe('Allow booking outside the configured event bounds'), + instant: z + .boolean() + .optional() + .describe('Create an instant meeting for supported team event types'), metadata: z .record(z.string(), z.any()) .optional() @@ -62,23 +103,46 @@ export let createBooking = SlateTool.create(spec, { baseUrl: ctx.config.baseUrl }); + if (!ctx.input.eventTypeId && !ctx.input.eventTypeSlug) { + throw calComServiceError( + 'Provide eventTypeId, or provide eventTypeSlug with username or teamSlug.' + ); + } + + if (ctx.input.eventTypeSlug && !ctx.input.username && !ctx.input.teamSlug) { + throw calComServiceError( + 'username or teamSlug is required when booking by eventTypeSlug.' + ); + } + let body: Record = { start: ctx.input.start, attendee: { name: ctx.input.attendeeName, email: ctx.input.attendeeEmail, timeZone: ctx.input.attendeeTimeZone, - phoneNumber: ctx.input.attendeePhoneNumber + phoneNumber: ctx.input.attendeePhoneNumber, + language: ctx.input.attendeeLanguage } }; if (ctx.input.eventTypeId) body.eventTypeId = ctx.input.eventTypeId; if (ctx.input.eventTypeSlug) body.eventTypeSlug = ctx.input.eventTypeSlug; if (ctx.input.username) body.username = ctx.input.username; + if (ctx.input.teamSlug) body.teamSlug = ctx.input.teamSlug; + if (ctx.input.organizationSlug) body.organizationSlug = ctx.input.organizationSlug; if (ctx.input.guests) body.guests = ctx.input.guests; if (ctx.input.location) body.location = ctx.input.location; if (ctx.input.meetingUrl) body.meetingUrl = ctx.input.meetingUrl; if (ctx.input.notes) body.notes = ctx.input.notes; + if (ctx.input.lengthInMinutes) body.lengthInMinutes = ctx.input.lengthInMinutes; + if (ctx.input.routing) body.routing = ctx.input.routing; + if (ctx.input.emailVerificationCode) + body.emailVerificationCode = ctx.input.emailVerificationCode; + if (ctx.input.allowConflicts !== undefined) body.allowConflicts = ctx.input.allowConflicts; + if (ctx.input.allowBookingOutOfBounds !== undefined) + body.allowBookingOutOfBounds = ctx.input.allowBookingOutOfBounds; + if (ctx.input.instant !== undefined) body.instant = ctx.input.instant; if (ctx.input.metadata) body.metadata = ctx.input.metadata; if (ctx.input.bookingFieldsResponses) body.bookingFieldsResponses = ctx.input.bookingFieldsResponses; diff --git a/integrations/cal-com/src/tools/get-available-slots.ts b/integrations/cal-com/src/tools/get-available-slots.ts index f82990d7b8..d91ebf6908 100644 --- a/integrations/cal-com/src/tools/get-available-slots.ts +++ b/integrations/cal-com/src/tools/get-available-slots.ts @@ -1,12 +1,13 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { calComServiceError } from '../lib/errors'; import { spec } from '../spec'; export let getAvailableSlots = SlateTool.create(spec, { name: 'Get Available Slots', key: 'get_available_slots', - description: `Query available booking time slots for a given event type within a date range. Slots can be looked up by event type ID, or by event type slug and username combination. Useful for finding open times before creating a booking.`, + description: `Query available booking time slots for a given event type within a date range. Slots can be looked up by event type ID, event type slug plus username or team slug, or usernames. Useful for finding open times before creating a booking.`, tags: { readOnly: true } @@ -19,12 +20,32 @@ export let getAvailableSlots = SlateTool.create(spec, { .string() .optional() .describe('Username of the event type owner (used with eventTypeSlug)'), - startTime: z.string().describe('Start of the date range (ISO 8601)'), - endTime: z.string().describe('End of the date range (ISO 8601)'), + teamSlug: z + .string() + .optional() + .describe('Team slug when checking a team event type by slug'), + organizationSlug: z + .string() + .optional() + .describe('Organization slug when checking an organization-scoped event type'), + usernames: z + .array(z.string()) + .optional() + .describe('Usernames to check for collective or round-robin event types'), + start: z.string().optional().describe('Start of the date range (ISO 8601)'), + end: z.string().optional().describe('End of the date range (ISO 8601)'), + startTime: z.string().optional().describe('Deprecated alias for start. Prefer start.'), + endTime: z.string().optional().describe('Deprecated alias for end. Prefer end.'), timeZone: z .string() .optional() - .describe('Time zone for the returned slots (e.g., America/New_York)') + .describe('Time zone for the returned slots (e.g., America/New_York)'), + format: z.enum(['time', 'range']).optional().describe('Slot response format'), + duration: z.number().optional().describe('Requested slot duration in minutes'), + bookingUidToReschedule: z + .string() + .optional() + .describe('Booking UID to exclude while finding reschedule slots') }) ) .output( @@ -38,21 +59,44 @@ export let getAvailableSlots = SlateTool.create(spec, { baseUrl: ctx.config.baseUrl }); + let start = ctx.input.start ?? ctx.input.startTime; + let end = ctx.input.end ?? ctx.input.endTime; + if (!start || !end) { + throw calComServiceError('start and end are required.'); + } + + let hasEventTypeId = ctx.input.eventTypeId !== undefined; + let hasSlugTarget = + !!ctx.input.eventTypeSlug && (!!ctx.input.username || !!ctx.input.teamSlug); + let hasUsernames = !!ctx.input.usernames && ctx.input.usernames.length > 0; + if (!hasEventTypeId && !hasSlugTarget && !hasUsernames) { + throw calComServiceError( + 'Provide eventTypeId, eventTypeSlug with username or teamSlug, or usernames.' + ); + } + let params: Record = { - startTime: ctx.input.startTime, - endTime: ctx.input.endTime + start, + end }; - if (ctx.input.eventTypeId) params.eventTypeId = ctx.input.eventTypeId; + if (ctx.input.eventTypeId !== undefined) params.eventTypeId = ctx.input.eventTypeId; if (ctx.input.eventTypeSlug) params.eventTypeSlug = ctx.input.eventTypeSlug; if (ctx.input.username) params.username = ctx.input.username; + if (ctx.input.teamSlug) params.teamSlug = ctx.input.teamSlug; + if (ctx.input.organizationSlug) params.organizationSlug = ctx.input.organizationSlug; + if (ctx.input.usernames) params.usernames = ctx.input.usernames; if (ctx.input.timeZone) params.timeZone = ctx.input.timeZone; + if (ctx.input.format) params.format = ctx.input.format; + if (ctx.input.duration !== undefined) params.duration = ctx.input.duration; + if (ctx.input.bookingUidToReschedule) + params.bookingUidToReschedule = ctx.input.bookingUidToReschedule; let slots = await client.getAvailableSlots(params); return { output: { slots }, - message: `Retrieved available slots from ${ctx.input.startTime} to ${ctx.input.endTime}.` + message: `Retrieved available slots from ${start} to ${end}.` }; }) .build(); diff --git a/integrations/cal-com/src/tools/get-busy-times.ts b/integrations/cal-com/src/tools/get-busy-times.ts index 2a27968edb..4c03e9a8d5 100644 --- a/integrations/cal-com/src/tools/get-busy-times.ts +++ b/integrations/cal-com/src/tools/get-busy-times.ts @@ -15,7 +15,23 @@ export let getBusyTimes = SlateTool.create(spec, { z.object({ dateFrom: z.string().describe('Start of the date range (ISO 8601)'), dateTo: z.string().describe('End of the date range (ISO 8601)'), - loggedInUsersTz: z.string().optional().describe('Time zone of the logged-in user') + timeZone: z + .string() + .optional() + .describe('Time zone for the busy-time query (e.g., America/New_York)'), + loggedInUsersTz: z + .string() + .optional() + .describe('Deprecated alias for timeZone. Prefer timeZone.'), + calendarsToLoad: z + .array( + z.object({ + credentialId: z.number().describe('Cal.com credential ID'), + externalId: z.string().describe('External calendar ID') + }) + ) + .optional() + .describe('Specific calendars to include in the busy-time query') }) ) .output( @@ -33,7 +49,9 @@ export let getBusyTimes = SlateTool.create(spec, { dateFrom: ctx.input.dateFrom, dateTo: ctx.input.dateTo }; - if (ctx.input.loggedInUsersTz) params.loggedInUsersTz = ctx.input.loggedInUsersTz; + if (ctx.input.timeZone) params.timeZone = ctx.input.timeZone; + else if (ctx.input.loggedInUsersTz) params.timeZone = ctx.input.loggedInUsersTz; + if (ctx.input.calendarsToLoad) params.calendarsToLoad = ctx.input.calendarsToLoad; let busyTimes = await client.getBusyTimes(params); diff --git a/integrations/cal-com/src/tools/get-event-type.ts b/integrations/cal-com/src/tools/get-event-type.ts new file mode 100644 index 0000000000..9dceab4a5f --- /dev/null +++ b/integrations/cal-com/src/tools/get-event-type.ts @@ -0,0 +1,37 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getEventType = SlateTool.create(spec, { + name: 'Get Event Type', + key: 'get_event_type', + description: `Retrieve a specific Cal.com event type by ID, including booking URL, duration, locations, schedule, booking fields, and host details.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + eventTypeId: z.number().describe('ID of the event type to retrieve') + }) + ) + .output( + z.object({ + eventType: z.any().describe('Event type details') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + let eventType = await client.getEventType(ctx.input.eventTypeId); + + return { + output: { eventType }, + message: `Event type **${ctx.input.eventTypeId}** retrieved.` + }; + }) + .build(); diff --git a/integrations/cal-com/src/tools/index.ts b/integrations/cal-com/src/tools/index.ts index 489fa503cb..6c59ab6b6b 100644 --- a/integrations/cal-com/src/tools/index.ts +++ b/integrations/cal-com/src/tools/index.ts @@ -4,10 +4,13 @@ export * from './delete-event-type'; export * from './get-available-slots'; export * from './get-booking'; export * from './get-busy-times'; +export * from './get-event-type'; export * from './get-profile'; export * from './list-bookings'; export * from './list-calendars'; export * from './list-event-types'; export * from './manage-booking'; +export * from './manage-out-of-office'; export * from './manage-schedule'; +export * from './manage-slot-reservation'; export * from './update-event-type'; diff --git a/integrations/cal-com/src/tools/list-bookings.ts b/integrations/cal-com/src/tools/list-bookings.ts index be945a0bd1..98de0d681f 100644 --- a/integrations/cal-com/src/tools/list-bookings.ts +++ b/integrations/cal-com/src/tools/list-bookings.ts @@ -21,6 +21,9 @@ export let listBookings = SlateTool.create(spec, { attendeeName: z.string().optional().describe('Filter by attendee name'), eventTypeId: z.number().optional().describe('Filter by event type ID'), eventTypeIds: z.string().optional().describe('Comma-separated list of event type IDs'), + bookingUid: z.string().optional().describe('Filter by booking UID'), + teamId: z.number().optional().describe('Filter by team ID'), + teamIds: z.string().optional().describe('Comma-separated list of team IDs'), afterStart: z .string() .optional() @@ -29,14 +32,50 @@ export let listBookings = SlateTool.create(spec, { .string() .optional() .describe('Only return bookings ending before this ISO 8601 date'), + afterCreatedAt: z + .string() + .optional() + .describe('Only return bookings created after this ISO 8601 date'), + beforeCreatedAt: z + .string() + .optional() + .describe('Only return bookings created before this ISO 8601 date'), + afterUpdatedAt: z + .string() + .optional() + .describe('Only return bookings updated after this ISO 8601 date'), + beforeUpdatedAt: z + .string() + .optional() + .describe('Only return bookings updated before this ISO 8601 date'), sortStart: z.enum(['asc', 'desc']).optional().describe('Sort order by start time'), - take: z.number().optional().describe('Number of bookings to return (pagination)'), - skip: z.number().optional().describe('Number of bookings to skip (pagination)') + sortEnd: z.enum(['asc', 'desc']).optional().describe('Sort order by end time'), + sortCreated: z + .enum(['asc', 'desc']) + .optional() + .describe('Sort order by booking creation time'), + sortUpdatedAt: z + .enum(['asc', 'desc']) + .optional() + .describe('Sort order by booking update time'), + cursor: z + .string() + .optional() + .describe('Opaque pagination cursor from a previous list_bookings response'), + limit: z.number().optional().describe('Number of bookings to return, default 50'), + take: z + .number() + .optional() + .describe('Deprecated alias for limit; prefer limit for cursor pagination') }) ) .output( z.object({ - bookings: z.array(z.any()).describe('List of bookings matching the filter criteria') + bookings: z.array(z.any()).describe('List of bookings matching the filter criteria'), + pagination: z + .any() + .optional() + .describe('Cursor pagination metadata including nextCursor and hasMore') }) ) .handleInvocation(async ctx => { @@ -51,17 +90,28 @@ export let listBookings = SlateTool.create(spec, { if (ctx.input.attendeeName) params.attendeeName = ctx.input.attendeeName; if (ctx.input.eventTypeId) params.eventTypeId = ctx.input.eventTypeId; if (ctx.input.eventTypeIds) params.eventTypeIds = ctx.input.eventTypeIds; + if (ctx.input.bookingUid) params.bookingUid = ctx.input.bookingUid; + if (ctx.input.teamId) params.teamId = ctx.input.teamId; + if (ctx.input.teamIds) params.teamIds = ctx.input.teamIds; if (ctx.input.afterStart) params.afterStart = ctx.input.afterStart; if (ctx.input.beforeEnd) params.beforeEnd = ctx.input.beforeEnd; + if (ctx.input.afterCreatedAt) params.afterCreatedAt = ctx.input.afterCreatedAt; + if (ctx.input.beforeCreatedAt) params.beforeCreatedAt = ctx.input.beforeCreatedAt; + if (ctx.input.afterUpdatedAt) params.afterUpdatedAt = ctx.input.afterUpdatedAt; + if (ctx.input.beforeUpdatedAt) params.beforeUpdatedAt = ctx.input.beforeUpdatedAt; if (ctx.input.sortStart) params.sortStart = ctx.input.sortStart; - if (ctx.input.take) params.take = ctx.input.take; - if (ctx.input.skip) params.skip = ctx.input.skip; + if (ctx.input.sortEnd) params.sortEnd = ctx.input.sortEnd; + if (ctx.input.sortCreated) params.sortCreated = ctx.input.sortCreated; + if (ctx.input.sortUpdatedAt) params.sortUpdatedAt = ctx.input.sortUpdatedAt; + if (ctx.input.cursor) params.cursor = ctx.input.cursor; + if (ctx.input.limit) params.limit = ctx.input.limit; + if (!ctx.input.limit && ctx.input.take) params.limit = ctx.input.take; - let bookings = await client.listBookings(params); + let { bookings, pagination } = await client.listBookings(params); let count = Array.isArray(bookings) ? bookings.length : 0; return { - output: { bookings: Array.isArray(bookings) ? bookings : [] }, + output: { bookings: Array.isArray(bookings) ? bookings : [], pagination }, message: `Found **${count}** booking(s).` }; }) diff --git a/integrations/cal-com/src/tools/manage-booking.ts b/integrations/cal-com/src/tools/manage-booking.ts index 98e6e8c431..33831a893c 100644 --- a/integrations/cal-com/src/tools/manage-booking.ts +++ b/integrations/cal-com/src/tools/manage-booking.ts @@ -1,12 +1,13 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { calComServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageBooking = SlateTool.create(spec, { name: 'Manage Booking', key: 'manage_booking', - description: `Perform lifecycle actions on an existing booking: cancel, reschedule, confirm, decline, mark as no-show, reassign to another host, or add guests. Select the desired action and provide the required parameters.`, + description: `Perform lifecycle actions on an existing booking: cancel, reschedule, request a reschedule, confirm, decline, mark absence, reassign to another host, add guests, or update the booking location. Select the desired action and provide the required parameters.`, instructions: [ 'For rescheduling, provide a new start time in ISO 8601 UTC format.', 'For reassigning, optionally specify a target userId; otherwise the system auto-assigns.', @@ -20,22 +21,40 @@ export let manageBooking = SlateTool.create(spec, { .enum([ 'cancel', 'reschedule', + 'request_reschedule', 'confirm', 'decline', 'mark_no_show', 'reassign', - 'add_guests' + 'add_guests', + 'update_location' ]) .describe('Action to perform on the booking'), cancellationReason: z .string() .optional() .describe('Reason for cancellation (used with cancel action)'), + cancelSubsequentBookings: z + .boolean() + .optional() + .describe('Cancel subsequent recurring bookings when canceling a recurring booking'), rescheduleStart: z .string() .optional() .describe('New start time for rescheduling (ISO 8601 UTC)'), rescheduleReason: z.string().optional().describe('Reason for rescheduling'), + rescheduledBy: z + .string() + .optional() + .describe('Name or email of the person who rescheduled the booking'), + emailVerificationCode: z + .string() + .optional() + .describe('Email verification code required by some rescheduling flows'), + rescheduleRequestReason: z + .string() + .optional() + .describe('Reason to send when requesting that a booking be rescheduled'), declineReason: z.string().optional().describe('Reason for declining the booking'), noShowHost: z.boolean().optional().describe('Whether to mark the host as no-show'), noShowAttendees: z @@ -50,11 +69,13 @@ export let manageBooking = SlateTool.create(spec, { .array( z.object({ email: z.string().describe('Guest email'), - name: z.string().optional().describe('Guest name') + name: z.string().optional().describe('Guest name'), + timeZone: z.string().optional().describe('Guest time zone') }) ) .optional() .describe('Guests to add to the booking'), + location: z.any().optional().describe('Cal.com location object for update_location'), seatUid: z.string().optional().describe('Seat UID for seated event operations') }) ) @@ -77,18 +98,39 @@ export let manageBooking = SlateTool.create(spec, { let data: Record = {}; if (ctx.input.cancellationReason) data.cancellationReason = ctx.input.cancellationReason; + if (ctx.input.cancelSubsequentBookings !== undefined) + data.cancelSubsequentBookings = ctx.input.cancelSubsequentBookings; if (ctx.input.seatUid) data.seatUid = ctx.input.seatUid; result = await client.cancelBooking(ctx.input.bookingUid, data); break; } case 'reschedule': { + if (!ctx.input.rescheduleStart) { + throw calComServiceError('rescheduleStart is required for reschedule.'); + } + let data: Record = {}; - if (ctx.input.rescheduleStart) data.start = ctx.input.rescheduleStart; + data.start = ctx.input.rescheduleStart; if (ctx.input.rescheduleReason) data.reschedulingReason = ctx.input.rescheduleReason; + if (ctx.input.rescheduledBy) data.rescheduledBy = ctx.input.rescheduledBy; + if (ctx.input.emailVerificationCode) + data.emailVerificationCode = ctx.input.emailVerificationCode; if (ctx.input.seatUid) data.seatUid = ctx.input.seatUid; result = await client.rescheduleBooking(ctx.input.bookingUid, data); break; } + case 'request_reschedule': { + if (!ctx.input.rescheduleRequestReason) { + throw calComServiceError( + 'rescheduleRequestReason is required for request_reschedule.' + ); + } + + result = await client.requestRescheduleBooking(ctx.input.bookingUid, { + rescheduleReason: ctx.input.rescheduleRequestReason + }); + break; + } case 'confirm': { result = await client.confirmBooking(ctx.input.bookingUid); break; @@ -100,10 +142,19 @@ export let manageBooking = SlateTool.create(spec, { break; } case 'mark_no_show': { + if ( + ctx.input.noShowHost === undefined && + (!ctx.input.noShowAttendees || ctx.input.noShowAttendees.length === 0) + ) { + throw calComServiceError( + 'noShowHost or at least one noShowAttendees email is required for mark_no_show.' + ); + } + let data: Record = {}; - if (ctx.input.noShowHost !== undefined) data.noShowHost = ctx.input.noShowHost; + if (ctx.input.noShowHost !== undefined) data.host = ctx.input.noShowHost; if (ctx.input.noShowAttendees) - data.attendees = ctx.input.noShowAttendees.map(email => ({ email, noShow: true })); + data.attendees = ctx.input.noShowAttendees.map(email => ({ email, absent: true })); result = await client.markNoShow(ctx.input.bookingUid, data); break; } @@ -115,7 +166,19 @@ export let manageBooking = SlateTool.create(spec, { break; } case 'add_guests': { - result = await client.addGuests(ctx.input.bookingUid, ctx.input.guests || []); + if (!ctx.input.guests || ctx.input.guests.length === 0) { + throw calComServiceError('At least one guest is required for add_guests.'); + } + + result = await client.addGuests(ctx.input.bookingUid, ctx.input.guests); + break; + } + case 'update_location': { + if (!ctx.input.location) { + throw calComServiceError('location is required for update_location.'); + } + + result = await client.updateBookingLocation(ctx.input.bookingUid, ctx.input.location); break; } } diff --git a/integrations/cal-com/src/tools/manage-out-of-office.ts b/integrations/cal-com/src/tools/manage-out-of-office.ts new file mode 100644 index 0000000000..71cfb43bf9 --- /dev/null +++ b/integrations/cal-com/src/tools/manage-out-of-office.ts @@ -0,0 +1,126 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { calComServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let reasonSchema = z.enum(['unspecified', 'vacation', 'travel', 'sick', 'public_holiday']); + +export let manageOutOfOffice = SlateTool.create(spec, { + name: 'Manage Out Of Office', + key: 'manage_out_of_office', + description: `List, create, update, or delete out-of-office entries for the authenticated Cal.com user.`, + instructions: [ + 'Use start and end ISO 8601 timestamps for create.', + 'Use oooId for update and delete.', + 'reason should be one of unspecified, vacation, travel, sick, or public_holiday.' + ] +}) + .input( + z.object({ + action: z + .enum(['list', 'create', 'update', 'delete']) + .describe('Out-of-office action to perform'), + oooId: z.number().optional().describe('Out-of-office entry ID for update/delete'), + start: z.string().optional().describe('Start time in ISO 8601 format'), + end: z.string().optional().describe('End time in ISO 8601 format'), + notes: z.string().optional().describe('Optional notes for the out-of-office entry'), + toUserId: z + .number() + .optional() + .describe('Optional user ID to route bookings to during the out-of-office period'), + reason: reasonSchema.optional().describe('Reason for the out-of-office entry'), + take: z.number().optional().describe('Maximum entries to return for list'), + skip: z.number().optional().describe('Entries to skip for list'), + sortStart: z.enum(['asc', 'desc']).optional().describe('Sort list by start time'), + sortEnd: z.enum(['asc', 'desc']).optional().describe('Sort list by end time') + }) + ) + .output( + z.object({ + entries: z.array(z.any()).optional().describe('Out-of-office entries for list'), + entry: z.any().optional().describe('Out-of-office entry for create/update'), + result: z.any().optional().describe('Raw result for delete or non-array list responses') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + switch (ctx.input.action) { + case 'list': { + let params: Record = {}; + if (ctx.input.take !== undefined) params.take = ctx.input.take; + if (ctx.input.skip !== undefined) params.skip = ctx.input.skip; + if (ctx.input.sortStart) params.sortStart = ctx.input.sortStart; + if (ctx.input.sortEnd) params.sortEnd = ctx.input.sortEnd; + + let result = await client.listOutOfOffice(params); + let entries = Array.isArray(result) ? result : []; + return { + output: { entries, result }, + message: `Found **${entries.length}** out-of-office entr${ + entries.length === 1 ? 'y' : 'ies' + }.` + }; + } + case 'create': { + if (!ctx.input.start || !ctx.input.end) { + throw calComServiceError('start and end are required for create.'); + } + + let body: Record = { + start: ctx.input.start, + end: ctx.input.end + }; + if (ctx.input.notes) body.notes = ctx.input.notes; + if (ctx.input.toUserId !== undefined) body.toUserId = ctx.input.toUserId; + if (ctx.input.reason) body.reason = ctx.input.reason; + + let entry = await client.createOutOfOffice(body); + return { + output: { entry }, + message: 'Out-of-office entry created.' + }; + } + case 'update': { + if (ctx.input.oooId === undefined) { + throw calComServiceError('oooId is required for update.'); + } + + let body: Record = {}; + if (ctx.input.start) body.start = ctx.input.start; + if (ctx.input.end) body.end = ctx.input.end; + if (ctx.input.notes) body.notes = ctx.input.notes; + if (ctx.input.toUserId !== undefined) body.toUserId = ctx.input.toUserId; + if (ctx.input.reason) body.reason = ctx.input.reason; + if (Object.keys(body).length === 0) { + throw calComServiceError( + 'start, end, notes, toUserId, or reason is required for update.' + ); + } + + let entry = await client.updateOutOfOffice(ctx.input.oooId, body); + return { + output: { entry }, + message: `Out-of-office entry **${ctx.input.oooId}** updated.` + }; + } + case 'delete': { + if (ctx.input.oooId === undefined) { + throw calComServiceError('oooId is required for delete.'); + } + + let result = await client.deleteOutOfOffice(ctx.input.oooId); + return { + output: { result }, + message: `Out-of-office entry **${ctx.input.oooId}** deleted.` + }; + } + } + + throw calComServiceError(`Unsupported out-of-office action: ${ctx.input.action}`); + }) + .build(); diff --git a/integrations/cal-com/src/tools/manage-schedule.ts b/integrations/cal-com/src/tools/manage-schedule.ts index afb81a8e3e..402a16ed08 100644 --- a/integrations/cal-com/src/tools/manage-schedule.ts +++ b/integrations/cal-com/src/tools/manage-schedule.ts @@ -1,10 +1,13 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { calComServiceError } from '../lib/errors'; import { spec } from '../spec'; let availabilitySchema = z.object({ - days: z.array(z.number()).describe('Days of the week (0=Sunday, 1=Monday, ... 6=Saturday)'), + days: z + .array(z.string()) + .describe('Weekday names for this availability window, such as monday or friday'), startTime: z.string().describe('Start time in 24h format (e.g., "09:00")'), endTime: z.string().describe('End time in 24h format (e.g., "17:00")') }); @@ -18,20 +21,22 @@ let overrideSchema = z.object({ export let manageSchedule = SlateTool.create(spec, { name: 'Manage Schedule', key: 'manage_schedule', - description: `Create, update, or delete an availability schedule. Schedules define when a user can be booked. Each user can have multiple schedules with one set as default. Use action "create" to make a new schedule, "update" to modify an existing one, "delete" to remove one, or "list" to view all schedules.`, + description: `Create, update, delete, retrieve, or list availability schedules. Schedules define when a user can be booked. Each user can have multiple schedules with one set as default.`, instructions: [ 'Availability times use 24-hour format (e.g., "08:00", "17:00").', - 'Days of the week: 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday.', + 'Availability days use weekday strings such as monday, tuesday, wednesday.', 'Default availability is Mon-Fri 09:00-17:00 if not specified.' ] }) .input( z.object({ - action: z.enum(['list', 'create', 'update', 'delete']).describe('Action to perform'), + action: z + .enum(['list', 'get', 'get_default', 'create', 'update', 'delete']) + .describe('Action to perform'), scheduleId: z .number() .optional() - .describe('Schedule ID (required for update and delete)'), + .describe('Schedule ID (required for get, update, and delete)'), name: z.string().optional().describe('Name of the schedule'), timeZone: z .string() @@ -67,7 +72,29 @@ export let manageSchedule = SlateTool.create(spec, { message: `Found **${list.length}** schedule(s).` }; } + case 'get': { + if (!ctx.input.scheduleId) { + throw calComServiceError('scheduleId is required for get.'); + } + + let schedule = await client.getSchedule(ctx.input.scheduleId); + return { + output: { schedule }, + message: `Schedule **${ctx.input.scheduleId}** retrieved.` + }; + } + case 'get_default': { + let schedule = await client.getDefaultSchedule(); + return { + output: { schedule }, + message: 'Default schedule retrieved.' + }; + } case 'create': { + if (!ctx.input.name) { + throw calComServiceError('name is required for create.'); + } + let body: Record = {}; if (ctx.input.name) body.name = ctx.input.name; if (ctx.input.timeZone) body.timeZone = ctx.input.timeZone; @@ -81,7 +108,10 @@ export let manageSchedule = SlateTool.create(spec, { }; } case 'update': { - if (!ctx.input.scheduleId) throw new Error('scheduleId is required for update'); + if (!ctx.input.scheduleId) { + throw calComServiceError('scheduleId is required for update.'); + } + let body: Record = {}; if (ctx.input.name) body.name = ctx.input.name; if (ctx.input.timeZone) body.timeZone = ctx.input.timeZone; @@ -95,7 +125,10 @@ export let manageSchedule = SlateTool.create(spec, { }; } case 'delete': { - if (!ctx.input.scheduleId) throw new Error('scheduleId is required for delete'); + if (!ctx.input.scheduleId) { + throw calComServiceError('scheduleId is required for delete.'); + } + let result = await client.deleteSchedule(ctx.input.scheduleId); return { output: { result }, @@ -104,6 +137,6 @@ export let manageSchedule = SlateTool.create(spec, { } } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw calComServiceError(`Unsupported schedule action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/cal-com/src/tools/manage-slot-reservation.ts b/integrations/cal-com/src/tools/manage-slot-reservation.ts new file mode 100644 index 0000000000..f55ce74222 --- /dev/null +++ b/integrations/cal-com/src/tools/manage-slot-reservation.ts @@ -0,0 +1,121 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { calComServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let manageSlotReservation = SlateTool.create(spec, { + name: 'Manage Slot Reservation', + key: 'manage_slot_reservation', + description: `Reserve, retrieve, update, or delete a temporary slot reservation for a Cal.com event type. Use this before creating a booking when a slot must be held briefly.`, + instructions: [ + 'Use action "reserve" with eventTypeId and slotStart.', + 'Use reservationUid for get, update, and delete actions.', + 'slotDuration is only needed for event types with variable lengths.' + ] +}) + .input( + z.object({ + action: z + .enum(['reserve', 'get', 'update', 'delete']) + .describe('Reservation action to perform'), + reservationUid: z + .string() + .optional() + .describe('Reservation UID required for get, update, and delete'), + eventTypeId: z.number().optional().describe('Event type ID required for reserve'), + slotStart: z + .string() + .optional() + .describe('Slot start time in ISO 8601 UTC format required for reserve/update'), + slotDuration: z + .number() + .optional() + .describe('Slot duration in minutes for variable-length event types'), + reservationDuration: z + .number() + .optional() + .describe('How many minutes the slot should remain reserved') + }) + ) + .output( + z.object({ + reservation: z.any().optional().describe('Slot reservation details'), + result: z.any().optional().describe('Result for delete operations') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + switch (ctx.input.action) { + case 'reserve': { + if (ctx.input.eventTypeId === undefined || !ctx.input.slotStart) { + throw calComServiceError('eventTypeId and slotStart are required for reserve.'); + } + + let body: Record = { + eventTypeId: ctx.input.eventTypeId, + slotStart: ctx.input.slotStart + }; + if (ctx.input.slotDuration !== undefined) body.slotDuration = ctx.input.slotDuration; + if (ctx.input.reservationDuration !== undefined) + body.reservationDuration = ctx.input.reservationDuration; + + let reservation = await client.reserveSlot(body); + return { + output: { reservation }, + message: `Reserved slot starting **${ctx.input.slotStart}**.` + }; + } + case 'get': { + if (!ctx.input.reservationUid) { + throw calComServiceError('reservationUid is required for get.'); + } + + let reservation = await client.getReservedSlot(ctx.input.reservationUid); + return { + output: { reservation }, + message: `Slot reservation **${ctx.input.reservationUid}** retrieved.` + }; + } + case 'update': { + if (!ctx.input.reservationUid) { + throw calComServiceError('reservationUid is required for update.'); + } + + let body: Record = {}; + if (ctx.input.slotStart) body.slotStart = ctx.input.slotStart; + if (ctx.input.slotDuration !== undefined) body.slotDuration = ctx.input.slotDuration; + if (ctx.input.reservationDuration !== undefined) + body.reservationDuration = ctx.input.reservationDuration; + if (Object.keys(body).length === 0) { + throw calComServiceError( + 'slotStart, slotDuration, or reservationDuration is required for update.' + ); + } + + let reservation = await client.updateReservedSlot(ctx.input.reservationUid, body); + return { + output: { reservation }, + message: `Slot reservation **${ctx.input.reservationUid}** updated.` + }; + } + case 'delete': { + if (!ctx.input.reservationUid) { + throw calComServiceError('reservationUid is required for delete.'); + } + + let result = await client.deleteReservedSlot(ctx.input.reservationUid); + return { + output: { result }, + message: `Slot reservation **${ctx.input.reservationUid}** deleted.` + }; + } + } + + throw calComServiceError(`Unsupported slot reservation action: ${ctx.input.action}`); + }) + .build(); diff --git a/integrations/cal-com/vitest.config.ts b/integrations/cal-com/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/cal-com/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/clearbit/README.md b/integrations/clearbit/README.md index 61e43d4325..1838dafe41 100644 --- a/integrations/clearbit/README.md +++ b/integrations/clearbit/README.md @@ -1,6 +1,6 @@ # Clearbit -Enrich person and company data using email addresses and domain names. Look up detailed contact information, firmographic data, technographics, and social profiles. Identify anonymous website visitors by IP address to reveal associated companies. Prospect for leads by searching contacts by role, seniority, title, and location. Discover companies matching specific criteria like size, industry, and technology usage. Convert company names to domains, retrieve company logos, and score signups for fraud risk. Receive webhook notifications for enrichment results and audience segment matches. +Enrich person and company data using email addresses and domain names. Look up detailed contact information, firmographic data, technographics, and social profiles. Identify anonymous website visitors by IP address to reveal associated companies. Prospect for leads by searching contacts by role, seniority, title, and location. Discover companies matching specific criteria like size, industry, and technology usage. Convert company names to domains and score signups for fraud risk. Receive webhook notifications for enrichment results and audience segment matches. ## Tools @@ -32,10 +32,6 @@ Look up detailed information about a person using their email address. Returns d Search for contacts at a company using its domain name. Filter by role, seniority, title, or location to build targeted outreach lists. Returns names, email addresses, titles, and seniority information for matching contacts. -### Get Company Logo - -Get a company's logo URL by its domain. Returns a direct URL to the company's logo image in the specified size and format. The logo is served from Clearbit's CDN. - ### Name to Domain Convert a company name to its website domain. Provide a partial or full company name to retrieve the company's domain, full name, and logo. Useful for resolving company domains when only a name is known. diff --git a/integrations/clearbit/docs/SPEC.md b/integrations/clearbit/docs/SPEC.md index 3f0ed62318..af1712911a 100644 --- a/integrations/clearbit/docs/SPEC.md +++ b/integrations/clearbit/docs/SPEC.md @@ -52,10 +52,6 @@ The Discovery API helps you find companies that meet your unique criteria. You m Convert a company name to its website domain. Provide Clearbit with a partial company name to retrieve more complete company information. This can return the company's full name, domain, and logo. -### Logo - -Find a company's logo via the company's domain. Clearbit will return a logo by searching through various sources, including social media accounts. - ### Risk Scoring Get a risk score for a new signup by checking their email and IP address against a number of factors, including if their name matches their email, if the email address is disposable, and if the IP or email address is blacklisted. Useful for fraud prevention and compliance checks. @@ -64,6 +60,12 @@ Get a risk score for a new signup by checking their email and IP address against Provides company name autocomplete suggestions as users type, useful for building forms and search interfaces. +### Deprecated or Discontinued Capabilities + +The public Clearbit Logo API is not exposed as a tool because Clearbit's current +official logo page states that the Logo API is discontinued and unavailable for +new users. + ## Events Clearbit supports webhooks in two primary ways: diff --git a/integrations/clearbit/package.json b/integrations/clearbit/package.json index 35d7971f26..700c7b02a0 100644 --- a/integrations/clearbit/package.json +++ b/integrations/clearbit/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/clearbit/src/index.ts b/integrations/clearbit/src/index.ts index 9f03e9502c..aba40fe4f1 100644 --- a/integrations/clearbit/src/index.ts +++ b/integrations/clearbit/src/index.ts @@ -8,7 +8,6 @@ import { enrichCompany, enrichPerson, findProspects, - getLogo, nameToDomain, revealCompany } from './tools'; @@ -24,7 +23,6 @@ export let provider = Slate.create({ findProspects, discoverCompanies, nameToDomain, - getLogo, checkRisk, autocompleteCompany ], diff --git a/integrations/clearbit/src/lib/client.ts b/integrations/clearbit/src/lib/client.ts index 6067028888..fa9868914b 100644 --- a/integrations/clearbit/src/lib/client.ts +++ b/integrations/clearbit/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { clearbitApiError } from './errors'; import type { ClearbitAutocompleteItem, ClearbitCombined, @@ -28,6 +29,14 @@ export class ClearbitClient { }); } + private async request(operation: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw clearbitApiError(error, operation); + } + } + // ─── Person Enrichment ───────────────────────────────────────── async findPerson(params: { @@ -40,8 +49,10 @@ export class ClearbitClient { if (params.webhookUrl) query.webhook_url = params.webhookUrl; if (params.subscribe !== undefined) query.subscribe = params.subscribe; - let response = await client.get('/v2/people/find', { params: query }); - return response.data as ClearbitPerson; + return this.request('find person', async () => { + let response = await client.get('/v2/people/find', { params: query }); + return response.data as ClearbitPerson; + }); } // ─── Company Enrichment ──────────────────────────────────────── @@ -54,8 +65,10 @@ export class ClearbitClient { let query: Record = { domain: params.domain }; if (params.webhookUrl) query.webhook_url = params.webhookUrl; - let response = await client.get('/v2/companies/find', { params: query }); - return response.data as ClearbitCompany; + return this.request('find company', async () => { + let response = await client.get('/v2/companies/find', { params: query }); + return response.data as ClearbitCompany; + }); } // ─── Combined Enrichment ─────────────────────────────────────── @@ -68,18 +81,22 @@ export class ClearbitClient { let query: Record = { email: params.email }; if (params.webhookUrl) query.webhook_url = params.webhookUrl; - let response = await client.get('/v2/combined/find', { params: query }); - return response.data as ClearbitCombined; + return this.request('find combined person and company', async () => { + let response = await client.get('/v2/combined/find', { params: query }); + return response.data as ClearbitCombined; + }); } // ─── Reveal (IP Intelligence) ────────────────────────────────── async reveal(params: { ip: string }): Promise { let client = this.createClient('https://reveal.clearbit.com'); - let response = await client.get('/v1/companies/reveal', { - params: { ip: params.ip } + return this.request('reveal company by IP', async () => { + let response = await client.get('/v1/companies/reveal', { + params: { ip: params.ip } + }); + return response.data as ClearbitReveal; }); - return response.data as ClearbitReveal; } // ─── Prospector ──────────────────────────────────────────────── @@ -123,8 +140,10 @@ export class ClearbitClient { if (params.pageSize !== undefined) query.page_size = params.pageSize; if (params.suppression) query.suppression = params.suppression; - let response = await client.get('/v1/people/search', { params: query }); - return response.data as ClearbitProspectorResponse; + return this.request('search prospects', async () => { + let response = await client.get('/v1/people/search', { params: query }); + return response.data as ClearbitProspectorResponse; + }); } // ─── Discovery ───────────────────────────────────────────────── @@ -141,28 +160,34 @@ export class ClearbitClient { if (params.pageSize !== undefined) query.page_size = params.pageSize; if (params.sort) query.sort = params.sort; - let response = await client.get('/v1/companies/search', { params: query }); - return response.data as ClearbitDiscoveryResponse; + return this.request('discover companies', async () => { + let response = await client.get('/v1/companies/search', { params: query }); + return response.data as ClearbitDiscoveryResponse; + }); } // ─── Name to Domain ──────────────────────────────────────────── async nameToDomain(params: { name: string }): Promise { let client = this.createClient('https://company.clearbit.com'); - let response = await client.get('/v1/domains/find', { - params: { name: params.name } + return this.request('resolve company name to domain', async () => { + let response = await client.get('/v1/domains/find', { + params: { name: params.name } + }); + return response.data as ClearbitNameToDomain; }); - return response.data as ClearbitNameToDomain; } // ─── Autocomplete ────────────────────────────────────────────── async autocomplete(params: { query: string }): Promise { let client = this.createClient('https://autocomplete.clearbit.com'); - let response = await client.get('/v1/companies/suggest', { - params: { query: params.query } + return this.request('autocomplete companies', async () => { + let response = await client.get('/v1/companies/suggest', { + params: { query: params.query } + }); + return response.data as ClearbitAutocompleteItem[]; }); - return response.data as ClearbitAutocompleteItem[]; } // ─── Risk ────────────────────────────────────────────────────── @@ -187,7 +212,9 @@ export class ClearbitClient { if (params.countryCode) body.country_code = params.countryCode; if (params.zipCode) body.zip_code = params.zipCode; - let response = await client.post('/v1/calculate', body); - return response.data as ClearbitRisk; + return this.request('calculate risk', async () => { + let response = await client.post('/v1/calculate', body); + return response.data as ClearbitRisk; + }); } } diff --git a/integrations/clearbit/src/lib/errors.ts b/integrations/clearbit/src/lib/errors.ts new file mode 100644 index 0000000000..0b8b91e41d --- /dev/null +++ b/integrations/clearbit/src/lib/errors.ts @@ -0,0 +1,97 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.error); + pushDetail(details, value.error_description); + pushDetail(details, value.title); + pushDetail(details, value.detail); + pushDetail(details, value.type); + collectDetails(value.errors, details); +}; + +let extractClearbitMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + let code = response.data.code ?? response.data.error ?? response.data.type; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +}; + +export let clearbitServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let clearbitApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = clearbitServiceError( + `Clearbit API ${operation} failed: ${statusLabelFor(response)}${extractClearbitMessage(error)}` + ); + serviceError.data.reason = 'clearbit_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/clearbit/src/tools/get-logo.ts b/integrations/clearbit/src/tools/get-logo.ts deleted file mode 100644 index 83a60d9fff..0000000000 --- a/integrations/clearbit/src/tools/get-logo.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { SlateTool } from 'slates'; -import { z } from 'zod'; -import { spec } from '../spec'; - -export let getLogo = SlateTool.create(spec, { - name: 'Get Company Logo', - key: 'get_logo', - description: `Get a company's logo URL by its domain. Returns a direct URL to the company's logo image in the specified size and format. The logo is served from Clearbit's CDN.`, - tags: { - readOnly: true - } -}) - .input( - z.object({ - domain: z.string().describe('Company domain (e.g., "clearbit.com")'), - size: z.number().optional().describe('Image dimensions in pixels (default: 128)'), - format: z.enum(['png', 'jpg']).optional().describe('Image format (default: "png")'), - greyscale: z.boolean().optional().describe('Return greyscale image (default: false)') - }) - ) - .output( - z.object({ - logoUrl: z.string().describe('Direct URL to the company logo image'), - domain: z.string().describe('The domain used for lookup') - }) - ) - .handleInvocation(async ctx => { - let params: string[] = []; - if (ctx.input.size !== undefined) params.push(`size=${ctx.input.size}`); - if (ctx.input.format) params.push(`format=${ctx.input.format}`); - if (ctx.input.greyscale !== undefined) params.push(`greyscale=${ctx.input.greyscale}`); - - let queryString = params.length > 0 ? `?${params.join('&')}` : ''; - let logoUrl = `https://logo.clearbit.com/${ctx.input.domain}${queryString}`; - - return { - output: { - logoUrl, - domain: ctx.input.domain - }, - message: `Logo URL for \`${ctx.input.domain}\`: ${logoUrl}` - }; - }) - .build(); diff --git a/integrations/clearbit/src/tools/index.ts b/integrations/clearbit/src/tools/index.ts index 3381d1b768..711ba869d2 100644 --- a/integrations/clearbit/src/tools/index.ts +++ b/integrations/clearbit/src/tools/index.ts @@ -5,6 +5,5 @@ export * from './enrich-combined'; export * from './enrich-company'; export * from './enrich-person'; export * from './find-prospects'; -export * from './get-logo'; export * from './name-to-domain'; export * from './reveal-company'; diff --git a/integrations/clickup/README.md b/integrations/clickup/README.md index 83f49643c1..cd4a87cccc 100644 --- a/integrations/clickup/README.md +++ b/integrations/clickup/README.md @@ -1,6 +1,6 @@ # Clickup -Create, update, delete, and search tasks across a hierarchical workspace structure of Spaces, Folders, and Lists. Manage task details including assignees, statuses, priorities, due dates, tags, dependencies, and custom fields. Track time on tasks with timers and time entries. Add comments and attachments to tasks. Create and manage Docs, Views (List, Board, Calendar, Gantt), Goals, and Targets for progress tracking. Organize workspaces by creating and managing Spaces, Folders, and Lists. Retrieve workspace members and team information. Subscribe to webhooks for real-time notifications on task, list, folder, space, and goal events. +Create, update, delete, and search tasks across a hierarchical workspace structure of Spaces, Folders, and Lists. Manage task details including assignees, statuses, priorities, due dates, tags, comments, checklists, and custom fields. Track time on tasks with timers and time entries. Create and manage Goals for progress tracking. Organize workspaces by creating and managing Spaces, Folders, Lists, and Space tags. Retrieve workspace members and team information. ## Tools @@ -24,30 +24,134 @@ Retrieve all ClickUp workspaces (teams) accessible to the authenticated user. Us Retrieve all comments on a ClickUp task. Returns the comment text, author, date, and resolution status. +### Create Task Comment + +Create a comment on a ClickUp task. + +### Update Task Comment + +Update a ClickUp task comment's text, assignee, or resolved state. + +### Delete Task Comment + +Delete a ClickUp task comment. + +### Create Checklist + +Create a checklist on a ClickUp task. + +### Update Checklist + +Rename a ClickUp task checklist or change its position on the task. + +### Delete Checklist + +Delete a ClickUp task checklist. + +### Create Checklist Item + +Create an item inside a ClickUp task checklist. + +### Update Checklist Item + +Update a ClickUp checklist item name, assignee, resolved state, or parent item. + +### Delete Checklist Item + +Delete an item from a ClickUp task checklist. + ### Get Custom Fields Retrieve all custom fields accessible on a ClickUp list. Returns field definitions including their IDs, names, types, and options. +### Set Custom Field Value + +Set a custom field value on a ClickUp task. + +### Remove Custom Field Value + +Clear a custom field value from a ClickUp task. + ### Get Folders Retrieve all folders in a ClickUp space. +### Create Folder + +Create a folder in a ClickUp space. + +### Update Folder + +Rename a ClickUp folder. + +### Delete Folder + +Delete a ClickUp folder. + ### Get Goals Retrieve all goals from the workspace. Optionally include completed goals. +### Create Goal + +Create a ClickUp goal in the configured workspace. + +### Update Goal + +Update a ClickUp goal. + +### Delete Goal + +Delete a ClickUp goal. + ### Get Lists Retrieve ClickUp lists from a folder or space. When a **folderId** is provided, returns lists in that folder. When a **spaceId** is provided, returns folderless lists in the space. +### Create List + +Create a ClickUp list in a folder or directly in a space. + +### Update List + +Update a ClickUp list. + +### Delete List + +Delete a ClickUp list. + ### Get Spaces Retrieve all spaces in the configured ClickUp workspace, including their names, IDs, and statuses. +### Create Space + +Create a ClickUp space in the configured workspace. + +### Update Space + +Update a ClickUp space. + +### Delete Space + +Delete a ClickUp space. + ### Get Space Tags Retrieve all tags defined in a ClickUp space. +### Create Space Tag + +Create a tag in a ClickUp space. + +### Update Space Tag + +Rename or recolor a ClickUp space tag. + +### Delete Space Tag + +Delete a tag from a ClickUp space. + ### Search Tasks Search and filter tasks across the entire ClickUp workspace. Filter by status, assignee, tags, due dates, creation dates, and more. Returns paginated results. Use the **listId** parameter to scope to a specific list, or omit it to search across the workspace. @@ -56,10 +160,38 @@ Search and filter tasks across the entire ClickUp workspace. Filter by status, a Retrieve time tracking entries from the workspace. Filter by date range, assignee, or specific task/list/space. Requires the Time Tracking ClickApp to be enabled. +### Create Time Entry + +Log a completed time entry in ClickUp. + +### Update Time Entry + +Update a ClickUp time entry's task, description, start/end time, duration, assignee, tags, or billable flag. + +### Delete Time Entry + +Delete a ClickUp time entry from the configured workspace. + +### Get Running Timer + +Retrieve the currently running ClickUp timer. + +### Start Timer + +Start a running timer in ClickUp. + +### Stop Timer + +Stop the currently running ClickUp timer. + ### Update Task Update an existing ClickUp task. Modify its name, description, status, priority, assignees, dates, time estimate, and more. Also supports adding/removing tags and setting custom field values in a single call. +### Get Workspace Members + +Retrieve members for the configured workspace. + ## License This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE). diff --git a/integrations/clickup/package.json b/integrations/clickup/package.json index 0700924dec..88dd573cfd 100644 --- a/integrations/clickup/package.json +++ b/integrations/clickup/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/clickup/src/auth.ts b/integrations/clickup/src/auth.ts index b82b27959b..59fa771301 100644 --- a/integrations/clickup/src/auth.ts +++ b/integrations/clickup/src/auth.ts @@ -1,10 +1,16 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { clickupApiError } from './lib/errors'; let axios = createAxios({ baseURL: 'https://api.clickup.com/api/v2' }); +axios.interceptors.response.use( + response => response, + error => Promise.reject(clickupApiError(error)) +); + export let auth = SlateAuth.create() .output( z.object({ diff --git a/integrations/clickup/src/index.ts b/integrations/clickup/src/index.ts index 4fc829901e..1a42e49b79 100644 --- a/integrations/clickup/src/index.ts +++ b/integrations/clickup/src/index.ts @@ -1,6 +1,8 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + createChecklist, + createChecklistItem, createComment, createFolder, createGoal, @@ -9,15 +11,21 @@ import { createSpaceTag, createTask, createTimeEntry, + deleteChecklist, + deleteChecklistItem, + deleteComment, deleteFolder, deleteGoal, deleteList, deleteSpace, + deleteSpaceTag, deleteTask, + deleteTimeEntry, getCustomFields, getFolders, getGoals, getLists, + getRunningTimer, getSpaces, getSpaceTags, getTask, @@ -25,15 +33,21 @@ import { getTimeEntries, getWorkspaceMembers, getWorkspaces, + removeCustomFieldValue, searchTasks, setCustomFieldValue, startTimer, stopTimer, + updateChecklist, + updateChecklistItem, + updateComment, updateFolder, updateGoal, updateList, updateSpace, - updateTask + updateSpaceTag, + updateTask, + updateTimeEntry } from './tools'; import { taskEvents, workspaceEvents } from './triggers'; @@ -47,6 +61,8 @@ export let provider = Slate.create({ deleteTask, getTaskComments, createComment, + updateComment, + deleteComment, getLists, createList, updateList, @@ -65,10 +81,22 @@ export let provider = Slate.create({ deleteGoal, getSpaceTags, createSpaceTag, + updateSpaceTag, + deleteSpaceTag, getCustomFields, setCustomFieldValue, + removeCustomFieldValue, + createChecklist, + updateChecklist, + deleteChecklist, + createChecklistItem, + updateChecklistItem, + deleteChecklistItem, getTimeEntries, createTimeEntry, + updateTimeEntry, + deleteTimeEntry, + getRunningTimer, startTimer, stopTimer, getWorkspaces, diff --git a/integrations/clickup/src/lib/client.ts b/integrations/clickup/src/lib/client.ts index c1b28af409..e7b819ff66 100644 --- a/integrations/clickup/src/lib/client.ts +++ b/integrations/clickup/src/lib/client.ts @@ -1,4 +1,7 @@ import { createAxios } from 'slates'; +import { clickupApiError } from './errors'; + +let encodePathSegment = (value: string) => encodeURIComponent(value); export class ClickUpClient { private axios: ReturnType; @@ -11,6 +14,11 @@ export class ClickUpClient { 'Content-Type': 'application/json' } }); + + this.axios.interceptors.response.use( + response => response, + error => Promise.reject(clickupApiError(error)) + ); } // ── Workspaces (Teams) ── @@ -405,17 +413,45 @@ export class ClickUpClient { spaceId: string, tag: { name: string; tagFg?: string; tagBg?: string } ) { - let response = await this.axios.post(`/space/${spaceId}/tag`, { tag }); + let response = await this.axios.post(`/space/${spaceId}/tag`, { + tag: { + name: tag.name, + tag_fg: tag.tagFg, + tag_bg: tag.tagBg + } + }); + return response.data as any; + } + + async updateSpaceTag( + spaceId: string, + tagName: string, + tag: { name?: string; tagFg?: string; tagBg?: string } + ) { + let response = await this.axios.put( + `/space/${spaceId}/tag/${encodePathSegment(tagName)}`, + { + tag: { + name: tag.name, + tag_fg: tag.tagFg, + tag_bg: tag.tagBg + } + } + ); return response.data as any; } + async deleteSpaceTag(spaceId: string, tagName: string) { + await this.axios.delete(`/space/${spaceId}/tag/${encodePathSegment(tagName)}`); + } + async addTagToTask(taskId: string, tagName: string) { - let response = await this.axios.post(`/task/${taskId}/tag/${tagName}`); + let response = await this.axios.post(`/task/${taskId}/tag/${encodePathSegment(tagName)}`); return response.data as any; } async removeTagFromTask(taskId: string, tagName: string) { - await this.axios.delete(`/task/${taskId}/tag/${tagName}`); + await this.axios.delete(`/task/${taskId}/tag/${encodePathSegment(tagName)}`); } // ── Time Tracking ── @@ -476,6 +512,33 @@ export class ClickUpClient { return response.data as any; } + async updateTimeEntry( + teamId: string, + timerId: string, + data: { + taskId?: string; + description?: string; + start?: number; + end?: number; + duration?: number; + assignee?: number; + tags?: { name: string }[]; + billable?: boolean; + } + ) { + let response = await this.axios.put(`/team/${teamId}/time_entries/${timerId}`, { + tid: data.taskId, + description: data.description, + start: data.start, + end: data.end, + duration: data.duration, + assignee: data.assignee, + tags: data.tags, + billable: data.billable + }); + return response.data.data as any; + } + async startTimer( teamId: string, data: { taskId?: string; description?: string; billable?: boolean } @@ -699,6 +762,11 @@ export class ClickUpClient { return response.data.checklist as any; } + async updateChecklist(checklistId: string, data: { name?: string; position?: number }) { + let response = await this.axios.put(`/checklist/${checklistId}`, data); + return response.data as any; + } + async createChecklistItem(checklistId: string, name: string, assignee?: number) { let response = await this.axios.post(`/checklist/${checklistId}/checklist_item`, { name, @@ -710,7 +778,12 @@ export class ClickUpClient { async updateChecklistItem( checklistId: string, checklistItemId: string, - data: { name?: string; resolved?: boolean; assignee?: number | null } + data: { + name?: string; + resolved?: boolean; + assignee?: number | null; + parent?: string | null; + } ) { let response = await this.axios.put( `/checklist/${checklistId}/checklist_item/${checklistItemId}`, @@ -719,6 +792,10 @@ export class ClickUpClient { return response.data.checklist as any; } + async deleteChecklistItem(checklistId: string, checklistItemId: string) { + await this.axios.delete(`/checklist/${checklistId}/checklist_item/${checklistItemId}`); + } + async deleteChecklist(checklistId: string) { await this.axios.delete(`/checklist/${checklistId}`); } diff --git a/integrations/clickup/src/lib/errors.ts b/integrations/clickup/src/lib/errors.ts new file mode 100644 index 0000000000..3376b5a39b --- /dev/null +++ b/integrations/clickup/src/lib/errors.ts @@ -0,0 +1,80 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (!isRecord(value)) { + pushMessage(messages, value); + return; + } + + for (let key of ['message', 'error', 'err', 'ECODE']) { + pushMessage(messages, value[key]); + } +}; + +let getErrorResponse = (error: unknown) => + isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + +let extractClickUpMessage = (error: unknown) => { + let messages: string[] = []; + let data = getErrorResponse(error)?.data; + + if (data !== undefined) { + collectMessages(data, messages); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let clickupServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let clickupApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = getErrorResponse(error); + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = clickupServiceError( + `ClickUp API ${operation} failed: ${statusLabel}${extractClickUpMessage(error)}` + ); + serviceError.data.reason = 'clickup_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/clickup/src/tools/index.ts b/integrations/clickup/src/tools/index.ts index 145bc08908..41352ed37c 100644 --- a/integrations/clickup/src/tools/index.ts +++ b/integrations/clickup/src/tools/index.ts @@ -2,6 +2,7 @@ export * from './create-task'; export * from './delete-task'; export * from './get-task'; export * from './get-workspace'; +export * from './manage-checklists'; export * from './manage-comments'; export * from './manage-custom-fields'; export * from './manage-folders'; diff --git a/integrations/clickup/src/tools/manage-checklists.ts b/integrations/clickup/src/tools/manage-checklists.ts new file mode 100644 index 0000000000..a4e338bae2 --- /dev/null +++ b/integrations/clickup/src/tools/manage-checklists.ts @@ -0,0 +1,284 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { ClickUpClient } from '../lib/client'; +import { clickupServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let checklistItemOutput = z.object({ + itemId: z.string(), + itemName: z.string(), + resolved: z.boolean().optional(), + assigneeUserId: z.string().optional(), + parentItemId: z.string().optional(), + dateCreated: z.string().optional() +}); + +let checklistOutput = z.object({ + checklistId: z.string(), + taskId: z.string().optional(), + checklistName: z.string().optional(), + resolvedCount: z.number().optional(), + unresolvedCount: z.number().optional(), + items: z.array(checklistItemOutput).optional() +}); + +let formatChecklistItem = (item: any) => ({ + itemId: String(item.id), + itemName: item.name, + resolved: item.resolved, + assigneeUserId: + item.assignee && typeof item.assignee === 'object' + ? String(item.assignee.id) + : item.assignee + ? String(item.assignee) + : undefined, + parentItemId: item.parent ? String(item.parent) : undefined, + dateCreated: item.date_created +}); + +let formatChecklist = (checklist: any) => ({ + checklistId: String(checklist.id), + taskId: checklist.task_id ? String(checklist.task_id) : undefined, + checklistName: checklist.name, + resolvedCount: checklist.resolved !== undefined ? Number(checklist.resolved) : undefined, + unresolvedCount: + checklist.unresolved !== undefined ? Number(checklist.unresolved) : undefined, + items: Array.isArray(checklist.items) ? checklist.items.map(formatChecklistItem) : [] +}); + +let findChecklistItem = (checklist: any, itemName?: string) => { + let items = Array.isArray(checklist?.items) ? checklist.items : []; + if (itemName) { + let match = items.find((item: any) => item.name === itemName); + if (match) return match; + } + + return items[items.length - 1]; +}; + +export let createChecklist = SlateTool.create(spec, { + name: 'Create Checklist', + key: 'create_checklist', + description: `Create a checklist on a ClickUp task.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + taskId: z.string().describe('The task ID to add the checklist to'), + name: z.string().describe('Checklist name') + }) + ) + .output(checklistOutput) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + let checklist = await client.createChecklist(ctx.input.taskId, ctx.input.name); + + return { + output: formatChecklist(checklist), + message: `Created checklist **${checklist.name}** on task ${ctx.input.taskId}.` + }; + }) + .build(); + +export let updateChecklist = SlateTool.create(spec, { + name: 'Update Checklist', + key: 'update_checklist', + description: `Rename a ClickUp task checklist or change its position on the task.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + checklistId: z.string().describe('The checklist ID to update'), + name: z.string().optional().describe('New checklist name'), + position: z.number().optional().describe('Checklist display position on the task') + }) + ) + .output( + z.object({ + checklistId: z.string(), + updated: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + await client.updateChecklist(ctx.input.checklistId, { + name: ctx.input.name, + position: ctx.input.position + }); + + return { + output: { + checklistId: ctx.input.checklistId, + updated: true + }, + message: `Updated checklist ${ctx.input.checklistId}.` + }; + }) + .build(); + +export let deleteChecklist = SlateTool.create(spec, { + name: 'Delete Checklist', + key: 'delete_checklist', + description: `Delete a checklist from a ClickUp task.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + checklistId: z.string().describe('The checklist ID to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + await client.deleteChecklist(ctx.input.checklistId); + + return { + output: { deleted: true }, + message: `Deleted checklist ${ctx.input.checklistId}.` + }; + }) + .build(); + +export let createChecklistItem = SlateTool.create(spec, { + name: 'Create Checklist Item', + key: 'create_checklist_item', + description: `Create an item inside a ClickUp task checklist.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + checklistId: z.string().describe('The checklist ID to add an item to'), + name: z.string().describe('Checklist item name'), + assignee: z.number().optional().describe('User ID to assign the checklist item to') + }) + ) + .output( + z.object({ + checklist: checklistOutput, + item: checklistItemOutput + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + let checklist = await client.createChecklistItem( + ctx.input.checklistId, + ctx.input.name, + ctx.input.assignee + ); + let item = findChecklistItem(checklist, ctx.input.name); + if (!item?.id) { + throw clickupServiceError('ClickUp did not return the created checklist item.'); + } + + return { + output: { + checklist: formatChecklist(checklist), + item: formatChecklistItem(item) + }, + message: `Created checklist item **${ctx.input.name}** in checklist ${ctx.input.checklistId}.` + }; + }) + .build(); + +export let updateChecklistItem = SlateTool.create(spec, { + name: 'Update Checklist Item', + key: 'update_checklist_item', + description: `Update a ClickUp checklist item name, assignee, resolved state, or parent item.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + checklistId: z.string().describe('The checklist ID that owns the item'), + checklistItemId: z.string().describe('The checklist item ID to update'), + name: z.string().optional().describe('New checklist item name'), + resolved: z.boolean().optional().describe('Mark the item resolved or unresolved'), + assignee: z + .number() + .nullable() + .optional() + .describe('User ID to assign, or null to clear the assignee'), + parentItemId: z + .string() + .nullable() + .optional() + .describe('Parent checklist item ID for nesting, or null to unnest') + }) + ) + .output( + z.object({ + checklist: checklistOutput, + item: checklistItemOutput + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + let checklist = await client.updateChecklistItem( + ctx.input.checklistId, + ctx.input.checklistItemId, + { + name: ctx.input.name, + resolved: ctx.input.resolved, + assignee: ctx.input.assignee, + parent: ctx.input.parentItemId + } + ); + let item = (Array.isArray(checklist.items) ? checklist.items : []).find( + (candidate: any) => String(candidate.id) === ctx.input.checklistItemId + ); + if (!item?.id) { + throw clickupServiceError('ClickUp did not return the updated checklist item.'); + } + + return { + output: { + checklist: formatChecklist(checklist), + item: formatChecklistItem(item) + }, + message: `Updated checklist item ${ctx.input.checklistItemId}.` + }; + }) + .build(); + +export let deleteChecklistItem = SlateTool.create(spec, { + name: 'Delete Checklist Item', + key: 'delete_checklist_item', + description: `Delete an item from a ClickUp task checklist.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + checklistId: z.string().describe('The checklist ID that owns the item'), + checklistItemId: z.string().describe('The checklist item ID to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + await client.deleteChecklistItem(ctx.input.checklistId, ctx.input.checklistItemId); + + return { + output: { deleted: true }, + message: `Deleted checklist item ${ctx.input.checklistItemId}.` + }; + }) + .build(); diff --git a/integrations/clickup/src/tools/manage-comments.ts b/integrations/clickup/src/tools/manage-comments.ts index cf142df374..2186dd3a45 100644 --- a/integrations/clickup/src/tools/manage-comments.ts +++ b/integrations/clickup/src/tools/manage-comments.ts @@ -87,3 +87,72 @@ export let createComment = SlateTool.create(spec, { }; }) .build(); + +export let updateComment = SlateTool.create(spec, { + name: 'Update Task Comment', + key: 'update_task_comment', + description: `Update a ClickUp task comment's text, optional assignee, or resolved state.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + commentId: z.string().describe('The comment ID to update'), + commentText: z.string().describe('Replacement comment text'), + assignee: z.number().optional().describe('User ID to assign the comment to'), + resolved: z.boolean().optional().describe('Mark the comment resolved or unresolved') + }) + ) + .output( + z.object({ + commentId: z.string(), + updated: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + await client.updateComment(ctx.input.commentId, { + commentText: ctx.input.commentText, + assignee: ctx.input.assignee, + resolved: ctx.input.resolved + }); + + return { + output: { + commentId: ctx.input.commentId, + updated: true + }, + message: `Updated comment ${ctx.input.commentId}.` + }; + }) + .build(); + +export let deleteComment = SlateTool.create(spec, { + name: 'Delete Task Comment', + key: 'delete_task_comment', + description: `Delete a ClickUp task comment by its comment ID.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + commentId: z.string().describe('The comment ID to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + await client.deleteComment(ctx.input.commentId); + + return { + output: { deleted: true }, + message: `Deleted comment ${ctx.input.commentId}.` + }; + }) + .build(); diff --git a/integrations/clickup/src/tools/manage-custom-fields.ts b/integrations/clickup/src/tools/manage-custom-fields.ts index 2ac739fc4b..18e8782f99 100644 --- a/integrations/clickup/src/tools/manage-custom-fields.ts +++ b/integrations/clickup/src/tools/manage-custom-fields.ts @@ -85,3 +85,33 @@ export let setCustomFieldValue = SlateTool.create(spec, { }; }) .build(); + +export let removeCustomFieldValue = SlateTool.create(spec, { + name: 'Remove Custom Field Value', + key: 'remove_custom_field_value', + description: `Remove a ClickUp custom field value from a task without deleting the field definition.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + taskId: z.string().describe('The task ID to clear the field on'), + fieldId: z.string().describe('The custom field ID to clear') + }) + ) + .output( + z.object({ + removed: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + await client.removeCustomFieldValue(ctx.input.taskId, ctx.input.fieldId); + + return { + output: { removed: true }, + message: `Removed custom field ${ctx.input.fieldId} from task ${ctx.input.taskId}.` + }; + }) + .build(); diff --git a/integrations/clickup/src/tools/manage-lists.ts b/integrations/clickup/src/tools/manage-lists.ts index 03f790d27a..cd6e2a97da 100644 --- a/integrations/clickup/src/tools/manage-lists.ts +++ b/integrations/clickup/src/tools/manage-lists.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { ClickUpClient } from '../lib/client'; +import { clickupServiceError } from '../lib/errors'; import { spec } from '../spec'; export let getLists = SlateTool.create(spec, { @@ -41,7 +42,7 @@ export let getLists = SlateTool.create(spec, { } else if (ctx.input.spaceId) { lists = await client.getFolderlessLists(ctx.input.spaceId, ctx.input.archived); } else { - throw new Error('Either folderId or spaceId must be provided'); + throw clickupServiceError('Either folderId or spaceId must be provided.'); } return { @@ -100,7 +101,7 @@ export let createList = SlateTool.create(spec, { } else if (ctx.input.spaceId) { list = await client.createFolderlessList(ctx.input.spaceId, data); } else { - throw new Error('Either folderId or spaceId must be provided'); + throw clickupServiceError('Either folderId or spaceId must be provided.'); } return { diff --git a/integrations/clickup/src/tools/manage-tags.ts b/integrations/clickup/src/tools/manage-tags.ts index 4b8bae8762..5a21ec2974 100644 --- a/integrations/clickup/src/tools/manage-tags.ts +++ b/integrations/clickup/src/tools/manage-tags.ts @@ -79,3 +79,75 @@ export let createSpaceTag = SlateTool.create(spec, { }; }) .build(); + +export let updateSpaceTag = SlateTool.create(spec, { + name: 'Update Space Tag', + key: 'update_space_tag', + description: `Rename or recolor an existing ClickUp task tag in a Space.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + spaceId: z.string().describe('The Space ID that owns the tag'), + tagName: z.string().describe('Current tag name'), + newTagName: z.string().optional().describe('New tag name'), + tagForegroundColor: z.string().optional().describe('Foreground color hex code'), + tagBackgroundColor: z.string().optional().describe('Background color hex code') + }) + ) + .output( + z.object({ + updated: z.boolean(), + tagName: z.string() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + let nextName = ctx.input.newTagName ?? ctx.input.tagName; + await client.updateSpaceTag(ctx.input.spaceId, ctx.input.tagName, { + name: nextName, + tagFg: ctx.input.tagForegroundColor, + tagBg: ctx.input.tagBackgroundColor + }); + + return { + output: { + updated: true, + tagName: nextName + }, + message: `Updated tag **${ctx.input.tagName}** in space ${ctx.input.spaceId}.` + }; + }) + .build(); + +export let deleteSpaceTag = SlateTool.create(spec, { + name: 'Delete Space Tag', + key: 'delete_space_tag', + description: `Delete a ClickUp task tag from a Space. This removes the tag definition from the Space.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + spaceId: z.string().describe('The Space ID that owns the tag'), + tagName: z.string().describe('Tag name to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + await client.deleteSpaceTag(ctx.input.spaceId, ctx.input.tagName); + + return { + output: { deleted: true }, + message: `Deleted tag **${ctx.input.tagName}** from space ${ctx.input.spaceId}.` + }; + }) + .build(); diff --git a/integrations/clickup/src/tools/time-tracking.ts b/integrations/clickup/src/tools/time-tracking.ts index c59e123e89..0e0d2808a5 100644 --- a/integrations/clickup/src/tools/time-tracking.ts +++ b/integrations/clickup/src/tools/time-tracking.ts @@ -121,6 +121,126 @@ export let createTimeEntry = SlateTool.create(spec, { }) .build(); +export let updateTimeEntry = SlateTool.create(spec, { + name: 'Update Time Entry', + key: 'update_time_entry', + description: `Update a ClickUp time entry's task, description, start/end time, duration, assignee, tags, or billable flag.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + timeEntryId: z.string().describe('The time entry ID to update'), + start: z.string().optional().describe('Start time as Unix timestamp in milliseconds'), + end: z.string().optional().describe('End time as Unix timestamp in milliseconds'), + duration: z.number().optional().describe('Duration in milliseconds'), + taskId: z.string().optional().describe('Task ID to associate the entry with'), + description: z.string().optional().describe('Updated description of the work'), + assignee: z.number().optional().describe('User ID to assign the entry to'), + billable: z.boolean().optional().describe('Whether the entry is billable'), + tags: z.array(z.string()).optional().describe('Tag names for the time entry') + }) + ) + .output( + z.object({ + timeEntryId: z.string(), + updated: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + let entry = await client.updateTimeEntry(ctx.config.workspaceId, ctx.input.timeEntryId, { + start: ctx.input.start ? Number(ctx.input.start) : undefined, + end: ctx.input.end ? Number(ctx.input.end) : undefined, + duration: ctx.input.duration, + taskId: ctx.input.taskId, + description: ctx.input.description, + assignee: ctx.input.assignee, + billable: ctx.input.billable, + tags: ctx.input.tags?.map(name => ({ name })) + }); + + return { + output: { + timeEntryId: String(entry?.id ?? ctx.input.timeEntryId), + updated: true + }, + message: `Updated time entry ${ctx.input.timeEntryId}.` + }; + }) + .build(); + +export let deleteTimeEntry = SlateTool.create(spec, { + name: 'Delete Time Entry', + key: 'delete_time_entry', + description: `Delete a ClickUp time entry from the configured workspace.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + timeEntryId: z.string().describe('The time entry ID to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + await client.deleteTimeEntry(ctx.config.workspaceId, ctx.input.timeEntryId); + + return { + output: { deleted: true }, + message: `Deleted time entry ${ctx.input.timeEntryId}.` + }; + }) + .build(); + +export let getRunningTimer = SlateTool.create(spec, { + name: 'Get Running Timer', + key: 'get_running_timer', + description: `Retrieve the currently running ClickUp timer for the authenticated user or a specified assignee.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + assignee: z.string().optional().describe('User ID to retrieve a running timer for') + }) + ) + .output( + z.object({ + running: z.boolean(), + timeEntryId: z.string().optional(), + taskId: z.string().optional(), + description: z.string().optional(), + duration: z.string().optional(), + start: z.string().optional() + }) + ) + .handleInvocation(async ctx => { + let client = new ClickUpClient(ctx.auth.token); + let entry = await client.getRunningTimer(ctx.config.workspaceId, ctx.input.assignee); + + return { + output: { + running: Boolean(entry?.id), + timeEntryId: entry?.id, + taskId: entry?.task?.id ?? entry?.tid, + description: entry?.description, + duration: entry?.duration !== undefined ? String(entry.duration) : undefined, + start: entry?.start !== undefined ? String(entry.start) : undefined + }, + message: entry?.id ? `Found running timer ${entry.id}.` : 'No running timer found.' + }; + }) + .build(); + export let startTimer = SlateTool.create(spec, { name: 'Start Timer', key: 'start_timer', diff --git a/integrations/cloudflare-workers/README.md b/integrations/cloudflare-workers/README.md index 077856c019..826d495352 100644 --- a/integrations/cloudflare-workers/README.md +++ b/integrations/cloudflare-workers/README.md @@ -20,18 +20,46 @@ Retrieve detailed information about a specific Worker version, including its bin List all Worker scripts in the account. Returns metadata for each script including handlers, compatibility settings, and deployment info. +### Upload Worker Module + +Create or replace a Worker script by uploading code and multipart metadata. Supports bindings, compatibility settings, observability, placement, Logpush, and version annotations. + +### Download Worker Script Content + +Download a Worker's script content as a Slate attachment without exposing full file content in tool output fields. + +### Update Worker Script Content + +Replace a Worker's script content without changing its existing configuration or metadata. + ### List Worker Versions List all versions of a Worker script. Optionally filter to only deployable versions. Versions are created separately from deployments—a version can exist without being deployed. +### Upload Worker Version + +Upload a new deployable Worker version without deploying it. Use **Deploy Worker Version** afterwards to route traffic to the returned version ID. + ### List Deployments List all deployments for a Worker script. The first deployment in the list is the active one serving traffic. Each deployment shows the version(s) and their traffic percentages for gradual rollouts. +### Get Deployment + +Retrieve a specific Worker deployment, including its traffic strategy and version split. + +### Delete Deployment + +Delete a specific Worker deployment from a script. + ### List Worker Domains List all custom domains attached to Workers in the account. Each domain maps a hostname on a Cloudflare zone to a specific Worker. +### Get Worker Domain + +Retrieve a specific custom domain attachment for a Worker. + ### List Worker Routes List all Worker routes for a Cloudflare zone. Routes map URL patterns to Worker scripts. @@ -44,6 +72,10 @@ Retrieve all cron trigger schedules configured for a Worker. Cron Triggers run t List all secret bindings on a Worker script. Returns secret names and types only — secret values are never exposed after creation. +### Get Secret Metadata + +Retrieve metadata for a secret binding on a Worker script. Secret values are not exposed in tool output. + ### Get Worker Settings Retrieve the full settings and bindings configuration for a Worker script, including environment variables, KV namespaces, R2 buckets, D1 databases, Queues, Durable Objects, and other bindings. diff --git a/integrations/cloudflare-workers/docs/SPEC.md b/integrations/cloudflare-workers/docs/SPEC.md index 9b038e81a3..d826335a54 100644 --- a/integrations/cloudflare-workers/docs/SPEC.md +++ b/integrations/cloudflare-workers/docs/SPEC.md @@ -33,7 +33,7 @@ Global API Key is the previous authorization scheme for interacting with the Clo ### Worker Script Management -Create, list, update, download, and delete Worker scripts within an account. Workers are standalone resources that can be created and configured without any code. Platform teams can provision Workers with the right settings, then hand them off to development teams for implementation. +Create, list, update, download, and delete Worker scripts within an account. Worker code downloads are returned as Slate attachments, and code uploads use Cloudflare's multipart Worker upload APIs. Workers are standalone resources that can be created and configured without any code. Platform teams can provision Workers with the right settings, then hand them off to development teams for implementation. ### Version and Deployment Management diff --git a/integrations/cloudflare-workers/package.json b/integrations/cloudflare-workers/package.json index e733b38c3b..690eb50bf5 100644 --- a/integrations/cloudflare-workers/package.json +++ b/integrations/cloudflare-workers/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/cloudflare-workers/slate.json b/integrations/cloudflare-workers/slate.json index 721b21de55..1992478a28 100644 --- a/integrations/cloudflare-workers/slate.json +++ b/integrations/cloudflare-workers/slate.json @@ -4,6 +4,7 @@ "categories": ["apis-and-http-requests", "code-execution"], "skills": [ "manage Worker scripts", + "upload and download Worker code", "deploy Worker versions", "configure bindings and settings", "manage secrets", diff --git a/integrations/cloudflare-workers/src/auth.ts b/integrations/cloudflare-workers/src/auth.ts index 0a9fdf1499..ab7b886ff5 100644 --- a/integrations/cloudflare-workers/src/auth.ts +++ b/integrations/cloudflare-workers/src/auth.ts @@ -1,5 +1,22 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { cloudflareWorkersApiError, cloudflareWorkersApiResponseError } from './lib/errors'; + +let ensureSuccessfulCloudflareResponse = (response: { + data?: unknown; + status?: number; + statusText?: string; +}) => { + let data = response.data; + if ( + data && + typeof data === 'object' && + 'success' in data && + (data as { success?: unknown }).success === false + ) { + throw cloudflareWorkersApiResponseError(response); + } +}; export let auth = SlateAuth.create() .output( @@ -32,20 +49,25 @@ export let auth = SlateAuth.create() output: { token: string; email?: string; authType: string }; input: { token: string }; }) => { - let http = createAxios({ - baseURL: 'https://api.cloudflare.com/client/v4', - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); - let response = await http.get('/user/tokens/verify'); - let result = response.data.result; - return { - profile: { - id: result.id, - status: result.status - } - }; + try { + let http = createAxios({ + baseURL: 'https://api.cloudflare.com/client/v4', + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); + let response = await http.get('/user/tokens/verify'); + ensureSuccessfulCloudflareResponse(response); + let result = response.data.result; + return { + profile: { + id: result.id, + status: result.status + } + }; + } catch (error) { + throw cloudflareWorkersApiError(error, 'verify API token'); + } } }) .addCustomAuth({ @@ -73,21 +95,26 @@ export let auth = SlateAuth.create() output: { token: string; email?: string; authType: string }; input: { email: string; token: string }; }) => { - let http = createAxios({ - baseURL: 'https://api.cloudflare.com/client/v4', - headers: { - 'X-Auth-Email': ctx.output.email!, - 'X-Auth-Key': ctx.output.token - } - }); - let response = await http.get('/user'); - let user = response.data.result; - return { - profile: { - id: user.id, - email: user.email, - name: `${user.first_name || ''} ${user.last_name || ''}`.trim() || undefined - } - }; + try { + let http = createAxios({ + baseURL: 'https://api.cloudflare.com/client/v4', + headers: { + 'X-Auth-Email': ctx.output.email!, + 'X-Auth-Key': ctx.output.token + } + }); + let response = await http.get('/user'); + ensureSuccessfulCloudflareResponse(response); + let user = response.data.result; + return { + profile: { + id: user.id, + email: user.email, + name: `${user.first_name || ''} ${user.last_name || ''}`.trim() || undefined + } + }; + } catch (error) { + throw cloudflareWorkersApiError(error, 'verify global API key'); + } } }); diff --git a/integrations/cloudflare-workers/src/index.ts b/integrations/cloudflare-workers/src/index.ts index 6951c3c31e..19affd415c 100644 --- a/integrations/cloudflare-workers/src/index.ts +++ b/integrations/cloudflare-workers/src/index.ts @@ -5,13 +5,18 @@ import { createDeployment, createRoute, createTail, + deleteDeployment, deleteRoute, deleteScript, deleteSecret, deleteTail, detachDomain, + getDeployment, + getDomain, getSchedules, getScript, + getScriptContent, + getSecret, getSubdomain, getVersion, getWorkerSettings, @@ -23,12 +28,15 @@ import { listTails, listTelemetryKeys, listVersions, + putScriptContent, putSecret, queryTelemetry, setScriptSubdomain, updateRoute, updateSchedules, - updateScriptSettings + updateScriptSettings, + uploadVersion, + uploadWorkerModule } from './tools'; import { deploymentChanges, inboundWebhook, scriptChanges } from './triggers'; @@ -36,13 +44,20 @@ export let provider = Slate.create({ spec, tools: [ listScripts, + uploadWorkerModule, getScript, + getScriptContent, + putScriptContent, deleteScript, listVersions, getVersion, + uploadVersion, listDeployments, + getDeployment, createDeployment, + deleteDeployment, listSecrets, + getSecret, putSecret, deleteSecret, getWorkerSettings, @@ -50,6 +65,7 @@ export let provider = Slate.create({ getSchedules, updateSchedules, listDomains, + getDomain, attachDomain, detachDomain, listRoutes, diff --git a/integrations/cloudflare-workers/src/lib/client.ts b/integrations/cloudflare-workers/src/lib/client.ts index 8b5ff428c4..388a73846e 100644 --- a/integrations/cloudflare-workers/src/lib/client.ts +++ b/integrations/cloudflare-workers/src/lib/client.ts @@ -1,4 +1,10 @@ +import { Buffer } from 'node:buffer'; import { createAxios } from 'slates'; +import { + cloudflareWorkersApiError, + cloudflareWorkersApiResponseError, + cloudflareWorkersServiceError +} from './errors'; export interface CloudflareAuthConfig { token: string; @@ -7,6 +13,170 @@ export interface CloudflareAuthConfig { accountId: string; } +export interface WorkerScriptUploadParams { + scriptName: string; + scriptContent: string; + moduleName?: string; + syntax?: 'module' | 'service_worker'; + contentType?: string; + compatibilityDate?: string; + compatibilityFlags?: string[]; + bindings?: Record[]; + usageModel?: string; + logpush?: boolean; + observability?: { + enabled?: boolean; + headSamplingRate?: number; + }; + placementMode?: string; + message?: string; + tag?: string; + alias?: string; + bindingsInheritStrict?: boolean; +} + +export interface ScriptContentUpdateParams { + scriptName: string; + scriptContent: string; + moduleName?: string; + syntax?: 'module' | 'service_worker'; + contentType?: string; +} + +export interface DownloadedScriptContent { + content: string; + contentType: string; + sizeBytes: number; +} + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let isCloudflareEnvelopeFailure = (value: unknown) => + isRecord(value) && value.success === false; + +let responseHeader = (headers: unknown, name: string) => { + if (!headers || typeof headers !== 'object') { + return undefined; + } + + let record = headers as Record & { + get?: (key: string) => unknown; + }; + let value = record[name] ?? record[name.toLowerCase()] ?? record.get?.(name); + + return typeof value === 'string' ? value : undefined; +}; + +let contentTypeWithoutParams = (value: string | undefined, fallback: string) => + value?.split(';', 1)[0]?.trim() || fallback; + +let toDownloadedContent = ( + data: unknown, + headers: unknown, + fallbackContentType: string +): DownloadedScriptContent => { + let content = + typeof data === 'string' + ? data + : Buffer.isBuffer(data) + ? data.toString('utf8') + : isRecord(data) + ? JSON.stringify(data, null, 2) + : String(data ?? ''); + let contentType = contentTypeWithoutParams( + responseHeader(headers, 'content-type'), + fallbackContentType + ); + + return { + content, + contentType, + sizeBytes: Buffer.byteLength(content, 'utf8') + }; +}; + +let defaultModuleName = (syntax: 'module' | 'service_worker') => + syntax === 'service_worker' ? 'worker.js' : 'index.js'; + +let defaultContentType = (syntax: 'module' | 'service_worker') => + syntax === 'service_worker' ? 'application/javascript' : 'application/javascript+module'; + +let appendWorkerFile = ( + formData: FormData, + name: string, + content: string, + contentType: string +) => { + formData.append(name, new Blob([content], { type: contentType }), name); +}; + +let buildUploadMetadata = ( + params: WorkerScriptUploadParams, + options: { versionUpload?: boolean } = {} +) => { + let syntax = options.versionUpload ? 'module' : (params.syntax ?? 'module'); + let moduleName = params.moduleName || defaultModuleName(syntax); + let metadata: Record = + syntax === 'service_worker' ? { body_part: moduleName } : { main_module: moduleName }; + + if (params.compatibilityDate !== undefined) { + metadata.compatibility_date = params.compatibilityDate; + } + if (params.compatibilityFlags !== undefined) { + metadata.compatibility_flags = params.compatibilityFlags; + } + if (params.bindings !== undefined) { + metadata.bindings = params.bindings; + } + if (params.usageModel !== undefined) { + metadata.usage_model = params.usageModel; + } + if (params.logpush !== undefined) { + metadata.logpush = params.logpush; + } + if (params.observability !== undefined) { + metadata.observability = { + enabled: params.observability.enabled, + head_sampling_rate: params.observability.headSamplingRate + }; + } + if (params.placementMode !== undefined) { + metadata.placement = { mode: params.placementMode }; + } + + let annotations: Record = {}; + if (params.message !== undefined) { + annotations['workers/message'] = params.message; + } + if (params.tag !== undefined) { + annotations['workers/tag'] = params.tag; + } + if (params.alias !== undefined) { + annotations['workers/alias'] = params.alias; + } + if (Object.keys(annotations).length > 0) { + metadata.annotations = annotations; + } + + return { + metadata, + moduleName, + syntax + }; +}; + +let buildScriptContentMetadata = (params: ScriptContentUpdateParams) => { + let syntax = params.syntax ?? 'module'; + let moduleName = params.moduleName || defaultModuleName(syntax); + return { + metadata: + syntax === 'service_worker' ? { body_part: moduleName } : { main_module: moduleName }, + moduleName, + syntax + }; +}; + export class CloudflareClient { private accountId: string; private http: ReturnType; @@ -26,6 +196,17 @@ export class CloudflareClient { baseURL: 'https://api.cloudflare.com/client/v4', headers }); + + this.http.interceptors.response.use( + response => { + if (isCloudflareEnvelopeFailure(response.data)) { + throw cloudflareWorkersApiResponseError(response); + } + + return response; + }, + error => Promise.reject(cloudflareWorkersApiError(error)) + ); } private get basePath() { @@ -48,9 +229,62 @@ export class CloudflareClient { async getScriptContent(scriptName: string) { let response = await this.http.get(`${this.basePath}/scripts/${scriptName}/content/v2`, { - headers: { Accept: 'application/javascript' } + headers: { + Accept: 'application/javascript, text/javascript, application/json, text/plain' + }, + responseType: 'text', + transformResponse: data => data }); - return response.data; + return toDownloadedContent(response.data, response.headers, 'application/javascript'); + } + + async uploadWorkerModule(params: WorkerScriptUploadParams) { + if (!params.scriptContent.trim()) { + throw cloudflareWorkersServiceError('scriptContent must not be empty.'); + } + + let { metadata, moduleName, syntax } = buildUploadMetadata(params); + let formData = new FormData(); + formData.append('metadata', JSON.stringify(metadata)); + appendWorkerFile( + formData, + moduleName, + params.scriptContent, + params.contentType || defaultContentType(syntax) + ); + + let response = await this.http.put( + `${this.basePath}/scripts/${params.scriptName}`, + formData, + { + params: { + bindings_inherit: params.bindingsInheritStrict ? 'strict' : undefined + } + } + ); + return response.data.result; + } + + async putScriptContent(params: ScriptContentUpdateParams) { + if (!params.scriptContent.trim()) { + throw cloudflareWorkersServiceError('scriptContent must not be empty.'); + } + + let { metadata, moduleName, syntax } = buildScriptContentMetadata(params); + let formData = new FormData(); + formData.append('metadata', JSON.stringify(metadata)); + appendWorkerFile( + formData, + moduleName, + params.scriptContent, + params.contentType || defaultContentType(syntax) + ); + + let response = await this.http.put( + `${this.basePath}/scripts/${params.scriptName}/content`, + formData + ); + return response.data.result; } async deleteScript(scriptName: string, force?: boolean) { @@ -89,6 +323,35 @@ export class CloudflareClient { return response.data.result; } + async uploadVersion(params: WorkerScriptUploadParams) { + if (!params.scriptContent.trim()) { + throw cloudflareWorkersServiceError('scriptContent must not be empty.'); + } + + let { metadata, moduleName, syntax } = buildUploadMetadata(params, { + versionUpload: true + }); + let formData = new FormData(); + formData.append('metadata', JSON.stringify(metadata)); + appendWorkerFile( + formData, + moduleName, + params.scriptContent, + params.contentType || defaultContentType(syntax) + ); + + let response = await this.http.post( + `${this.basePath}/scripts/${params.scriptName}/versions`, + formData, + { + params: { + bindings_inherit: params.bindingsInheritStrict ? 'strict' : undefined + } + } + ); + return response.data.result; + } + // ===================== // Deployments // ===================== @@ -139,6 +402,13 @@ export class CloudflareClient { return response.data.result; } + async getSecret(scriptName: string, secretName: string) { + let response = await this.http.get( + `${this.basePath}/scripts/${scriptName}/secrets/${secretName}` + ); + return response.data.result; + } + async putSecret(scriptName: string, name: string, value: string) { let response = await this.http.put(`${this.basePath}/scripts/${scriptName}/secrets`, { name, diff --git a/integrations/cloudflare-workers/src/lib/errors.ts b/integrations/cloudflare-workers/src/lib/errors.ts new file mode 100644 index 0000000000..5a42ede7fe --- /dev/null +++ b/integrations/cloudflare-workers/src/lib/errors.ts @@ -0,0 +1,107 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let trimmed = String(value).trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let collectCloudflareDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectCloudflareDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.code); + addDetail(details, value.documentation_url); + + if (isRecord(value.source)) { + addDetail(details, value.source.pointer); + } +}; + +let extractCloudflareMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + collectCloudflareDetails(data.errors, details); + collectCloudflareDetails(data.messages, details); + collectCloudflareDetails(data.error, details); + collectCloudflareDetails(data.message, details); + } else { + collectCloudflareDetails(data, details); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let cloudflareWorkersServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let cloudflareWorkersApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = cloudflareWorkersServiceError( + `Cloudflare Workers API ${operation} failed: ${statusLabelFor(response)}${extractCloudflareMessage(error)}` + ); + serviceError.data.reason = 'cloudflare_workers_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let cloudflareWorkersApiResponseError = ( + response: ErrorResponse, + operation = 'request' +) => { + let serviceError = cloudflareWorkersServiceError( + `Cloudflare Workers API ${operation} failed: ${statusLabelFor(response)}${extractCloudflareMessage({ response })}` + ); + serviceError.data.reason = 'cloudflare_workers_api_error'; + serviceError.data.upstreamStatus = response.status; + return serviceError; +}; diff --git a/integrations/cloudflare-workers/src/tools/index.ts b/integrations/cloudflare-workers/src/tools/index.ts index 1b53d0bb78..4dc651be2e 100644 --- a/integrations/cloudflare-workers/src/tools/index.ts +++ b/integrations/cloudflare-workers/src/tools/index.ts @@ -12,3 +12,6 @@ export * from './manage-settings'; export * from './manage-subdomain'; export * from './manage-tails'; export * from './query-telemetry'; +export * from './script-content'; +export * from './upload-version'; +export * from './upload-worker-module'; diff --git a/integrations/cloudflare-workers/src/tools/manage-deployments.ts b/integrations/cloudflare-workers/src/tools/manage-deployments.ts index c4d3a683f5..88b695fd99 100644 --- a/integrations/cloudflare-workers/src/tools/manage-deployments.ts +++ b/integrations/cloudflare-workers/src/tools/manage-deployments.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { cloudflareWorkersServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -80,7 +81,7 @@ export let createDeployment = SlateTool.create(spec, { versionId: z.string().describe('Version UUID to deploy'), percentage: z .number() - .min(0) + .min(0.01) .max(100) .describe('Percentage of traffic for this version') }) @@ -102,6 +103,16 @@ export let createDeployment = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx); + let totalPercentage = ctx.input.versions.reduce( + (sum, version) => sum + version.percentage, + 0 + ); + if (Math.abs(totalPercentage - 100) > 0.0001) { + throw cloudflareWorkersServiceError( + `Deployment version percentages must sum to 100, got ${totalPercentage}.` + ); + } + let result = await client.createDeployment( ctx.input.scriptName, ctx.input.versions, @@ -113,6 +124,12 @@ export let createDeployment = SlateTool.create(spec, { percentage: v.percentage })); + if (!result.id) { + throw cloudflareWorkersServiceError( + 'Cloudflare did not return a deployment ID for the created deployment.' + ); + } + return { output: { deploymentId: result.id, @@ -123,3 +140,69 @@ export let createDeployment = SlateTool.create(spec, { }; }) .build(); + +export let getDeployment = SlateTool.create(spec, { + name: 'Get Deployment', + key: 'get_deployment', + description: `Retrieve a specific Worker deployment, including its traffic strategy and version split.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + scriptName: z.string().describe('Name of the Worker script'), + deploymentId: z.string().describe('Deployment UUID to retrieve') + }) + ) + .output(deploymentSchema) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.getDeployment(ctx.input.scriptName, ctx.input.deploymentId); + + return { + output: { + deploymentId: result.id || ctx.input.deploymentId, + createdOn: result.created_on, + source: result.source, + strategy: result.strategy, + authorEmail: result.author_email, + versions: (result.versions || []).map((v: any) => ({ + versionId: v.version_id, + percentage: v.percentage + })) + }, + message: `Retrieved deployment **${ctx.input.deploymentId}** for Worker **${ctx.input.scriptName}**.` + }; + }) + .build(); + +export let deleteDeployment = SlateTool.create(spec, { + name: 'Delete Deployment', + key: 'delete_deployment', + description: `Delete a specific Worker deployment from a script.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + scriptName: z.string().describe('Name of the Worker script'), + deploymentId: z.string().describe('Deployment UUID to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean().describe('Whether the deletion was successful') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + await client.deleteDeployment(ctx.input.scriptName, ctx.input.deploymentId); + + return { + output: { deleted: true }, + message: `Deployment **${ctx.input.deploymentId}** has been deleted from Worker **${ctx.input.scriptName}**.` + }; + }) + .build(); diff --git a/integrations/cloudflare-workers/src/tools/manage-domains.ts b/integrations/cloudflare-workers/src/tools/manage-domains.ts index bceceb76fa..ccff2743ba 100644 --- a/integrations/cloudflare-workers/src/tools/manage-domains.ts +++ b/integrations/cloudflare-workers/src/tools/manage-domains.ts @@ -46,6 +46,38 @@ export let listDomains = SlateTool.create(spec, { }) .build(); +export let getDomain = SlateTool.create(spec, { + name: 'Get Worker Domain', + key: 'get_domain', + description: `Retrieve a specific custom domain attachment for a Worker.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + domainId: z.string().describe('Domain record UUID to retrieve') + }) + ) + .output(domainSchema) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.getDomain(ctx.input.domainId); + + return { + output: { + domainId: result.id, + hostname: result.hostname, + service: result.service, + zoneId: result.zone_id, + zoneName: result.zone_name, + environment: result.environment + }, + message: `Retrieved Worker domain **${ctx.input.domainId}**.` + }; + }) + .build(); + export let attachDomain = SlateTool.create(spec, { name: 'Attach Worker Domain', key: 'attach_domain', diff --git a/integrations/cloudflare-workers/src/tools/manage-secrets.ts b/integrations/cloudflare-workers/src/tools/manage-secrets.ts index 04a8203cfd..c6fe4168a4 100644 --- a/integrations/cloudflare-workers/src/tools/manage-secrets.ts +++ b/integrations/cloudflare-workers/src/tools/manage-secrets.ts @@ -42,6 +42,35 @@ export let listSecrets = SlateTool.create(spec, { }) .build(); +export let getSecret = SlateTool.create(spec, { + name: 'Get Secret Metadata', + key: 'get_secret', + description: `Retrieve metadata for a secret binding on a Worker script. Secret values are not exposed in tool output.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + scriptName: z.string().describe('Name of the Worker script'), + secretName: z.string().describe('Name of the secret binding') + }) + ) + .output(secretSchema) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.getSecret(ctx.input.scriptName, ctx.input.secretName); + + return { + output: { + secretName: result.name || ctx.input.secretName, + type: result.type || 'secret_text' + }, + message: `Retrieved metadata for secret **${ctx.input.secretName}** on Worker **${ctx.input.scriptName}**.` + }; + }) + .build(); + export let putSecret = SlateTool.create(spec, { name: 'Set Secret', key: 'put_secret', diff --git a/integrations/cloudflare-workers/src/tools/manage-tails.ts b/integrations/cloudflare-workers/src/tools/manage-tails.ts index f49d424965..9205a0500b 100644 --- a/integrations/cloudflare-workers/src/tools/manage-tails.ts +++ b/integrations/cloudflare-workers/src/tools/manage-tails.ts @@ -3,6 +3,12 @@ import { z } from 'zod'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; +let tailSchema = z.object({ + tailId: z.string().describe('Tail session UUID'), + websocketUrl: z.string().optional().describe('WebSocket URL for streaming logs'), + expiresAt: z.string().optional().describe('ISO 8601 expiration timestamp') +}); + export let createTail = SlateTool.create(spec, { name: 'Start Tail Session', key: 'create_tail', @@ -53,6 +59,7 @@ export let listTails = SlateTool.create(spec, { ) .output( z.object({ + tails: z.array(tailSchema).describe('Active tail sessions for the Worker'), tailId: z.string().optional().describe('Active tail session UUID'), websocketUrl: z.string().optional().describe('WebSocket URL for streaming logs'), expiresAt: z.string().optional().describe('ISO 8601 expiration timestamp') @@ -62,16 +69,22 @@ export let listTails = SlateTool.create(spec, { let client = createClient(ctx); let result = await client.listTails(ctx.input.scriptName); - let tail = Array.isArray(result) ? result[0] : result; + let tails = (Array.isArray(result) ? result : result ? [result] : []).map((tail: any) => ({ + tailId: tail.id, + websocketUrl: tail.url, + expiresAt: tail.expires_at + })); + let tail = tails[0]; return { output: { - tailId: tail?.id, - websocketUrl: tail?.url, - expiresAt: tail?.expires_at + tails, + tailId: tail?.tailId, + websocketUrl: tail?.websocketUrl, + expiresAt: tail?.expiresAt }, - message: tail?.id - ? `Active tail session **${tail.id}** for Worker **${ctx.input.scriptName}**.` + message: tail?.tailId + ? `Active tail session **${tail.tailId}** for Worker **${ctx.input.scriptName}**.` : `No active tail sessions for Worker **${ctx.input.scriptName}**.` }; }) diff --git a/integrations/cloudflare-workers/src/tools/script-content.ts b/integrations/cloudflare-workers/src/tools/script-content.ts new file mode 100644 index 0000000000..7da9bfded6 --- /dev/null +++ b/integrations/cloudflare-workers/src/tools/script-content.ts @@ -0,0 +1,92 @@ +import { createTextAttachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +let scriptSyntaxSchema = z + .enum(['module', 'service_worker']) + .optional() + .describe('Worker syntax for uploaded content. Defaults to module syntax.'); + +export let getScriptContent = SlateTool.create(spec, { + name: 'Download Worker Script Content', + key: 'get_script_content', + description: `Download a Worker's script content as a Slate attachment. Use this when you need to inspect or archive the deployed source without putting full file content in tool output fields.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + scriptName: z.string().describe('Name of the Worker script') + }) + ) + .output( + z.object({ + scriptName: z.string().describe('Name of the Worker script'), + contentType: z.string().describe('MIME type of the downloaded script content'), + sizeBytes: z.number().describe('Downloaded content size in bytes'), + attachmentCount: z.number().describe('Number of Slate attachments returned') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let content = await client.getScriptContent(ctx.input.scriptName); + + return { + output: { + scriptName: ctx.input.scriptName, + contentType: content.contentType, + sizeBytes: content.sizeBytes, + attachmentCount: 1 + }, + attachments: [createTextAttachment(content.content, content.contentType)], + message: `Downloaded script content for Worker **${ctx.input.scriptName}** (${content.sizeBytes} bytes).` + }; + }) + .build(); + +export let putScriptContent = SlateTool.create(spec, { + name: 'Update Worker Script Content', + key: 'put_script_content', + description: `Replace a Worker's script content without changing its existing configuration or metadata. Use Upload Worker Module when you also need to change bindings, compatibility settings, or annotations.` +}) + .input( + z.object({ + scriptName: z.string().describe('Name of the Worker script'), + scriptContent: z.string().describe('Worker source code to upload'), + syntax: scriptSyntaxSchema, + moduleName: z + .string() + .optional() + .describe( + 'Filename for the uploaded script part. Defaults to index.js for module syntax and worker.js for service-worker syntax.' + ), + contentType: z.string().optional().describe('MIME type for the uploaded code part') + }) + ) + .output( + z.object({ + scriptId: z.string().describe('Worker script identifier'), + updated: z.boolean().describe('Whether the content update was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.putScriptContent({ + scriptName: ctx.input.scriptName, + scriptContent: ctx.input.scriptContent, + syntax: ctx.input.syntax, + moduleName: ctx.input.moduleName, + contentType: ctx.input.contentType + }); + + return { + output: { + scriptId: result?.id || ctx.input.scriptName, + updated: true + }, + message: `Updated script content for Worker **${ctx.input.scriptName}**.` + }; + }) + .build(); diff --git a/integrations/cloudflare-workers/src/tools/upload-version.ts b/integrations/cloudflare-workers/src/tools/upload-version.ts new file mode 100644 index 0000000000..40c7ac6996 --- /dev/null +++ b/integrations/cloudflare-workers/src/tools/upload-version.ts @@ -0,0 +1,90 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { cloudflareWorkersServiceError } from '../lib/errors'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +let bindingSchema = z + .record(z.string(), z.any()) + .describe('Cloudflare Worker binding object from multipart upload metadata.'); + +export let uploadVersion = SlateTool.create(spec, { + name: 'Upload Worker Version', + key: 'upload_version', + description: `Upload a new deployable Worker version without deploying it. Use create_deployment afterwards to route traffic to the returned version ID.`, + instructions: [ + 'Version upload requires module syntax. Provide source code that exports a fetch handler from the main module.', + 'A version is not deployed until create_deployment is called.' + ] +}) + .input( + z.object({ + scriptName: z.string().describe('Name of the Worker script'), + scriptContent: z.string().describe('Worker module source code for the new version'), + moduleName: z + .string() + .optional() + .describe('Filename for the uploaded main module. Defaults to index.js.'), + contentType: z.string().optional().describe('MIME type for the uploaded module'), + compatibilityDate: z + .string() + .optional() + .describe('Worker compatibility date in YYYY-MM-DD format'), + compatibilityFlags: z.array(z.string()).optional().describe('Compatibility flags'), + bindings: z.array(bindingSchema).optional().describe('Complete binding list'), + usageModel: z.string().optional().describe('Worker usage model'), + message: z + .string() + .max(1000) + .optional() + .describe('Human-readable annotation for the version'), + tag: z.string().max(100).optional().describe('User-provided version tag'), + alias: z.string().max(63).optional().describe('Associated alias for the version'), + bindingsInheritStrict: z + .boolean() + .optional() + .describe('When true, fail the upload if inherited bindings cannot be resolved') + }) + ) + .output( + z.object({ + versionId: z.string().describe('Created version UUID'), + number: z.number().optional().describe('Sequential version number'), + createdOn: z.string().optional().describe('ISO 8601 creation timestamp'), + source: z.string().optional().describe('Source that created this version') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.uploadVersion({ + scriptName: ctx.input.scriptName, + scriptContent: ctx.input.scriptContent, + moduleName: ctx.input.moduleName, + contentType: ctx.input.contentType, + compatibilityDate: ctx.input.compatibilityDate, + compatibilityFlags: ctx.input.compatibilityFlags, + bindings: ctx.input.bindings, + usageModel: ctx.input.usageModel, + message: ctx.input.message, + tag: ctx.input.tag, + alias: ctx.input.alias, + bindingsInheritStrict: ctx.input.bindingsInheritStrict + }); + + if (!result?.id) { + throw cloudflareWorkersServiceError( + 'Cloudflare did not return a version ID for the uploaded Worker version.' + ); + } + + return { + output: { + versionId: result.id, + number: result.number, + createdOn: result.metadata?.created_on, + source: result.metadata?.source + }, + message: `Uploaded version **${result.id}** for Worker **${ctx.input.scriptName}**.` + }; + }) + .build(); diff --git a/integrations/cloudflare-workers/src/tools/upload-worker-module.ts b/integrations/cloudflare-workers/src/tools/upload-worker-module.ts new file mode 100644 index 0000000000..48c5536f5f --- /dev/null +++ b/integrations/cloudflare-workers/src/tools/upload-worker-module.ts @@ -0,0 +1,126 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +let bindingSchema = z + .record(z.string(), z.any()) + .describe( + 'Cloudflare Worker binding object from the multipart upload metadata docs, such as a plain_text, secret_text, kv_namespace, r2_bucket, d1, queue, durable_object_namespace, or service binding.' + ); + +let observabilityInputSchema = z.object({ + enabled: z.boolean().optional().describe('Enable or disable Workers observability'), + headSamplingRate: z + .number() + .min(0) + .max(1) + .optional() + .describe('Head sampling rate from 0.0 to 1.0') +}); + +export let uploadWorkerModule = SlateTool.create(spec, { + name: 'Upload Worker Module', + key: 'upload_worker_module', + description: `Create or replace a Worker script by uploading code and multipart metadata. This is the full Worker upload path for changing code plus bindings, compatibility settings, observability, placement, Logpush, or version annotations.`, + instructions: [ + 'For ES module Workers, leave syntax as module and export a fetch handler from the uploaded module.', + 'For service-worker syntax, set syntax to service_worker and provide event-listener style source code.', + 'Use put_script_content for code-only updates that should not touch existing configuration or metadata.' + ] +}) + .input( + z.object({ + scriptName: z.string().describe('Name of the Worker script to create or replace'), + scriptContent: z.string().describe('Worker source code to upload'), + syntax: z + .enum(['module', 'service_worker']) + .optional() + .describe('Worker syntax for the uploaded code. Defaults to module syntax.'), + moduleName: z + .string() + .optional() + .describe( + 'Filename for the uploaded code part. Defaults to index.js for module syntax and worker.js for service-worker syntax.' + ), + contentType: z.string().optional().describe('MIME type for the uploaded code part'), + compatibilityDate: z + .string() + .optional() + .describe('Worker compatibility date in YYYY-MM-DD format'), + compatibilityFlags: z + .array(z.string()) + .optional() + .describe('Worker compatibility flags to apply'), + bindings: z + .array(bindingSchema) + .optional() + .describe('Complete binding list for the uploaded Worker metadata'), + usageModel: z.string().optional().describe('Worker usage model'), + logpush: z.boolean().optional().describe('Enable or disable Workers Logpush'), + observability: observabilityInputSchema + .optional() + .describe('Workers observability configuration'), + placementMode: z.string().optional().describe('Smart Placement mode'), + message: z + .string() + .max(1000) + .optional() + .describe('Human-readable annotation for the uploaded version'), + tag: z.string().max(100).optional().describe('User-provided version tag annotation'), + bindingsInheritStrict: z + .boolean() + .optional() + .describe('When true, fail the upload if inherited bindings cannot be resolved') + }) + ) + .output( + z.object({ + scriptId: z.string().describe('Worker script identifier'), + createdOn: z.string().optional().describe('ISO 8601 creation timestamp'), + modifiedOn: z.string().optional().describe('ISO 8601 last modified timestamp'), + handlers: z + .array(z.string()) + .optional() + .describe('Event handlers defined by the Worker'), + hasModules: z.boolean().optional().describe('Whether the Worker uses ES modules'), + compatibilityDate: z.string().optional().describe('Compatibility date'), + compatibilityFlags: z.array(z.string()).optional().describe('Compatibility flags'), + lastDeployedFrom: z.string().optional().describe('Source of the latest deployment') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.uploadWorkerModule({ + scriptName: ctx.input.scriptName, + scriptContent: ctx.input.scriptContent, + syntax: ctx.input.syntax, + moduleName: ctx.input.moduleName, + contentType: ctx.input.contentType, + compatibilityDate: ctx.input.compatibilityDate, + compatibilityFlags: ctx.input.compatibilityFlags, + bindings: ctx.input.bindings, + usageModel: ctx.input.usageModel, + logpush: ctx.input.logpush, + observability: ctx.input.observability, + placementMode: ctx.input.placementMode, + message: ctx.input.message, + tag: ctx.input.tag, + bindingsInheritStrict: ctx.input.bindingsInheritStrict + }); + + return { + output: { + scriptId: result?.id || ctx.input.scriptName, + createdOn: result?.created_on, + modifiedOn: result?.modified_on, + handlers: result?.handlers, + hasModules: result?.has_modules, + compatibilityDate: result?.compatibility_date, + compatibilityFlags: result?.compatibility_flags, + lastDeployedFrom: result?.last_deployed_from + }, + message: `Uploaded Worker **${ctx.input.scriptName}**.` + }; + }) + .build(); diff --git a/integrations/coda/package.json b/integrations/coda/package.json index 57e10824d4..e439a14138 100644 --- a/integrations/coda/package.json +++ b/integrations/coda/package.json @@ -8,11 +8,12 @@ }, "dependencies": { "@types/node": "^20", - "slates": "1.0.0-rc.14", + "@lowerdeck/error": "^1.1.0", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/coda/src/auth.ts b/integrations/coda/src/auth.ts index 403ef5c9c1..8a4ae759bc 100644 --- a/integrations/coda/src/auth.ts +++ b/integrations/coda/src/auth.ts @@ -1,9 +1,9 @@ -import { createAxios, SlateAuth } from 'slates'; +import { SlateAuth } from 'slates'; import { z } from 'zod'; +import { codaApiError } from './lib/errors'; +import { createCodaHttp } from './lib/http'; -let http = createAxios({ - baseURL: 'https://coda.io/apis/v1' -}); +let http = createCodaHttp(); export let auth = SlateAuth.create() .output( @@ -31,11 +31,16 @@ export let auth = SlateAuth.create() }, getProfile: async (ctx: { output: { token: string }; input: { token: string } }) => { - let response = await http.get('/whoami', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + let response: any; + try { + response = await http.get('/whoami', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); + } catch (error) { + throw codaApiError(error, 'get profile'); + } return { profile: { diff --git a/integrations/coda/src/index.ts b/integrations/coda/src/index.ts index 911269fe04..f6a0d510a8 100644 --- a/integrations/coda/src/index.ts +++ b/integrations/coda/src/index.ts @@ -7,17 +7,28 @@ import { createPageTool, deleteDocTool, deleteFolderTool, + deletePageContentTool, deletePageTool, deleteRowsTool, + deleteRowTool, + getColumnTool, + getControlTool, getDocAnalyticsTool, getDocTool, + getFolderTool, getFormulasControlsTool, + getFormulaTool, getMutationStatusTool, getPageContentTool, + getPageTool, getRowTool, + getTableTool, + getUserInfoTool, listColumnsTool, + listDocCategoriesTool, listDocsTool, listFoldersTool, + listPageContentTool, listPagesTool, listPermissionsTool, listRowsTool, @@ -26,8 +37,10 @@ import { pushButtonTool, removePermissionTool, resolveUrlTool, + searchPrincipalsTool, triggerAutomationTool, unpublishDocTool, + updateAclSettingsTool, updateDocTool, updateFolderTool, updatePageTool, @@ -45,32 +58,45 @@ export let provider = Slate.create({ updateDocTool, deleteDocTool, listFoldersTool, + getFolderTool, createFolderTool, updateFolderTool, deleteFolderTool, listPagesTool, + getPageTool, createPageTool, updatePageTool, deletePageTool, + listPageContentTool, getPageContentTool, + deletePageContentTool, listTablesTool, + getTableTool, listColumnsTool, + getColumnTool, listRowsTool, getRowTool, upsertRowsTool, updateRowTool, + deleteRowTool, deleteRowsTool, pushButtonTool, getFormulasControlsTool, + getFormulaTool, + getControlTool, triggerAutomationTool, listPermissionsTool, addPermissionTool, removePermissionTool, + searchPrincipalsTool, + updateAclSettingsTool, publishDocTool, unpublishDocTool, + listDocCategoriesTool, resolveUrlTool, getMutationStatusTool, - getDocAnalyticsTool + getDocAnalyticsTool, + getUserInfoTool ], triggers: [rowChangesTrigger] }); diff --git a/integrations/coda/src/lib/client.ts b/integrations/coda/src/lib/client.ts index 087cb52072..ebd34448cb 100644 --- a/integrations/coda/src/lib/client.ts +++ b/integrations/coda/src/lib/client.ts @@ -1,8 +1,11 @@ -import { createAxios } from 'slates'; +import { createCodaHttp } from './http'; -let http = createAxios({ - baseURL: 'https://coda.io/apis/v1' -}); +let http = createCodaHttp(); + +type CanvasContent = { + format: 'html'; + content: string; +}; export class Client { private headers: Record; @@ -18,6 +21,7 @@ export class Client { async listDocs(params?: { isOwner?: boolean; + isPublished?: boolean; query?: string; sourceDoc?: string; isStarred?: boolean; @@ -95,7 +99,7 @@ export class Client { return response.data; } - async createFolder(body: { name: string; parentFolderId?: string }) { + async createFolder(body: { name: string; workspaceId: string; description?: string }) { let response = await http.post('/folders', body, { headers: this.headers }); @@ -106,6 +110,7 @@ export class Client { folderId: string, body: { name?: string; + description?: string; } ) { let response = await http.patch(`/folders/${folderId}`, body, { @@ -152,7 +157,10 @@ export class Client { iconName?: string; imageUrl?: string; parentPageId?: string; - pageContent?: { type: string; body: string }; + pageContent?: { + type: 'canvas'; + canvasContent: CanvasContent; + }; } ) { let response = await http.post(`/docs/${docId}/pages`, body, { @@ -170,8 +178,9 @@ export class Client { iconName?: string; imageUrl?: string; contentUpdate?: { - insertionIndex?: number; - canvasContent?: { type: string; body: string }; + insertionMode?: 'append' | 'replace' | 'before' | 'after'; + elementId?: string; + canvasContent?: CanvasContent; }; } ) { @@ -199,7 +208,8 @@ export class Client { docId: string, pageIdOrName: string, params?: { - outputFormat?: 'html' | 'markdown'; + limit?: number; + pageToken?: string; } ) { let response = await http.get( @@ -212,6 +222,23 @@ export class Client { return response.data; } + async deletePageContent( + docId: string, + pageIdOrName: string, + body?: { + elementIds?: string[]; + } + ) { + let response = await http.delete( + `/docs/${docId}/pages/${encodeURIComponent(pageIdOrName)}/content`, + { + headers: this.headers, + data: body + } + ); + return response.data; + } + async beginPageExport( docId: string, pageIdOrName: string, @@ -239,6 +266,15 @@ export class Client { return response.data; } + async downloadText(url: string) { + let response = await http.get(url, { + headers: this.headers, + responseType: 'text', + transformResponse: value => value + }); + return response.data; + } + // ── Tables ── async listTables( @@ -349,13 +385,17 @@ export class Client { body: { rows: Array<{ cells: Array<{ column: string; value: any }> }>; keyColumns?: string[]; + }, + params?: { + disableParsing?: boolean; } ) { let response = await http.post( `/docs/${docId}/tables/${encodeURIComponent(tableIdOrName)}/rows`, body, { - headers: this.headers + headers: this.headers, + params } ); return response.data; @@ -367,13 +407,17 @@ export class Client { rowIdOrName: string, body: { row: { cells: Array<{ column: string; value: any }> }; + }, + params?: { + disableParsing?: boolean; } ) { let response = await http.put( `/docs/${docId}/tables/${encodeURIComponent(tableIdOrName)}/rows/${encodeURIComponent(rowIdOrName)}`, body, { - headers: this.headers + headers: this.headers, + params } ); return response.data; @@ -502,7 +546,7 @@ export class Client { body: { access: 'readonly' | 'write' | 'comment'; principal: { type: 'email' | 'domain' | 'anyone'; email?: string; domain?: string }; - suppressNotification?: boolean; + suppressEmail?: boolean; } ) { let response = await http.post(`/docs/${docId}/acl/permissions`, body, { @@ -588,35 +632,6 @@ export class Client { return response.data; } - // ── Webhooks ── - - async listWebhooks(docId: string) { - let response = await http.get(`/docs/${docId}/webhooks`, { - headers: this.headers - }); - return response.data; - } - - async createWebhook( - docId: string, - body: { - url: string; - eventTypes: string[]; - } - ) { - let response = await http.post(`/docs/${docId}/webhooks`, body, { - headers: this.headers - }); - return response.data; - } - - async deleteWebhook(docId: string, webhookId: string) { - let response = await http.delete(`/docs/${docId}/webhooks/${webhookId}`, { - headers: this.headers - }); - return response.data; - } - // ── URL Resolution ── async resolveUrl(url: string) { diff --git a/integrations/coda/src/lib/errors.ts b/integrations/coda/src/lib/errors.ts new file mode 100644 index 0000000000..7cd4824a0a --- /dev/null +++ b/integrations/coda/src/lib/errors.ts @@ -0,0 +1,111 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.detail); + addDetail(details, value.description); + addDetail(details, value.error); + addDetail(details, value.reason); + addDetail(details, value.code); + collectDetails(value.errors, details); +}; + +let extractMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let slateData = isRecord(error) + ? (error.data as Record | undefined) + : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + collectDetails(slateData, details); + + if (details.length > 0) return details.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let statusLabelFor = (status?: number, statusText?: string) => + status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : ''; + +let getStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + let data = isRecord(error.data) ? error.data : undefined; + let upstream = isRecord(data?.upstream) ? data.upstream : undefined; + let dataStatus = typeof data?.status === 'number' ? data.status : undefined; + let upstreamStatus = typeof upstream?.status === 'number' ? upstream.status : undefined; + + return response?.status ?? dataStatus ?? upstreamStatus; +}; + +let getStatusText = (error: unknown) => { + if (!isRecord(error)) return undefined; + let response = error.response as ErrorResponse | undefined; + return response?.statusText; +}; + +let upstreamCodeFor = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + if (isRecord(response?.data) && typeof response.data.code === 'string') { + return response.data.code; + } + + let data = isRecord(error.data) ? error.data : undefined; + let upstream = isRecord(data?.upstream) ? data.upstream : undefined; + return typeof upstream?.code === 'string' ? upstream.code : undefined; +}; + +export let codaServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let codaApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let status = getStatus(error); + let serviceError = codaServiceError( + `Coda API ${operation} failed: ${statusLabelFor(status, getStatusText(error))}${extractMessage(error)}` + ); + serviceError.data.reason = 'coda_api_error'; + serviceError.data.upstreamStatus = status; + serviceError.data.upstreamCode = upstreamCodeFor(error); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/coda/src/lib/http.ts b/integrations/coda/src/lib/http.ts new file mode 100644 index 0000000000..4d1802cdd9 --- /dev/null +++ b/integrations/coda/src/lib/http.ts @@ -0,0 +1,17 @@ +import { createAxios } from 'slates'; +import { codaApiError } from './errors'; + +export const CODA_API_BASE_URL = 'https://coda.io/apis/v1'; + +export let createCodaHttp = () => { + let http = createAxios({ + baseURL: CODA_API_BASE_URL + }); + + http.interceptors.response.use( + response => response, + error => Promise.reject(codaApiError(error)) + ); + + return http; +}; diff --git a/integrations/coda/src/tools/account.ts b/integrations/coda/src/tools/account.ts new file mode 100644 index 0000000000..ce7ca2adeb --- /dev/null +++ b/integrations/coda/src/tools/account.ts @@ -0,0 +1,72 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getUserInfoTool = SlateTool.create(spec, { + name: 'Get User Info', + key: 'get_user_info', + description: `Verify the Coda API token and retrieve limited account information for the authenticated user.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + loginId: z.string().optional().describe('Login ID for the authenticated user'), + name: z.string().optional().describe('Name of the authenticated user'), + email: z.string().optional().describe('Email of the authenticated user') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let user = await client.whoami(); + + return { + output: { + loginId: user.loginId, + name: user.name, + email: user.loginId + }, + message: `Authenticated as **${user.name || user.loginId}**.` + }; + }) + .build(); + +export let listDocCategoriesTool = SlateTool.create(spec, { + name: 'List Doc Categories', + key: 'list_doc_categories', + description: `List Coda doc categories that can be used when publishing docs.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + categories: z.array( + z.object({ + name: z.string().describe('Category name'), + categoryId: z.string().optional().describe('Category ID when returned by Coda') + }) + ) + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listCategories(); + let rawCategories = result.categories || result.items || []; + let categories = rawCategories.map((category: any) => ({ + name: category.name, + categoryId: category.id + })); + + return { + output: { + categories + }, + message: `Found **${categories.length}** doc categor${categories.length === 1 ? 'y' : 'ies'}.` + }; + }) + .build(); diff --git a/integrations/coda/src/tools/create-page.ts b/integrations/coda/src/tools/create-page.ts index c8e7857154..1b62c66be9 100644 --- a/integrations/coda/src/tools/create-page.ts +++ b/integrations/coda/src/tools/create-page.ts @@ -25,8 +25,8 @@ export let createPageTool = SlateTool.create(spec, { .output( z.object({ pageId: z.string().describe('ID of the created page'), - name: z.string().describe('Name of the created page'), - browserLink: z.string().optional().describe('URL to open the page') + requestId: z.string().describe('ID to track the asynchronous page creation'), + name: z.string().describe('Requested name of the created page') }) ) .handleInvocation(async ctx => { @@ -43,7 +43,10 @@ export let createPageTool = SlateTool.create(spec, { if (ctx.input.content) { body.pageContent = { type: 'canvas', - body: ctx.input.content + canvasContent: { + format: 'html', + content: ctx.input.content + } }; } @@ -52,10 +55,10 @@ export let createPageTool = SlateTool.create(spec, { return { output: { pageId: page.id, - name: page.name, - browserLink: page.browserLink + requestId: page.requestId, + name: ctx.input.name }, - message: `Created page **${page.name}** in doc **${ctx.input.docId}**.` + message: `Queued creation of page **${ctx.input.name}** in doc **${ctx.input.docId}**. Request ID: ${page.requestId}` }; }) .build(); diff --git a/integrations/coda/src/tools/delete-page.ts b/integrations/coda/src/tools/delete-page.ts index 180e032128..1b09d3c823 100644 --- a/integrations/coda/src/tools/delete-page.ts +++ b/integrations/coda/src/tools/delete-page.ts @@ -19,19 +19,23 @@ export let deletePageTool = SlateTool.create(spec, { ) .output( z.object({ - deleted: z.boolean().describe('Whether the page was successfully deleted') + requestId: z.string().describe('ID to track the asynchronous page deletion'), + pageId: z.string().describe('ID of the page queued for deletion'), + deleted: z.boolean().describe('Whether the delete was successfully queued') }) ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); - await client.deletePage(ctx.input.docId, ctx.input.pageIdOrName); + let result = await client.deletePage(ctx.input.docId, ctx.input.pageIdOrName); return { output: { + requestId: result.requestId, + pageId: result.id, deleted: true }, - message: `Deleted page **${ctx.input.pageIdOrName}** from doc **${ctx.input.docId}**.` + message: `Queued deletion of page **${ctx.input.pageIdOrName}** from doc **${ctx.input.docId}**. Request ID: ${result.requestId}` }; }) .build(); diff --git a/integrations/coda/src/tools/delete-rows.ts b/integrations/coda/src/tools/delete-rows.ts index 876dc1bc8d..bfdbdb4877 100644 --- a/integrations/coda/src/tools/delete-rows.ts +++ b/integrations/coda/src/tools/delete-rows.ts @@ -3,6 +3,45 @@ import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; +export let deleteRowTool = SlateTool.create(spec, { + name: 'Delete Row', + key: 'delete_row', + description: `Delete a single row from a Coda table or view by row ID or name. This action cannot be undone.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + tableIdOrName: z.string().describe('ID or name of the table'), + rowIdOrName: z.string().describe('ID or name of the row to delete') + }) + ) + .output( + z.object({ + requestId: z.string().describe('ID to track the asynchronous deletion status'), + rowId: z.string().describe('ID of the deleted row') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.deleteRow( + ctx.input.docId, + ctx.input.tableIdOrName, + ctx.input.rowIdOrName + ); + + return { + output: { + requestId: result.requestId, + rowId: result.id + }, + message: `Deleted row **${ctx.input.rowIdOrName}** from table **${ctx.input.tableIdOrName}**.` + }; + }) + .build(); + export let deleteRowsTool = SlateTool.create(spec, { name: 'Delete Rows', key: 'delete_rows', diff --git a/integrations/coda/src/tools/get-column.ts b/integrations/coda/src/tools/get-column.ts new file mode 100644 index 0000000000..972961f05b --- /dev/null +++ b/integrations/coda/src/tools/get-column.ts @@ -0,0 +1,49 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getColumnTool = SlateTool.create(spec, { + name: 'Get Column', + key: 'get_column', + description: `Retrieve metadata for a column in a Coda table or view, including display name, format type, calculation status, and parent table.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + tableIdOrName: z.string().describe('ID or name of the table or view'), + columnIdOrName: z.string().describe('ID or name of the column to retrieve') + }) + ) + .output( + z.object({ + columnId: z.string().describe('ID of the column'), + name: z.string().describe('Name of the column'), + columnType: z.string().optional().describe('Coda column format type'), + calculated: z.boolean().optional().describe('Whether the column is calculated'), + parentTableId: z.string().optional().describe('ID of the parent table') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let column = await client.getColumn( + ctx.input.docId, + ctx.input.tableIdOrName, + ctx.input.columnIdOrName + ); + + return { + output: { + columnId: column.id, + name: column.name, + columnType: column.format?.type, + calculated: column.calculated, + parentTableId: column.parent?.id + }, + message: `Retrieved column **${column.name}** (${column.id}).` + }; + }) + .build(); diff --git a/integrations/coda/src/tools/get-doc.ts b/integrations/coda/src/tools/get-doc.ts index 94d3484053..78230b59eb 100644 --- a/integrations/coda/src/tools/get-doc.ts +++ b/integrations/coda/src/tools/get-doc.ts @@ -38,7 +38,7 @@ export let getDocTool = SlateTool.create(spec, { output: { docId: doc.id, name: doc.name, - ownerName: doc.owner, + ownerName: doc.ownerName ?? doc.owner, createdAt: doc.createdAt, updatedAt: doc.updatedAt, workspaceId: doc.workspace?.id, diff --git a/integrations/coda/src/tools/get-formulas-controls.ts b/integrations/coda/src/tools/get-formulas-controls.ts index 495d8ef9de..64bc9a15a2 100644 --- a/integrations/coda/src/tools/get-formulas-controls.ts +++ b/integrations/coda/src/tools/get-formulas-controls.ts @@ -6,7 +6,7 @@ import { spec } from '../spec'; export let getFormulasControlsTool = SlateTool.create(spec, { name: 'Get Formulas and Controls', key: 'get_formulas_controls', - description: `Retrieve named formulas and interactive controls (sliders, checkboxes, select boxes, etc.) from a Coda doc, including their current computed values.`, + description: `List named formulas and interactive controls (sliders, checkboxes, select boxes, etc.) from a Coda doc. Use get_formula or get_control for current computed values.`, tags: { readOnly: true } @@ -33,7 +33,7 @@ export let getFormulasControlsTool = SlateTool.create(spec, { z.object({ formulaId: z.string().describe('ID of the formula'), name: z.string().describe('Name of the formula'), - value: z.any().optional().describe('Current computed value of the formula') + parentPageId: z.string().optional().describe('ID of the parent page') }) ) .optional(), @@ -46,7 +46,7 @@ export let getFormulasControlsTool = SlateTool.create(spec, { .string() .optional() .describe('Type of control (e.g. slider, checkbox)'), - value: z.any().optional().describe('Current value of the control') + parentPageId: z.string().optional().describe('ID of the parent page') }) ) .optional() @@ -63,7 +63,7 @@ export let getFormulasControlsTool = SlateTool.create(spec, { formulas = (formulaResult.items || []).map((f: any) => ({ formulaId: f.id, name: f.name, - value: f.value + parentPageId: f.parent?.id })); } @@ -73,7 +73,7 @@ export let getFormulasControlsTool = SlateTool.create(spec, { controlId: c.id, name: c.name, controlType: c.controlType, - value: c.value + parentPageId: c.parent?.id })); } @@ -90,3 +90,81 @@ export let getFormulasControlsTool = SlateTool.create(spec, { }; }) .build(); + +export let getFormulaTool = SlateTool.create(spec, { + name: 'Get Formula', + key: 'get_formula', + description: `Retrieve a Coda formula and its current computed value.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + formulaIdOrName: z.string().describe('ID or name of the formula to retrieve') + }) + ) + .output( + z.object({ + formulaId: z.string().describe('ID of the formula'), + name: z.string().describe('Name of the formula'), + value: z.any().optional().describe('Current computed value of the formula'), + parentPageId: z.string().optional().describe('ID of the parent page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let formula = await client.getFormula(ctx.input.docId, ctx.input.formulaIdOrName); + + return { + output: { + formulaId: formula.id, + name: formula.name, + value: formula.value, + parentPageId: formula.parent?.id + }, + message: `Retrieved formula **${formula.name}** (${formula.id}).` + }; + }) + .build(); + +export let getControlTool = SlateTool.create(spec, { + name: 'Get Control', + key: 'get_control', + description: `Retrieve a Coda control and its current value.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + controlIdOrName: z.string().describe('ID or name of the control to retrieve') + }) + ) + .output( + z.object({ + controlId: z.string().describe('ID of the control'), + name: z.string().describe('Name of the control'), + controlType: z.string().optional().describe('Type of control'), + value: z.any().optional().describe('Current value of the control'), + parentPageId: z.string().optional().describe('ID of the parent page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let control = await client.getControl(ctx.input.docId, ctx.input.controlIdOrName); + + return { + output: { + controlId: control.id, + name: control.name, + controlType: control.controlType, + value: control.value, + parentPageId: control.parent?.id + }, + message: `Retrieved control **${control.name}** (${control.id}).` + }; + }) + .build(); diff --git a/integrations/coda/src/tools/get-page-content.ts b/integrations/coda/src/tools/get-page-content.ts index 441f13927d..663f799fc8 100644 --- a/integrations/coda/src/tools/get-page-content.ts +++ b/integrations/coda/src/tools/get-page-content.ts @@ -1,12 +1,71 @@ -import { SlateTool } from 'slates'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { codaServiceError } from '../lib/errors'; import { spec } from '../spec'; +let mimeTypeFor = (format: 'html' | 'markdown') => + format === 'html' ? 'text/html' : 'text/markdown'; + +export let listPageContentTool = SlateTool.create(spec, { + name: 'List Page Content', + key: 'list_page_content', + description: `List structured content elements on a Coda page. Use this to inspect element IDs before deleting or replacing page content.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + pageIdOrName: z.string().describe('ID or name of the page'), + limit: z.number().optional().describe('Maximum number of page content elements'), + pageToken: z.string().optional().describe('Token for fetching the next page') + }) + ) + .output( + z.object({ + elements: z.array( + z.object({ + elementId: z.string().describe('ID of the content element'), + type: z.string().optional().describe('Type of content element'), + format: z.string().optional().describe('Format of the element content'), + content: z.string().optional().describe('Text content when returned by Coda'), + lineLevel: z.number().optional().describe('Line indentation level when applicable') + }) + ), + nextPageToken: z.string().optional().describe('Token for fetching the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.getPageContent(ctx.input.docId, ctx.input.pageIdOrName, { + limit: ctx.input.limit, + pageToken: ctx.input.pageToken + }); + + let elements = (result.items || []).map((element: any) => ({ + elementId: element.id, + type: element.type, + format: element.itemContent?.format, + content: element.itemContent?.content, + lineLevel: element.itemContent?.lineLevel + })); + + return { + output: { + elements, + nextPageToken: result.nextPageToken + }, + message: `Found **${elements.length}** content element(s) on page **${ctx.input.pageIdOrName}**.` + }; + }) + .build(); + export let getPageContentTool = SlateTool.create(spec, { - name: 'Get Page Content', + name: 'Export Page Content', key: 'get_page_content', - description: `Retrieve the content of a specific page in a Coda doc. Supports exporting as HTML or Markdown format.`, + description: `Export a Coda page as HTML or Markdown. The exported file content is returned as a Slate text attachment.`, tags: { readOnly: true } @@ -19,15 +78,27 @@ export let getPageContentTool = SlateTool.create(spec, { .enum(['html', 'markdown']) .optional() .default('html') - .describe('Output format for page content') + .describe('Output format for exported page content'), + maxPollAttempts: z + .number() + .optional() + .default(10) + .describe('Maximum number of export-status polling attempts'), + pollIntervalMs: z + .number() + .optional() + .default(1000) + .describe('Delay between export-status polling attempts in milliseconds') }) ) .output( z.object({ pageId: z.string().describe('ID of the page'), pageName: z.string().describe('Name of the page'), - content: z.string().describe('Content of the page in the requested format'), - contentFormat: z.string().describe('Format of the returned content (html or markdown)'), + exportRequestId: z.string().describe('ID of the Coda export request'), + status: z.string().describe('Final export status returned by Coda'), + contentFormat: z.string().describe('Format of the attached content'), + attachmentCount: z.number().describe('Number of returned attachments'), browserLink: z.string().optional().describe('URL to open the page') }) ) @@ -35,44 +106,94 @@ export let getPageContentTool = SlateTool.create(spec, { let client = new Client({ token: ctx.auth.token }); let page = await client.getPage(ctx.input.docId, ctx.input.pageIdOrName); - - let contentResult = await client.beginPageExport(ctx.input.docId, ctx.input.pageIdOrName, { + let exportResult = await client.beginPageExport(ctx.input.docId, ctx.input.pageIdOrName, { outputFormat: ctx.input.outputFormat }); - let exportRequestId = contentResult.id; - let exportResult: any = contentResult; + let exportRequestId = exportResult.id; + for (let attempt = 0; attempt < ctx.input.maxPollAttempts; attempt++) { + if (exportResult.status !== 'inProgress' && exportResult.status !== 'pending') { + break; + } - let maxAttempts = 10; - let attempt = 0; - while ( - attempt < maxAttempts && - (!exportResult.status || exportResult.status === 'inProgress') - ) { - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise(resolve => setTimeout(resolve, ctx.input.pollIntervalMs)); exportResult = await client.getPageExportStatus( ctx.input.docId, ctx.input.pageIdOrName, exportRequestId ); - attempt++; } - let content = exportResult.downloadLink || ''; - if (exportResult.downloadLink) { - // The API returns a download link; we return it as-is - content = exportResult.downloadLink; + if (exportResult.status === 'failed') { + throw codaServiceError( + `Coda page export failed: ${exportResult.error || 'unknown export error'}` + ); + } + + if (!exportResult.downloadLink) { + throw codaServiceError( + `Coda page export ${exportRequestId} did not complete within ${ctx.input.maxPollAttempts} poll attempt(s).` + ); } + let content = await client.downloadText(exportResult.downloadLink); + return { output: { pageId: page.id, pageName: page.name, - content, + exportRequestId, + status: exportResult.status, contentFormat: ctx.input.outputFormat, + attachmentCount: 1, browserLink: page.browserLink }, - message: `Retrieved **${ctx.input.outputFormat}** content for page **${page.name}**.` + attachments: [createTextAttachment(content, mimeTypeFor(ctx.input.outputFormat))], + message: `Exported **${ctx.input.outputFormat}** content for page **${page.name}**.` + }; + }) + .build(); + +export let deletePageContentTool = SlateTool.create(spec, { + name: 'Delete Page Content', + key: 'delete_page_content', + description: `Delete specific content elements from a Coda page by ID, or delete all page content when no element IDs are supplied.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + pageIdOrName: z.string().describe('ID or name of the page'), + elementIds: z + .array(z.string()) + .optional() + .describe( + 'IDs of page content elements to delete. Omit or pass an empty array to delete all content.' + ) + }) + ) + .output( + z.object({ + requestId: z.string().describe('ID to track the asynchronous content deletion'), + pageId: z.string().describe('ID of the page whose content deletion was queued'), + deleted: z.boolean().describe('Whether the delete was successfully queued') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.deletePageContent(ctx.input.docId, ctx.input.pageIdOrName, { + elementIds: ctx.input.elementIds + }); + + return { + output: { + requestId: result.requestId, + pageId: result.id, + deleted: true + }, + message: `Queued deletion of page content on **${ctx.input.pageIdOrName}**. Request ID: ${result.requestId}` }; }) .build(); diff --git a/integrations/coda/src/tools/get-page.ts b/integrations/coda/src/tools/get-page.ts new file mode 100644 index 0000000000..a4a1b1fb66 --- /dev/null +++ b/integrations/coda/src/tools/get-page.ts @@ -0,0 +1,63 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getPageTool = SlateTool.create(spec, { + name: 'Get Page', + key: 'get_page', + description: `Retrieve metadata for a Coda page, including visibility, parent/child page relationships, content type, timestamps, and browser link.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + pageIdOrName: z.string().describe('ID or name of the page to retrieve') + }) + ) + .output( + z.object({ + pageId: z.string().describe('ID of the page'), + name: z.string().describe('Name of the page'), + subtitle: z.string().optional().describe('Subtitle of the page'), + iconName: z.string().optional().describe('Icon name for the page'), + imageUrl: z.string().optional().describe('Cover image URL for the page'), + contentType: z.string().optional().describe('Type of page content'), + isHidden: z.boolean().optional().describe('Whether the page is hidden'), + isEffectivelyHidden: z + .boolean() + .optional() + .describe('Whether the page or any parent is hidden'), + parentPageId: z.string().optional().describe('ID of the parent page'), + childPageIds: z.array(z.string()).describe('IDs of child pages'), + createdAt: z.string().optional().describe('Timestamp when the page was created'), + updatedAt: z.string().optional().describe('Timestamp when the page was updated'), + browserLink: z.string().optional().describe('URL to open the page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let page = await client.getPage(ctx.input.docId, ctx.input.pageIdOrName); + + return { + output: { + pageId: page.id, + name: page.name, + subtitle: page.subtitle, + iconName: page.icon?.name, + imageUrl: page.image?.browserLink, + contentType: page.contentType, + isHidden: page.isHidden, + isEffectivelyHidden: page.isEffectivelyHidden, + parentPageId: page.parent?.id, + childPageIds: (page.children || []).map((child: any) => child.id), + createdAt: page.createdAt, + updatedAt: page.updatedAt, + browserLink: page.browserLink + }, + message: `Retrieved page **${page.name}** (${page.id}).` + }; + }) + .build(); diff --git a/integrations/coda/src/tools/get-table.ts b/integrations/coda/src/tools/get-table.ts new file mode 100644 index 0000000000..6961577d90 --- /dev/null +++ b/integrations/coda/src/tools/get-table.ts @@ -0,0 +1,52 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getTableTool = SlateTool.create(spec, { + name: 'Get Table', + key: 'get_table', + description: `Retrieve metadata for a table or view in a Coda doc, including table type, row count, layout, display column, parent page, and view parent table.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + tableIdOrName: z.string().describe('ID or name of the table or view to retrieve') + }) + ) + .output( + z.object({ + tableId: z.string().describe('ID of the table or view'), + name: z.string().describe('Name of the table or view'), + tableType: z.string().optional().describe('Type of table, such as table or view'), + rowCount: z.number().optional().describe('Number of rows in the table or view'), + layout: z.string().optional().describe('Table layout'), + parentPageId: z.string().optional().describe('ID of the parent page'), + parentTableId: z.string().optional().describe('ID of the base table for a view'), + displayColumnId: z.string().optional().describe('ID of the display column'), + browserLink: z.string().optional().describe('URL to open the table or view') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let table = await client.getTable(ctx.input.docId, ctx.input.tableIdOrName); + + return { + output: { + tableId: table.id, + name: table.name, + tableType: table.tableType, + rowCount: table.rowCount, + layout: table.layout, + parentPageId: table.parent?.id, + parentTableId: table.parentTable?.id, + displayColumnId: table.displayColumn?.id, + browserLink: table.browserLink + }, + message: `Retrieved table **${table.name}** (${table.id}).` + }; + }) + .build(); diff --git a/integrations/coda/src/tools/index.ts b/integrations/coda/src/tools/index.ts index 6370df3952..5e65f1dc16 100644 --- a/integrations/coda/src/tools/index.ts +++ b/integrations/coda/src/tools/index.ts @@ -1,14 +1,18 @@ +export * from './account'; export * from './create-doc'; export * from './create-page'; export * from './delete-doc'; export * from './delete-page'; export * from './delete-rows'; +export * from './get-column'; export * from './get-doc'; export * from './get-doc-analytics'; export * from './get-formulas-controls'; export * from './get-mutation-status'; +export * from './get-page'; export * from './get-page-content'; export * from './get-row'; +export * from './get-table'; export * from './list-columns'; export * from './list-docs'; export * from './list-pages'; diff --git a/integrations/coda/src/tools/list-docs.ts b/integrations/coda/src/tools/list-docs.ts index 201b9a4971..6a6d8e6146 100644 --- a/integrations/coda/src/tools/list-docs.ts +++ b/integrations/coda/src/tools/list-docs.ts @@ -15,7 +15,13 @@ export let listDocsTool = SlateTool.create(spec, { z.object({ query: z.string().optional().describe('Search query to filter docs by name'), isOwner: z.boolean().optional().describe('Show only docs owned by the API token owner'), + isPublished: z.boolean().optional().describe('Show only published docs'), isStarred: z.boolean().optional().describe('Show only starred docs'), + inGallery: z.boolean().optional().describe('Show only docs visible in the gallery'), + sourceDocId: z + .string() + .optional() + .describe('Show only docs copied from this source doc ID'), workspaceId: z.string().optional().describe('Filter docs to a specific workspace'), folderId: z.string().optional().describe('Filter docs to a specific folder'), limit: z.number().optional().describe('Maximum number of docs to return (default 25)'), @@ -48,7 +54,10 @@ export let listDocsTool = SlateTool.create(spec, { let result = await client.listDocs({ query: ctx.input.query, isOwner: ctx.input.isOwner, + isPublished: ctx.input.isPublished, isStarred: ctx.input.isStarred, + inGallery: ctx.input.inGallery, + sourceDoc: ctx.input.sourceDocId, workspaceId: ctx.input.workspaceId, folderId: ctx.input.folderId, limit: ctx.input.limit, @@ -58,7 +67,7 @@ export let listDocsTool = SlateTool.create(spec, { let docs = (result.items || []).map((doc: any) => ({ docId: doc.id, name: doc.name, - ownerName: doc.owner, + ownerName: doc.ownerName ?? doc.owner, createdAt: doc.createdAt, updatedAt: doc.updatedAt, workspaceId: doc.workspace?.id, diff --git a/integrations/coda/src/tools/manage-folders.ts b/integrations/coda/src/tools/manage-folders.ts index 45ab052b5c..3e9365b27b 100644 --- a/integrations/coda/src/tools/manage-folders.ts +++ b/integrations/coda/src/tools/manage-folders.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { codaServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listFoldersTool = SlateTool.create(spec, { @@ -26,6 +27,7 @@ export let listFoldersTool = SlateTool.create(spec, { folderId: z.string().describe('ID of the folder'), name: z.string().describe('Name of the folder'), workspaceId: z.string().optional().describe('ID of the containing workspace'), + description: z.string().optional().describe('Folder description'), browserLink: z.string().optional().describe('URL to open the folder') }) ), @@ -46,6 +48,7 @@ export let listFoldersTool = SlateTool.create(spec, { folderId: folder.id, name: folder.name, workspaceId: folder.workspace?.id, + description: folder.description, browserLink: folder.browserLink })); @@ -59,10 +62,53 @@ export let listFoldersTool = SlateTool.create(spec, { }) .build(); +export let getFolderTool = SlateTool.create(spec, { + name: 'Get Folder', + key: 'get_folder', + description: `Retrieve details for a Coda folder, including workspace, description, editability, and browser link.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + folderId: z.string().describe('ID of the folder to retrieve') + }) + ) + .output( + z.object({ + folderId: z.string().describe('ID of the folder'), + name: z.string().describe('Name of the folder'), + workspaceId: z.string().optional().describe('ID of the containing workspace'), + workspaceName: z.string().optional().describe('Name of the containing workspace'), + description: z.string().optional().describe('Folder description'), + canEdit: z.boolean().optional().describe('Whether folder settings can be edited'), + browserLink: z.string().optional().describe('URL to open the folder') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let folder = await client.getFolder(ctx.input.folderId); + + return { + output: { + folderId: folder.id, + name: folder.name, + workspaceId: folder.workspace?.id, + workspaceName: folder.workspace?.name, + description: folder.description, + canEdit: folder.canEdit, + browserLink: folder.browserLink + }, + message: `Retrieved folder **${folder.name}** (${folder.id}).` + }; + }) + .build(); + export let createFolderTool = SlateTool.create(spec, { name: 'Create Folder', key: 'create_folder', - description: `Create a new folder in a workspace, optionally nested under a parent folder.`, + description: `Create a new folder in a Coda workspace.`, tags: { destructive: false } @@ -70,13 +116,18 @@ export let createFolderTool = SlateTool.create(spec, { .input( z.object({ name: z.string().describe('Name for the new folder'), - parentFolderId: z.string().optional().describe('ID of the parent folder for nesting') + workspaceId: z + .string() + .describe('ID of the workspace where the folder should be created'), + description: z.string().optional().describe('Description for the folder') }) ) .output( z.object({ folderId: z.string().describe('ID of the created folder'), name: z.string().describe('Name of the created folder'), + workspaceId: z.string().optional().describe('ID of the containing workspace'), + description: z.string().optional().describe('Folder description'), browserLink: z.string().optional().describe('URL to open the folder') }) ) @@ -85,13 +136,16 @@ export let createFolderTool = SlateTool.create(spec, { let folder = await client.createFolder({ name: ctx.input.name, - parentFolderId: ctx.input.parentFolderId + workspaceId: ctx.input.workspaceId, + description: ctx.input.description }); return { output: { folderId: folder.id, name: folder.name, + workspaceId: folder.workspace?.id, + description: folder.description, browserLink: folder.browserLink }, message: `Created folder **${folder.name}**.` @@ -102,7 +156,7 @@ export let createFolderTool = SlateTool.create(spec, { export let updateFolderTool = SlateTool.create(spec, { name: 'Update Folder', key: 'update_folder', - description: `Rename an existing folder.`, + description: `Update an existing folder name or description.`, tags: { destructive: false } @@ -110,28 +164,36 @@ export let updateFolderTool = SlateTool.create(spec, { .input( z.object({ folderId: z.string().describe('ID of the folder to update'), - name: z.string().describe('New name for the folder') + name: z.string().optional().describe('New name for the folder'), + description: z.string().optional().describe('New description for the folder') }) ) .output( z.object({ folderId: z.string().describe('ID of the updated folder'), - name: z.string().describe('Updated name of the folder') + name: z.string().optional().describe('Updated name of the folder'), + description: z.string().optional().describe('Updated description of the folder') }) ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); + if (ctx.input.name === undefined && ctx.input.description === undefined) { + throw codaServiceError('Provide name or description to update the folder.'); + } + await client.updateFolder(ctx.input.folderId, { - name: ctx.input.name + name: ctx.input.name, + description: ctx.input.description }); return { output: { folderId: ctx.input.folderId, - name: ctx.input.name + name: ctx.input.name, + description: ctx.input.description }, - message: `Updated folder **${ctx.input.folderId}** to **${ctx.input.name}**.` + message: `Updated folder **${ctx.input.folderId}**.` }; }) .build(); diff --git a/integrations/coda/src/tools/manage-permissions.ts b/integrations/coda/src/tools/manage-permissions.ts index b54647e948..8129b65944 100644 --- a/integrations/coda/src/tools/manage-permissions.ts +++ b/integrations/coda/src/tools/manage-permissions.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { codaServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listPermissionsTool = SlateTool.create(spec, { @@ -13,7 +14,9 @@ export let listPermissionsTool = SlateTool.create(spec, { }) .input( z.object({ - docId: z.string().describe('ID of the doc') + docId: z.string().describe('ID of the doc'), + limit: z.number().optional().describe('Maximum number of permissions to return'), + pageToken: z.string().optional().describe('Token for fetching the next page') }) ) .output( @@ -33,6 +36,7 @@ export let listPermissionsTool = SlateTool.create(spec, { .describe('Access level (readonly, write, comment)') }) ), + nextPageToken: z.string().optional().describe('Token for fetching the next page'), aclSettings: z .object({ allowEditorsToChangePermissions: z.boolean().optional(), @@ -45,7 +49,10 @@ export let listPermissionsTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); - let permResult = await client.listPermissions(ctx.input.docId); + let permResult = await client.listPermissions(ctx.input.docId, { + limit: ctx.input.limit, + pageToken: ctx.input.pageToken + }); let settingsResult = await client.getAclSettings(ctx.input.docId); let permissions = (permResult.items || []).map((p: any) => ({ @@ -59,6 +66,7 @@ export let listPermissionsTool = SlateTool.create(spec, { return { output: { permissions, + nextPageToken: permResult.nextPageToken, aclSettings: { allowEditorsToChangePermissions: settingsResult.allowEditorsToChangePermissions, allowViewersToCopyDoc: settingsResult.allowViewersToCopyDoc, @@ -93,10 +101,14 @@ export let addPermissionTool = SlateTool.create(spec, { .string() .optional() .describe('Domain name (required when principalType is "domain")'), + suppressEmail: z + .boolean() + .optional() + .describe('Suppress email notification to the principal'), suppressNotification: z .boolean() .optional() - .describe('Suppress email notification to the principal') + .describe('Deprecated alias for suppressEmail') }) ) .output( @@ -109,13 +121,23 @@ export let addPermissionTool = SlateTool.create(spec, { let client = new Client({ token: ctx.auth.token }); let principal: any = { type: ctx.input.principalType }; - if (ctx.input.principalType === 'email') principal.email = ctx.input.principalEmail; - if (ctx.input.principalType === 'domain') principal.domain = ctx.input.principalDomain; + if (ctx.input.principalType === 'email') { + if (!ctx.input.principalEmail) { + throw codaServiceError('principalEmail is required when principalType is "email".'); + } + principal.email = ctx.input.principalEmail; + } + if (ctx.input.principalType === 'domain') { + if (!ctx.input.principalDomain) { + throw codaServiceError('principalDomain is required when principalType is "domain".'); + } + principal.domain = ctx.input.principalDomain; + } await client.addPermission(ctx.input.docId, { access: ctx.input.accessLevel, principal, - suppressNotification: ctx.input.suppressNotification + suppressEmail: ctx.input.suppressEmail ?? ctx.input.suppressNotification }); return { @@ -127,6 +149,118 @@ export let addPermissionTool = SlateTool.create(spec, { }) .build(); +export let searchPrincipalsTool = SlateTool.create(spec, { + name: 'Search Principals', + key: 'search_principals', + description: `Search Coda users and groups that can be shared on a doc. Useful before granting doc permissions.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + query: z.string().describe('Search term used to find users or groups') + }) + ) + .output( + z.object({ + users: z.array( + z.object({ + name: z.string().optional().describe('User name'), + email: z.string().optional().describe('User email') + }) + ), + groups: z.array( + z.object({ + groupId: z.string().optional().describe('Group ID'), + name: z.string().optional().describe('Group name'), + type: z.string().optional().describe('Principal type') + }) + ) + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.searchPrincipals(ctx.input.docId, { + query: ctx.input.query + }); + + let users = (result.users || []).map((user: any) => ({ + name: user.name, + email: user.email + })); + let groups = (result.groups || []).map((group: any) => ({ + groupId: group.id, + name: group.name, + type: group.type + })); + + return { + output: { + users, + groups + }, + message: `Found **${users.length}** user(s) and **${groups.length}** group(s).` + }; + }) + .build(); + +export let updateAclSettingsTool = SlateTool.create(spec, { + name: 'Update ACL Settings', + key: 'update_acl_settings', + description: `Update Coda doc sharing settings such as whether editors can change permissions and whether viewers can copy or request editing access.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + docId: z.string().describe('ID of the doc'), + allowEditorsToChangePermissions: z + .boolean() + .optional() + .describe('Whether editors can change permissions'), + allowViewersToCopyDoc: z + .boolean() + .optional() + .describe('Whether viewers can copy the doc'), + allowViewersToRequestEditing: z + .boolean() + .optional() + .describe('Whether viewers can request edit access') + }) + ) + .output( + z.object({ + updated: z.boolean().describe('Whether ACL settings were updated') + }) + ) + .handleInvocation(async ctx => { + if ( + ctx.input.allowEditorsToChangePermissions === undefined && + ctx.input.allowViewersToCopyDoc === undefined && + ctx.input.allowViewersToRequestEditing === undefined + ) { + throw codaServiceError('Provide at least one ACL setting to update.'); + } + + let client = new Client({ token: ctx.auth.token }); + await client.updateAclSettings(ctx.input.docId, { + allowEditorsToChangePermissions: ctx.input.allowEditorsToChangePermissions, + allowViewersToCopyDoc: ctx.input.allowViewersToCopyDoc, + allowViewersToRequestEditing: ctx.input.allowViewersToRequestEditing + }); + + return { + output: { + updated: true + }, + message: `Updated ACL settings for doc **${ctx.input.docId}**.` + }; + }) + .build(); + export let removePermissionTool = SlateTool.create(spec, { name: 'Remove Permission', key: 'remove_permission', diff --git a/integrations/coda/src/tools/publish-doc.ts b/integrations/coda/src/tools/publish-doc.ts index dba0e950b4..9d08357a97 100644 --- a/integrations/coda/src/tools/publish-doc.ts +++ b/integrations/coda/src/tools/publish-doc.ts @@ -23,7 +23,8 @@ export let publishDocTool = SlateTool.create(spec, { categoryNames: z .array(z.string()) .optional() - .describe('Categories to assign to the published doc') + .describe('Categories to assign to the published doc'), + earnCredit: z.boolean().optional().describe('Whether to show author credit') }) ) .output( @@ -38,7 +39,8 @@ export let publishDocTool = SlateTool.create(spec, { slug: ctx.input.slug, discoverable: ctx.input.discoverable, mode: ctx.input.mode, - categoryNames: ctx.input.categoryNames + categoryNames: ctx.input.categoryNames, + earnCredit: ctx.input.earnCredit }); return { diff --git a/integrations/coda/src/tools/update-page.ts b/integrations/coda/src/tools/update-page.ts index 8f3f45eb47..9684382529 100644 --- a/integrations/coda/src/tools/update-page.ts +++ b/integrations/coda/src/tools/update-page.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { codaServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updatePageTool = SlateTool.create(spec, { @@ -19,30 +20,66 @@ export let updatePageTool = SlateTool.create(spec, { subtitle: z.string().optional().describe('New subtitle for the page'), iconName: z.string().optional().describe('New icon name for the page'), imageUrl: z.string().optional().describe('New cover image URL'), + isHidden: z.boolean().optional().describe('Whether the page should be hidden'), + insertionMode: z + .enum(['append', 'replace', 'before', 'after']) + .optional() + .default('append') + .describe('How to apply contentToAppend when provided'), + elementId: z + .string() + .optional() + .describe('Page content element ID for replace, before, or after insertion modes'), contentToAppend: z.string().optional().describe('HTML content to append to the page') }) ) .output( z.object({ pageId: z.string().describe('ID of the updated page'), - name: z.string().describe('Updated name of the page') + requestId: z.string().describe('ID to track the asynchronous page update'), + name: z.string().optional().describe('Requested updated name of the page') }) ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); + if ( + ctx.input.name === undefined && + ctx.input.subtitle === undefined && + ctx.input.iconName === undefined && + ctx.input.imageUrl === undefined && + ctx.input.isHidden === undefined && + !ctx.input.contentToAppend + ) { + throw codaServiceError('Provide at least one page property or contentToAppend.'); + } + + if ( + ctx.input.contentToAppend && + ctx.input.insertionMode && + ctx.input.insertionMode !== 'append' && + !ctx.input.elementId + ) { + throw codaServiceError( + 'elementId is required when insertionMode is replace, before, or after.' + ); + } + let body: any = {}; if (ctx.input.name !== undefined) body.name = ctx.input.name; if (ctx.input.subtitle !== undefined) body.subtitle = ctx.input.subtitle; if (ctx.input.iconName !== undefined) body.iconName = ctx.input.iconName; if (ctx.input.imageUrl !== undefined) body.imageUrl = ctx.input.imageUrl; + if (ctx.input.isHidden !== undefined) body.isHidden = ctx.input.isHidden; if (ctx.input.contentToAppend) { body.contentUpdate = { + insertionMode: ctx.input.insertionMode, + elementId: ctx.input.elementId, canvasContent: { - type: 'canvas', - body: ctx.input.contentToAppend + format: 'html', + content: ctx.input.contentToAppend } }; } @@ -52,9 +89,10 @@ export let updatePageTool = SlateTool.create(spec, { return { output: { pageId: page.id, - name: page.name || ctx.input.name || ctx.input.pageIdOrName + requestId: page.requestId, + name: ctx.input.name }, - message: `Updated page **${ctx.input.pageIdOrName}** in doc **${ctx.input.docId}**.` + message: `Queued update for page **${ctx.input.pageIdOrName}** in doc **${ctx.input.docId}**. Request ID: ${page.requestId}` }; }) .build(); diff --git a/integrations/coda/src/tools/update-row.ts b/integrations/coda/src/tools/update-row.ts index e0169d34c1..0890ac5153 100644 --- a/integrations/coda/src/tools/update-row.ts +++ b/integrations/coda/src/tools/update-row.ts @@ -24,7 +24,11 @@ export let updateRowTool = SlateTool.create(spec, { value: z.any().describe('New value for the cell') }) ) - .describe('Cell values to update') + .describe('Cell values to update'), + disableParsing: z + .boolean() + .optional() + .describe('If true, preserve values exactly instead of letting Coda parse strings') }) ) .output( @@ -42,6 +46,9 @@ export let updateRowTool = SlateTool.create(spec, { ctx.input.rowIdOrName, { row: { cells: ctx.input.cells } + }, + { + disableParsing: ctx.input.disableParsing } ); diff --git a/integrations/coda/src/tools/upsert-rows.ts b/integrations/coda/src/tools/upsert-rows.ts index 45be3a385f..a3d5e78252 100644 --- a/integrations/coda/src/tools/upsert-rows.ts +++ b/integrations/coda/src/tools/upsert-rows.ts @@ -41,7 +41,11 @@ export let upsertRowsTool = SlateTool.create(spec, { keyColumns: z .array(z.string()) .optional() - .describe('Column IDs or names to match on for upsert behavior') + .describe('Column IDs or names to match on for upsert behavior'), + disableParsing: z + .boolean() + .optional() + .describe('If true, preserve values exactly instead of letting Coda parse strings') }) ) .output( @@ -53,10 +57,17 @@ export let upsertRowsTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); - let result = await client.upsertRows(ctx.input.docId, ctx.input.tableIdOrName, { - rows: ctx.input.rows, - keyColumns: ctx.input.keyColumns - }); + let result = await client.upsertRows( + ctx.input.docId, + ctx.input.tableIdOrName, + { + rows: ctx.input.rows, + keyColumns: ctx.input.keyColumns + }, + { + disableParsing: ctx.input.disableParsing + } + ); return { output: { diff --git a/integrations/cohere/README.md b/integrations/cohere/README.md index 2cd2a712ff..cc6c594161 100644 --- a/integrations/cohere/README.md +++ b/integrations/cohere/README.md @@ -1,6 +1,6 @@ # Cohere -Generate text and chat completions using Cohere's Command family of large language models, with support for multi-turn conversations, retrieval augmented generation (RAG), tool use, reasoning, vision, and translation. Create text and image embeddings for semantic search, classification, and clustering. Rerank search results by semantic relevance. Manage batch embedding jobs and datasets for large-scale document processing. Tokenize and detokenize text for cost estimation. List available models and their capabilities. +Generate text and chat completions using Cohere's Command family of large language models, with support for multi-turn conversations, retrieval augmented generation (RAG), tool use, reasoning, vision, structured output, and translation. Create text and image embeddings for semantic search, classification, and clustering. Rerank search results by semantic relevance. Transcribe speech audio. Manage datasets and batch embedding jobs for large-scale document processing. Tokenize and detokenize text for cost estimation. List available models and inspect their capabilities. ## Tools @@ -8,18 +8,30 @@ Generate text and chat completions using Cohere's Command family of large langua Generate text responses using Cohere's Command family of models. Supports multi-turn conversations with system prompts, tool use for calling external APIs, and retrieval augmented generation (RAG) with inline citations. Can be configured for reasoning tasks with adjustable thinking budgets. -### Embed Text +### Embed Content -Generate text embeddings using Cohere's Embed models. Returns vector representations that capture semantic meaning, useful for semantic search, classification, clustering, and similarity comparisons. Supports configurable dimensionality and multiple output formats. +Generate embeddings using Cohere's Embed models for text, image data URIs, or mixed text/image inputs. Returns vector representations that capture semantic meaning, useful for semantic search, classification, clustering, and similarity comparisons. Supports configurable dimensionality and multiple output formats. ### List Models List available Cohere models with their capabilities. Filter by endpoint type (chat, embed, rerank, etc.) to find models compatible with a specific use case. +### Get Model + +Retrieve detailed metadata for a Cohere model, including supported endpoints, context length, tokenizer URL, feature flags, and default sampling parameters. + +### Create Dataset + +Create a Cohere hosted dataset by uploading a CSV, JSONL, or text file. Embed-input datasets can be used later with batch embed jobs. + ### List Datasets List datasets stored in your Cohere account. Datasets are used for batch embedding jobs and can be filtered by type, date, and validation status. +### Get Dataset Usage + +View total Cohere hosted dataset storage usage for the organization. + ### Create Embed Job Launch an asynchronous batch embedding job to embed a large dataset (100K+ documents). Results are stored as a new hosted dataset. Best suited for encoding large corpora for retrieval use cases. @@ -32,6 +44,10 @@ Rerank a list of documents by semantic relevance to a query using Cohere's Reran Split text into tokens using byte-pair encoding (BPE) for a specific Cohere model. Useful for estimating costs, understanding how a model processes input, and checking token limits before making API calls. +### Transcribe Audio + +Transcribe speech from an uploaded audio file using Cohere Transcribe. + ## License This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE). diff --git a/integrations/cohere/docs/SPEC.md b/integrations/cohere/docs/SPEC.md index aedb122b56..264378781b 100644 --- a/integrations/cohere/docs/SPEC.md +++ b/integrations/cohere/docs/SPEC.md @@ -33,22 +33,26 @@ There are no OAuth2 flows, scopes, or additional credentials required for standa ### Chat / Text Generation -The Command family of models (Command A, Command R7B, Command A Translate, Command A Reasoning, Command A Vision, Command R+, Command R) are text-generation LLMs powering tool-using agents, RAG, translation, copywriting, and similar use cases. The Chat API supports multi-turn conversations with system prompts, configurable parameters like temperature, max tokens, stop sequences, frequency penalty, and seed for reproducibility. The Chat API is capable of streaming events (such as text generation) as they come, meaning partial results from the model can be displayed within moments. +The Command family of models (Command A+, Command A, Command R7B, Command A Translate, Command A Reasoning, Command A Vision, Command R+, Command R) are text-generation LLMs powering tool-using agents, RAG, translation, copywriting, and similar use cases. The Chat API supports multi-turn conversations with system prompts, configurable parameters like temperature, max tokens, stop sequences, frequency penalty, seed, top-k/top-p sampling, response format, tool choice, and priority. The Chat API is capable of streaming events (such as text generation) as they come, meaning partial results from the model can be displayed within moments. - **Retrieval Augmented Generation (RAG):** Documents can be passed directly in the request to ground model responses in provided context, with inline citations generated automatically. - **Tool Use:** The models can integrate with external APIs, databases, and services to access real-time information and perform actions beyond text generation. This capability turns simple language models into intelligent agents. Tools are defined using JSON schema. -- **Reasoning:** Command A Reasoning is a hybrid reasoning model designed to excel at complex agentic tasks, with 111 billion parameters and a 256K context length. Thinking can be enabled/disabled, and a token budget can be set to limit reasoning tokens. +- **Reasoning:** Command A Reasoning is a hybrid reasoning model designed to excel at complex agentic tasks, with 111 billion parameters and a 256K context length. Thinking is enabled by default for reasoning models, can be disabled, and a token budget can be set to limit reasoning tokens. - **Vision:** Command A Vision brings enterprise-grade vision capabilities, supporting text + image processing, with up to 20 images per request and a 128K token context length. - **Translation:** Command A Translate is Cohere's machine translation model, achieving state-of-the-art performance across 23 languages. ### Text Embeddings -The Embed endpoint returns text embeddings — lists of floating point numbers that capture semantic information about the text. Embeddings can be used to create text classifiers as well as empower semantic search. +The Embed endpoint returns embeddings — vectors that capture semantic information about text, image data URIs, or mixed text/image inputs. Embeddings can be used to create classifiers, cluster documents, and power semantic search. -- Supports text and image inputs. -- Input types include `search_document` (for vector DB storage), `search_query` (for search queries), `classification`, and `clustering`. +- Supports text, image, and mixed inputs. +- Input types include `search_document` (for vector DB storage), `search_query` (for search queries), `classification`, `clustering`, and `image`. - Output embedding dimensionality can be configured (256, 512, 1024, or 1536) for embed-v4 and newer models. -- Multiple embedding formats available: float, int8, uint8, and binary. +- Multiple embedding formats available: float, int8, uint8, binary, ubinary, and base64. + +### Audio Transcription + +Cohere Transcribe is the audio transcription model for automatic speech recognition. The API accepts an uploaded audio file with a model and ISO-639-1 language code, returning transcript text. ### Batch Embedding Jobs @@ -63,7 +67,7 @@ The Rerank endpoint takes in a query and a list of texts and produces an ordered ### Dataset Management -The Cohere platform allows you to upload and manage datasets that can be used in batch embedding with Embedding Jobs. Datasets can be managed in the Dashboard or programmatically using the Datasets API. +The Cohere platform allows you to upload and manage datasets that can be used in batch embedding with Embedding Jobs. Datasets can be managed in the Dashboard or programmatically using the Datasets API, including create, list, get, delete, and organization storage usage. - Supports CSV and JSONL file formats. - Metadata can be preserved via `keep_fields` or `optional_fields`. diff --git a/integrations/cohere/package.json b/integrations/cohere/package.json index bc362d7625..824daa6e1e 100644 --- a/integrations/cohere/package.json +++ b/integrations/cohere/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/cohere/slate.json b/integrations/cohere/slate.json index 4640c1361a..ae22077af2 100644 --- a/integrations/cohere/slate.json +++ b/integrations/cohere/slate.json @@ -1,17 +1,20 @@ { "name": "@metorial/cohere", - "description": "Generate text and chat completions using Cohere's Command family of large language models, with support for multi-turn conversations, retrieval augmented generation (RAG), tool use, reasoning, vision, and translation. Create text and image embeddings for semantic search, classification, and clustering. Rerank search results by semantic relevance. Manage batch embedding jobs and datasets for large-scale document processing. Tokenize and detokenize text for cost estimation. List available models and their capabilities.", + "description": "Generate text and chat completions using Cohere's Command family of large language models, with support for multi-turn conversations, retrieval augmented generation (RAG), tool use, reasoning, vision, structured output, and translation. Create text and image embeddings for semantic search, classification, and clustering. Rerank search results by semantic relevance. Transcribe speech audio. Manage batch embedding jobs and datasets for large-scale document processing. Tokenize and detokenize text for cost estimation. List available models and their capabilities.", "categories": ["apis-and-http-requests", "language-translation"], "skills": [ "generate text completions", "multi-turn chat conversations", "create text embeddings", + "create image embeddings", "rerank search results", + "transcribe audio", "retrieval augmented generation", "translate text across languages", "process images with vision", "manage batch embedding jobs", "manage datasets", + "inspect model metadata", "tokenize and detokenize text" ], "logoUrl": "https://provider-logos.metorial-cdn.com/cohere.png" diff --git a/integrations/cohere/src/index.ts b/integrations/cohere/src/index.ts index 40ce1c8dac..a287cedd7c 100644 --- a/integrations/cohere/src/index.ts +++ b/integrations/cohere/src/index.ts @@ -3,17 +3,21 @@ import { spec } from './spec'; import { cancelEmbedJobTool, chatTool, + createDatasetTool, createEmbedJobTool, deleteDatasetTool, detokenizeTool, embedTool, getDatasetTool, + getDatasetUsageTool, getEmbedJobTool, + getModelTool, listDatasetsTool, listEmbedJobsTool, listModelsTool, rerankTool, - tokenizeTool + tokenizeTool, + transcribeAudioTool } from './tools'; import { inboundWebhook } from './triggers/inbound-webhook'; @@ -27,13 +31,17 @@ export let provider = Slate.create({ tokenizeTool, detokenizeTool, listModelsTool, + getModelTool, + createDatasetTool, listDatasetsTool, + getDatasetUsageTool, getDatasetTool, deleteDatasetTool, createEmbedJobTool, listEmbedJobsTool, getEmbedJobTool, - cancelEmbedJobTool + cancelEmbedJobTool, + transcribeAudioTool ], triggers: [inboundWebhook] }); diff --git a/integrations/cohere/src/lib/client.ts b/integrations/cohere/src/lib/client.ts index 46fae57024..ee5bceac17 100644 --- a/integrations/cohere/src/lib/client.ts +++ b/integrations/cohere/src/lib/client.ts @@ -1,9 +1,117 @@ -import { createAxios } from 'slates'; +import { createApiServiceError, createAxios } from 'slates'; +import { cohereApiError } from './errors'; let http = createAxios({ baseURL: 'https://api.cohere.com' }); +http.interceptors?.response?.use( + (response: any) => response, + (error: unknown) => Promise.reject(cohereApiError(error)) +); + +let sanitizeMultipartHeader = (value: string) => value.replace(/[\r\n"]/g, '_'); + +let appendMultipartField = ( + parts: Buffer[], + boundary: string, + name: string, + value: string | number | boolean +) => { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${sanitizeMultipartHeader(name)}"\r\n\r\n${String(value)}\r\n` + ) + ); +}; + +let appendMultipartFile = ( + parts: Buffer[], + boundary: string, + name: string, + filename: string, + content: Buffer, + mimeType: string +) => { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${sanitizeMultipartHeader(name)}"; filename="${sanitizeMultipartHeader(filename)}"\r\nContent-Type: ${mimeType}\r\n\r\n` + ) + ); + parts.push(content); + parts.push(Buffer.from('\r\n')); +}; + +let decodeFileContent = (params: { + content?: string; + contentBase64?: string; + fieldName: string; + required?: boolean; +}) => { + let hasContent = params.content !== undefined; + let hasBase64 = params.contentBase64 !== undefined; + let required = params.required ?? true; + + if (hasContent && hasBase64) { + throw createApiServiceError( + `Provide only one of ${params.fieldName} or ${params.fieldName}Base64.` + ); + } + + if (required && !hasContent && !hasBase64) { + throw createApiServiceError( + `Provide one of ${params.fieldName} or ${params.fieldName}Base64.` + ); + } + + if (!hasContent && !hasBase64) { + return undefined; + } + + if (hasBase64) { + return Buffer.from(params.contentBase64 ?? '', 'base64'); + } + + return Buffer.from(params.content ?? '', 'utf8'); +}; + +let buildMultipartBody = (params: { + fields?: Record; + files: Array<{ + fieldName: string; + filename: string; + fileContent: Buffer; + mimeType?: string; + }>; +}) => { + let boundary = `----SlatesCohereBoundary${Date.now()}${Math.random().toString(16).slice(2)}`; + let parts: Buffer[] = []; + + for (let [name, value] of Object.entries(params.fields ?? {})) { + if (value !== undefined) { + appendMultipartField(parts, boundary, name, value); + } + } + + for (let file of params.files) { + appendMultipartFile( + parts, + boundary, + file.fieldName, + file.filename, + file.fileContent, + file.mimeType ?? 'application/octet-stream' + ); + } + + parts.push(Buffer.from(`--${boundary}--\r\n`)); + + return { + body: Buffer.concat(parts), + contentType: `multipart/form-data; boundary=${boundary}` + }; +}; + export class CohereClient { private token: string; @@ -31,8 +139,15 @@ export class CohereClient { stopSequences?: string[]; frequencyPenalty?: number; presencePenalty?: number; + k?: number; + p?: number; seed?: number; + logprobs?: boolean; safetyMode?: string; + toolChoice?: string; + strictTools?: boolean; + priority?: number; + citationOptions?: Record; responseFormat?: Record; tools?: Record[]; documents?: Record[]; @@ -50,8 +165,15 @@ export class CohereClient { if (params.frequencyPenalty !== undefined) body.frequency_penalty = params.frequencyPenalty; if (params.presencePenalty !== undefined) body.presence_penalty = params.presencePenalty; + if (params.k !== undefined) body.k = params.k; + if (params.p !== undefined) body.p = params.p; if (params.seed !== undefined) body.seed = params.seed; + if (params.logprobs !== undefined) body.logprobs = params.logprobs; if (params.safetyMode !== undefined) body.safety_mode = params.safetyMode; + if (params.toolChoice !== undefined) body.tool_choice = params.toolChoice; + if (params.strictTools !== undefined) body.strict_tools = params.strictTools; + if (params.priority !== undefined) body.priority = params.priority; + if (params.citationOptions !== undefined) body.citation_options = params.citationOptions; if (params.responseFormat !== undefined) body.response_format = params.responseFormat; if (params.tools !== undefined) body.tools = params.tools; if (params.documents !== undefined) body.documents = params.documents; @@ -71,9 +193,12 @@ export class CohereClient { inputType: string; texts?: string[]; images?: string[]; + inputs?: Record[]; embeddingTypes?: string[]; outputDimension?: number; + maxTokens?: number; truncate?: string; + priority?: number; }) { let body: Record = { model: params.model, @@ -82,9 +207,12 @@ export class CohereClient { if (params.texts !== undefined) body.texts = params.texts; if (params.images !== undefined) body.images = params.images; + if (params.inputs !== undefined) body.inputs = params.inputs; if (params.embeddingTypes !== undefined) body.embedding_types = params.embeddingTypes; if (params.outputDimension !== undefined) body.output_dimension = params.outputDimension; + if (params.maxTokens !== undefined) body.max_tokens = params.maxTokens; if (params.truncate !== undefined) body.truncate = params.truncate; + if (params.priority !== undefined) body.priority = params.priority; let response = await http.post('/v2/embed', body, { headers: this.headers() @@ -118,6 +246,52 @@ export class CohereClient { return response.data; } + // ==================== Audio Transcriptions (v2) ==================== + + async transcribeAudio(params: { + model: string; + language: string; + filename: string; + fileContent?: string; + fileContentBase64?: string; + mimeType?: string; + temperature?: number; + }) { + let fileContent = decodeFileContent({ + content: params.fileContent, + contentBase64: params.fileContentBase64, + fieldName: 'fileContent' + }); + if (fileContent === undefined) { + throw createApiServiceError('Provide one of fileContent or fileContentBase64.'); + } + + let multipart = buildMultipartBody({ + fields: { + model: params.model, + language: params.language, + temperature: params.temperature + }, + files: [ + { + fieldName: 'file', + filename: params.filename, + fileContent, + mimeType: params.mimeType + } + ] + }); + + let response = await http.post('/v2/audio/transcriptions', multipart.body, { + headers: { + ...this.headers(), + 'Content-Type': multipart.contentType + } + }); + + return response.data; + } + // ==================== Tokenize / Detokenize (v1) ==================== async tokenize(params: { text: string; model: string }) { @@ -173,8 +347,93 @@ export class CohereClient { return response.data; } + async getModel(model: string) { + let response = await http.get(`/v1/models/${encodeURIComponent(model)}`, { + headers: this.headers() + }); + + return response.data; + } + // ==================== Datasets (v1) ==================== + async createDataset(params: { + name: string; + datasetType: string; + fileName: string; + fileContent?: string; + fileContentBase64?: string; + mimeType?: string; + evalFileName?: string; + evalFileContent?: string; + evalFileContentBase64?: string; + evalMimeType?: string; + keepOriginalFile?: boolean; + skipMalformedInput?: boolean; + keepFields?: string[]; + optionalFields?: string[]; + textSeparator?: string; + csvDelimiter?: string; + }) { + let dataContent = decodeFileContent({ + content: params.fileContent, + contentBase64: params.fileContentBase64, + fieldName: 'fileContent' + }); + if (dataContent === undefined) { + throw createApiServiceError('Provide one of fileContent or fileContentBase64.'); + } + let evalContent = decodeFileContent({ + content: params.evalFileContent, + contentBase64: params.evalFileContentBase64, + fieldName: 'evalFileContent', + required: false + }); + + let files = [ + { + fieldName: 'data', + filename: params.fileName, + fileContent: dataContent, + mimeType: params.mimeType + } + ]; + if (evalContent !== undefined) { + files.push({ + fieldName: 'eval_data', + filename: params.evalFileName ?? 'eval-data.jsonl', + fileContent: evalContent, + mimeType: params.evalMimeType + }); + } + + let multipart = buildMultipartBody({ files }); + let queryParams: Record = { + name: params.name, + type: params.datasetType + }; + + if (params.keepOriginalFile !== undefined) + queryParams.keep_original_file = params.keepOriginalFile; + if (params.skipMalformedInput !== undefined) + queryParams.skip_malformed_input = params.skipMalformedInput; + if (params.keepFields !== undefined) queryParams.keep_fields = params.keepFields; + if (params.optionalFields !== undefined) + queryParams.optional_fields = params.optionalFields; + if (params.textSeparator !== undefined) queryParams.text_separator = params.textSeparator; + if (params.csvDelimiter !== undefined) queryParams.csv_delimiter = params.csvDelimiter; + + let response = await http.post('/v1/datasets', multipart.body, { + headers: { + ...this.headers(), + 'Content-Type': multipart.contentType + }, + params: queryParams + }); + + return response.data; + } + async listDatasets(params?: { datasetType?: string; before?: string; @@ -201,6 +460,14 @@ export class CohereClient { return response.data; } + async getDatasetUsage() { + let response = await http.get('/v1/datasets/usage', { + headers: this.headers() + }); + + return response.data; + } + async getDataset(datasetId: string) { let response = await http.get(`/v1/datasets/${datasetId}`, { headers: this.headers() diff --git a/integrations/cohere/src/lib/errors.ts b/integrations/cohere/src/lib/errors.ts new file mode 100644 index 0000000000..35bf5079b6 --- /dev/null +++ b/integrations/cohere/src/lib/errors.ts @@ -0,0 +1,47 @@ +import { buildApiServiceError } from 'slates'; + +export let cohereApiError = (error: unknown, operation = 'request') => + buildApiServiceError(error, { + providerLabel: 'Cohere', + operation, + reason: 'cohere_api_error', + includeNumbers: false, + extractMessage: (input, helpers) => { + let response = helpers.getResponse(input); + let data = response?.data; + let details: string[] = []; + let collectOptions = { + detailKeys: ['message', 'type', 'code'], + includeNumbers: false, + nestedKeys: [] + }; + + if (helpers.isRecord(data)) { + if (helpers.isRecord(data.error)) { + helpers.collectDetails(data.error, details, collectOptions); + } + + helpers.collectDetails(data.message, details, collectOptions); + helpers.collectDetails(data.detail, details, collectOptions); + if (!helpers.isRecord(data.error)) { + helpers.collectDetails(data.error, details, collectOptions); + } + } else { + helpers.collectDetails(data, details, collectOptions); + } + + if (details.length > 0) return details.join(' - '); + if (input instanceof Error && input.message) return input.message; + return 'Unknown error'; + }, + extractStatus: (input, response, helpers) => + response?.status ?? + (helpers.isRecord(input) && typeof input.status === 'number' + ? input.status + : undefined) ?? + (helpers.isRecord(input) && + helpers.isRecord(input.data) && + typeof input.data.status === 'number' + ? input.data.status + : undefined) + }); diff --git a/integrations/cohere/src/tools.schema.test.ts b/integrations/cohere/src/tools.schema.test.ts new file mode 100644 index 0000000000..54e3e08108 --- /dev/null +++ b/integrations/cohere/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Cohere tool input schemas', provider.actions); diff --git a/integrations/cohere/src/tools/chat.ts b/integrations/cohere/src/tools/chat.ts index 9e4fd4df93..44b79a58e8 100644 --- a/integrations/cohere/src/tools/chat.ts +++ b/integrations/cohere/src/tools/chat.ts @@ -1,11 +1,13 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { CohereClient } from '../lib/client'; import { spec } from '../spec'; let messageSchema = z.object({ role: z.enum(['system', 'user', 'assistant', 'tool']).describe('Role of the message sender'), - content: z.string().describe('Text content of the message') + content: z + .any() + .describe('Message content as a string or Cohere content blocks for multimodal inputs') }); let toolDefinitionSchema = z.object({ @@ -20,11 +22,6 @@ let toolDefinitionSchema = z.object({ }) }); -let documentSchema = z.object({ - id: z.string().optional().describe('Unique identifier for the document'), - data: z.record(z.string(), z.string()).describe('Key-value pairs of document content') -}); - export let chatTool = SlateTool.create(spec, { name: 'Chat', key: 'chat', @@ -69,27 +66,73 @@ export let chatTool = SlateTool.create(spec, { .describe('Penalizes repeated tokens based on frequency (0 to 1)'), presencePenalty: z .number() + .min(0) + .max(1) .optional() .describe('Penalizes tokens that have already appeared (0 to 1)'), + k: z + .number() + .int() + .min(0) + .max(500) + .optional() + .describe('Top-k sampling parameter. Use 0 to disable k-sampling.'), + p: z + .number() + .min(0.01) + .max(0.99) + .optional() + .describe('Top-p nucleus sampling probability mass'), seed: z.number().optional().describe('Fixed seed for reproducible outputs'), + logprobs: z + .boolean() + .optional() + .describe('Include generated-token log probabilities in the raw Cohere response'), safetyMode: z .enum(['CONTEXTUAL', 'STRICT', 'OFF']) .optional() .describe('Safety filtering level for generated content'), + toolChoice: z + .enum(['REQUIRED', 'NONE']) + .optional() + .describe('Force tool use or force no tool use when tools are supplied'), + strictTools: z + .boolean() + .optional() + .describe('Require tool calls to strictly follow supplied tool definitions'), + priority: z + .number() + .int() + .min(0) + .max(999) + .optional() + .describe('Cohere request priority. Lower numbers are handled earlier.'), + citationOptions: z + .record(z.string(), z.any()) + .optional() + .describe('Cohere citation_options object for controlling citation generation'), + responseFormat: z + .record(z.string(), z.any()) + .optional() + .describe( + 'Cohere response_format object, such as {"type":"json_object"}. Not supported with tools or documents.' + ), tools: z .array(toolDefinitionSchema) .optional() .describe('Function definitions the model can invoke'), documents: z - .array(documentSchema) + .array(z.any()) .optional() .describe( - 'Documents for RAG — the model will ground its response and generate citations' + 'Documents for RAG as strings or Cohere document objects — the model will ground its response and generate citations' ), enableThinking: z .boolean() .optional() - .describe('Enable reasoning/thinking mode for complex tasks'), + .describe( + 'Set false to disable thinking on reasoning models. True or omitted keeps Cohere default reasoning behavior.' + ), thinkingBudget: z .number() .optional() @@ -117,6 +160,7 @@ export let chatTool = SlateTool.create(spec, { .array(z.any()) .optional() .describe('Inline citations referencing provided documents'), + logprobs: z.array(z.any()).optional().describe('Generated-token log probabilities'), inputTokens: z.number().optional().describe('Number of input tokens consumed'), outputTokens: z.number().optional().describe('Number of output tokens generated') }) @@ -124,12 +168,19 @@ export let chatTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new CohereClient({ token: ctx.auth.token }); + if (ctx.input.responseFormat && (ctx.input.tools || ctx.input.documents)) { + throw createApiServiceError('responseFormat is not supported with tools or documents.'); + } + + if (ctx.input.safetyMode && (ctx.input.tools || ctx.input.documents)) { + throw createApiServiceError('safetyMode is not supported with tools or documents.'); + } + let thinking: Record | undefined; - if (ctx.input.enableThinking !== undefined) { - thinking = { enabled: ctx.input.enableThinking }; - if (ctx.input.thinkingBudget !== undefined) { - thinking.budget_tokens = ctx.input.thinkingBudget; - } + if (ctx.input.enableThinking === false) { + thinking = { type: 'disabled' }; + } else if (ctx.input.thinkingBudget !== undefined) { + thinking = { token_budget: ctx.input.thinkingBudget }; } let result = await client.chat({ @@ -140,8 +191,16 @@ export let chatTool = SlateTool.create(spec, { stopSequences: ctx.input.stopSequences, frequencyPenalty: ctx.input.frequencyPenalty, presencePenalty: ctx.input.presencePenalty, + k: ctx.input.k, + p: ctx.input.p, seed: ctx.input.seed, + logprobs: ctx.input.logprobs, safetyMode: ctx.input.safetyMode, + toolChoice: ctx.input.toolChoice, + strictTools: ctx.input.strictTools, + priority: ctx.input.priority, + citationOptions: ctx.input.citationOptions, + responseFormat: ctx.input.responseFormat, tools: ctx.input.tools, documents: ctx.input.documents, thinking @@ -188,6 +247,7 @@ export let chatTool = SlateTool.create(spec, { text, toolCalls, citations, + logprobs: result.logprobs, inputTokens, outputTokens }; diff --git a/integrations/cohere/src/tools/embed.ts b/integrations/cohere/src/tools/embed.ts index bc5e97a7a1..1b7001ea35 100644 --- a/integrations/cohere/src/tools/embed.ts +++ b/integrations/cohere/src/tools/embed.ts @@ -1,18 +1,26 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { CohereClient } from '../lib/client'; import { spec } from '../spec'; +let embedInputSchema = z.object({ + content: z + .array(z.record(z.string(), z.any())) + .min(1) + .describe('Mixed Cohere input content blocks, such as text and image_url blocks') +}); + export let embedTool = SlateTool.create(spec, { - name: 'Embed Text', + name: 'Embed Content', key: 'embed_text', - description: `Generate text embeddings using Cohere's Embed models. Returns vector representations that capture semantic meaning, useful for semantic search, classification, clustering, and similarity comparisons. Supports configurable dimensionality and multiple output formats.`, + description: `Generate embeddings using Cohere's Embed models for text, image data URIs, or mixed text/image inputs. Returns vector representations useful for semantic search, classification, clustering, and similarity comparisons. Supports configurable dimensionality and multiple output formats.`, instructions: [ 'Set "inputType" based on your use case: "search_document" for indexing, "search_query" for queries, "classification" for classifiers, "clustering" for grouping.', + 'Provide exactly one of "texts", "images", or "inputs". Use "inputType": "image" for image-only embeddings.', 'Use "outputDimension" to control embedding size (256, 512, 1024, or 1536) — smaller dimensions are faster but less precise.' ], constraints: [ - 'Maximum of 96 texts per request.', + 'Maximum of 96 texts or mixed inputs per request, or 1 image data URI per request.', 'Model must be a valid embedding model (e.g., "embed-v4.0", "embed-english-v3.0", "embed-multilingual-v3.0").' ], tags: { @@ -24,45 +32,91 @@ export let embedTool = SlateTool.create(spec, { model: z .string() .describe('Embedding model to use (e.g., "embed-v4.0", "embed-english-v3.0")'), - texts: z.array(z.string()).min(1).describe('Array of text strings to embed (max 96)'), + texts: z + .array(z.string()) + .min(1) + .max(96) + .optional() + .describe( + 'Array of text strings to embed (max 96). Provide exactly one input source.' + ), + images: z + .array(z.string()) + .min(1) + .max(1) + .optional() + .describe( + 'Array containing one image data URI to embed. Provide exactly one input source.' + ), + inputs: z + .array(embedInputSchema) + .min(1) + .max(96) + .optional() + .describe( + 'Array of mixed Cohere inputs containing text and image components. Provide exactly one input source.' + ), inputType: z - .enum(['search_document', 'search_query', 'classification', 'clustering']) + .enum(['search_document', 'search_query', 'classification', 'clustering', 'image']) .describe('Type of input — determines how embeddings are optimized'), embeddingTypes: z - .array(z.enum(['float', 'int8', 'uint8', 'binary', 'ubinary'])) + .array(z.enum(['float', 'int8', 'uint8', 'binary', 'ubinary', 'base64'])) .optional() .describe('Embedding formats to return (defaults to float)'), outputDimension: z .number() .optional() .describe('Embedding dimensions: 256, 512, 1024, or 1536 (for embed-v4 and newer)'), + maxTokens: z + .number() + .int() + .positive() + .optional() + .describe('Maximum tokens to embed per input before truncation'), truncate: z .enum(['NONE', 'START', 'END']) .optional() - .describe('How to handle inputs exceeding max token length') + .describe('How to handle inputs exceeding max token length'), + priority: z + .number() + .int() + .min(0) + .max(999) + .optional() + .describe('Cohere request priority. Lower numbers are handled earlier.') }) ) .output( z.object({ embeddingId: z.string().describe('Unique identifier for this embedding response'), embeddings: z - .record(z.string(), z.array(z.array(z.number()))) - .describe( - 'Embeddings keyed by type (e.g., "float"), each containing arrays of numbers' - ), - texts: z.array(z.string()).optional().describe('The input texts that were embedded') + .record(z.string(), z.any()) + .describe('Embeddings keyed by type (e.g., "float", "int8", "base64")'), + texts: z.array(z.string()).optional().describe('The input texts that were embedded'), + images: z.array(z.any()).optional().describe('The input images that were embedded') }) ) .handleInvocation(async ctx => { let client = new CohereClient({ token: ctx.auth.token }); + let inputSourceCount = [ctx.input.texts, ctx.input.images, ctx.input.inputs].filter( + value => value !== undefined + ).length; + + if (inputSourceCount !== 1) { + throw createApiServiceError('Provide exactly one of texts, images, or inputs.'); + } let result = await client.embed({ model: ctx.input.model, texts: ctx.input.texts, + images: ctx.input.images, + inputs: ctx.input.inputs, inputType: ctx.input.inputType, embeddingTypes: ctx.input.embeddingTypes, outputDimension: ctx.input.outputDimension, - truncate: ctx.input.truncate + maxTokens: ctx.input.maxTokens, + truncate: ctx.input.truncate, + priority: ctx.input.priority }); let embeddings = result.embeddings || {}; @@ -71,9 +125,10 @@ export let embedTool = SlateTool.create(spec, { output: { embeddingId: result.id || '', embeddings, - texts: result.texts + texts: result.texts, + images: result.images }, - message: `Generated embeddings for **${ctx.input.texts.length}** text(s) using **${ctx.input.model}**.` + message: `Generated embeddings for **${(ctx.input.texts ?? ctx.input.images ?? ctx.input.inputs ?? []).length}** input(s) using **${ctx.input.model}**.` }; }) .build(); diff --git a/integrations/cohere/src/tools/index.ts b/integrations/cohere/src/tools/index.ts index d406454d2d..63ea748bd3 100644 --- a/integrations/cohere/src/tools/index.ts +++ b/integrations/cohere/src/tools/index.ts @@ -5,3 +5,4 @@ export * from './manage-datasets'; export * from './manage-embed-jobs'; export * from './rerank'; export * from './tokenize'; +export * from './transcribe-audio'; diff --git a/integrations/cohere/src/tools/list-models.ts b/integrations/cohere/src/tools/list-models.ts index c9a22fe077..4c9f3c3ea1 100644 --- a/integrations/cohere/src/tools/list-models.ts +++ b/integrations/cohere/src/tools/list-models.ts @@ -3,6 +3,33 @@ import { z } from 'zod'; import { CohereClient } from '../lib/client'; import { spec } from '../spec'; +let modelOutputSchema = z.object({ + name: z.string().describe('Model identifier'), + endpoints: z.array(z.string()).optional().describe('Supported API endpoints'), + contextLength: z.number().optional().describe('Maximum context window in tokens'), + isDeprecated: z.boolean().optional().describe('Whether the model is deprecated'), + finetuned: z.boolean().optional().describe('Whether the model is fine-tuned'), + tokenizerUrl: z.string().optional().describe('Public URL to the tokenizer configuration'), + defaultEndpoints: z + .array(z.string()) + .optional() + .describe('Default endpoints for this model'), + features: z.array(z.string()).optional().describe('Features supported by this model'), + samplingDefaults: z.any().optional().describe('Default sampling parameters for this model') +}); + +let mapModel = (m: any) => ({ + name: m.name || '', + endpoints: m.endpoints, + contextLength: m.context_length, + isDeprecated: m.is_deprecated, + finetuned: m.finetuned, + tokenizerUrl: m.tokenizer_url, + defaultEndpoints: m.default_endpoints, + features: m.features, + samplingDefaults: m.sampling_defaults +}); + export let listModelsTool = SlateTool.create(spec, { name: 'List Models', key: 'list_models', @@ -32,17 +59,7 @@ export let listModelsTool = SlateTool.create(spec, { ) .output( z.object({ - models: z - .array( - z.object({ - name: z.string().describe('Model identifier'), - endpoints: z.array(z.string()).optional().describe('Supported API endpoints'), - contextLength: z.number().optional().describe('Maximum context window in tokens'), - isDeprecated: z.boolean().optional().describe('Whether the model is deprecated'), - finetuned: z.boolean().optional().describe('Whether the model is fine-tuned') - }) - ) - .describe('List of available models'), + models: z.array(modelOutputSchema).describe('List of available models'), nextPageToken: z.string().optional().describe('Token to fetch the next page of results') }) ) @@ -56,13 +73,7 @@ export let listModelsTool = SlateTool.create(spec, { pageToken: ctx.input.pageToken }); - let models = (result.models || []).map((m: any) => ({ - name: m.name || '', - endpoints: m.endpoints, - contextLength: m.context_length, - isDeprecated: m.is_deprecated, - finetuned: m.finetuned - })); + let models = (result.models || []).map(mapModel); return { output: { @@ -73,3 +84,29 @@ export let listModelsTool = SlateTool.create(spec, { }; }) .build(); + +export let getModelTool = SlateTool.create(spec, { + name: 'Get Model', + key: 'get_model', + description: `Retrieve detailed metadata for a Cohere model, including supported endpoints, context length, tokenizer URL, feature flags, and default sampling parameters.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + model: z.string().describe('Cohere model name to retrieve') + }) + ) + .output(modelOutputSchema) + .handleInvocation(async ctx => { + let client = new CohereClient({ token: ctx.auth.token }); + let result = await client.getModel(ctx.input.model); + let model = mapModel(result); + + return { + output: model, + message: `Retrieved model **${model.name}** with **${model.endpoints?.length ?? 0}** supported endpoint(s).` + }; + }) + .build(); diff --git a/integrations/cohere/src/tools/manage-datasets.ts b/integrations/cohere/src/tools/manage-datasets.ts index d176c301dd..e56bb79591 100644 --- a/integrations/cohere/src/tools/manage-datasets.ts +++ b/integrations/cohere/src/tools/manage-datasets.ts @@ -12,6 +12,97 @@ let datasetOutputSchema = z.object({ updatedAt: z.string().optional().describe('ISO 8601 timestamp of last update') }); +export let createDatasetTool = SlateTool.create(spec, { + name: 'Create Dataset', + key: 'create_dataset', + description: `Create a Cohere hosted dataset by uploading a CSV, JSONL, or text file. Embed-input datasets can be used later with batch embed jobs.`, + instructions: [ + 'Provide exactly one of "fileContent" or "fileContentBase64" for the required data file.', + 'For batch embedding, use datasetType "embed-input" and include a text field in each row.' + ], + tags: { + destructive: false + } +}) + .input( + z.object({ + name: z.string().describe('Name for the uploaded dataset'), + datasetType: z + .string() + .default('embed-input') + .describe('Cohere dataset type. Use "embed-input" for Embed Jobs.'), + fileName: z.string().describe('Name of the dataset file, such as "embed.jsonl"'), + fileContent: z.string().optional().describe('Dataset file content as a text string'), + fileContentBase64: z.string().optional().describe('Base64-encoded dataset file bytes'), + mimeType: z.string().optional().describe('MIME type for the dataset file'), + evalFileName: z.string().optional().describe('Optional evaluation dataset file name'), + evalFileContent: z + .string() + .optional() + .describe('Optional evaluation dataset file content as a text string'), + evalFileContentBase64: z + .string() + .optional() + .describe('Optional base64-encoded evaluation dataset file bytes'), + evalMimeType: z.string().optional().describe('MIME type for the evaluation file'), + keepOriginalFile: z + .boolean() + .optional() + .describe('Whether Cohere should store the original uploaded file'), + skipMalformedInput: z + .boolean() + .optional() + .describe('Drop malformed rows instead of failing validation'), + keepFields: z + .array(z.string()) + .optional() + .describe('Field names that must be preserved in the hosted dataset'), + optionalFields: z + .array(z.string()) + .optional() + .describe('Field names that should be preserved when present'), + textSeparator: z + .string() + .optional() + .describe('Separator for splitting raw text uploads'), + csvDelimiter: z.string().optional().describe('Delimiter used for CSV uploads') + }) + ) + .output( + z.object({ + datasetId: z.string().describe('ID of the created dataset') + }) + ) + .handleInvocation(async ctx => { + let client = new CohereClient({ token: ctx.auth.token }); + let result = await client.createDataset({ + name: ctx.input.name, + datasetType: ctx.input.datasetType, + fileName: ctx.input.fileName, + fileContent: ctx.input.fileContent, + fileContentBase64: ctx.input.fileContentBase64, + mimeType: ctx.input.mimeType, + evalFileName: ctx.input.evalFileName, + evalFileContent: ctx.input.evalFileContent, + evalFileContentBase64: ctx.input.evalFileContentBase64, + evalMimeType: ctx.input.evalMimeType, + keepOriginalFile: ctx.input.keepOriginalFile, + skipMalformedInput: ctx.input.skipMalformedInput, + keepFields: ctx.input.keepFields, + optionalFields: ctx.input.optionalFields, + textSeparator: ctx.input.textSeparator, + csvDelimiter: ctx.input.csvDelimiter + }); + + return { + output: { + datasetId: result.id || '' + }, + message: `Created dataset **${result.id || ctx.input.name}**.` + }; + }) + .build(); + export let listDatasetsTool = SlateTool.create(spec, { name: 'List Datasets', key: 'list_datasets', @@ -75,6 +166,35 @@ export let listDatasetsTool = SlateTool.create(spec, { }) .build(); +export let getDatasetUsageTool = SlateTool.create(spec, { + name: 'Get Dataset Usage', + key: 'get_dataset_usage', + description: `View total Cohere hosted dataset storage usage for the organization.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + organizationUsageBytes: z + .number() + .describe('Total dataset storage used by the organization') + }) + ) + .handleInvocation(async ctx => { + let client = new CohereClient({ token: ctx.auth.token }); + let result = await client.getDatasetUsage(); + + return { + output: { + organizationUsageBytes: result.organization_usage ?? 0 + }, + message: `Cohere dataset storage usage is **${result.organization_usage ?? 0}** bytes.` + }; + }) + .build(); + export let getDatasetTool = SlateTool.create(spec, { name: 'Get Dataset', key: 'get_dataset', diff --git a/integrations/cohere/src/tools/transcribe-audio.ts b/integrations/cohere/src/tools/transcribe-audio.ts new file mode 100644 index 0000000000..1c69d49ef0 --- /dev/null +++ b/integrations/cohere/src/tools/transcribe-audio.ts @@ -0,0 +1,64 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { CohereClient } from '../lib/client'; +import { spec } from '../spec'; + +export let transcribeAudioTool = SlateTool.create(spec, { + name: 'Transcribe Audio', + key: 'transcribe_audio', + description: `Transcribe speech from an uploaded audio file using Cohere Transcribe. Supports the current Cohere audio transcription endpoint for automatic speech recognition.`, + constraints: ['Audio files must be no larger than Cohere Transcribe model limits.'], + tags: { + readOnly: true, + destructive: false + } +}) + .input( + z.object({ + model: z + .string() + .default('cohere-transcribe-03-2026') + .describe('Cohere transcription model to use'), + language: z + .string() + .length(2) + .describe('Input audio language in ISO-639-1 format, such as "en"'), + filename: z.string().describe('Audio file name, including extension'), + fileContent: z.string().optional().describe('Audio file content as a text string'), + fileContentBase64: z.string().optional().describe('Base64-encoded audio file bytes'), + mimeType: z.string().optional().describe('Audio MIME type, such as audio/wav'), + temperature: z + .number() + .min(0) + .max(1) + .optional() + .describe('Sampling temperature between 0 and 1') + }) + ) + .output( + z.object({ + text: z.string().describe('Transcribed text'), + rawResult: z.any().optional().describe('Full Cohere transcription response') + }) + ) + .handleInvocation(async ctx => { + let client = new CohereClient({ token: ctx.auth.token }); + let result = await client.transcribeAudio({ + model: ctx.input.model, + language: ctx.input.language, + filename: ctx.input.filename, + fileContent: ctx.input.fileContent, + fileContentBase64: ctx.input.fileContentBase64, + mimeType: ctx.input.mimeType, + temperature: ctx.input.temperature + }); + + return { + output: { + text: result.text || '', + rawResult: result + }, + message: `Transcribed audio file **${ctx.input.filename}**.` + }; + }) + .build(); diff --git a/integrations/cohere/vitest.config.ts b/integrations/cohere/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/cohere/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/coinbase/README.md b/integrations/coinbase/README.md index 479f65f33a..2010da630e 100644 --- a/integrations/coinbase/README.md +++ b/integrations/coinbase/README.md @@ -1,6 +1,10 @@ # Coinbase -Buy, sell, send, and receive cryptocurrency. Manage wallets and accounts, create market/limit/stop-limit orders, and view transaction history. Access real-time and historical market data including prices, exchange rates, and order books. Accept crypto payments via Coinbase Commerce by creating charges and checkouts. Query onchain data such as balances and transaction history across supported networks. Stream live market data via WebSocket. Receive webhooks for account events, payment lifecycle changes, and onchain activity. +Buy, sell, send, and receive cryptocurrency. Manage Coinbase App wallets and +addresses, view transactions, list payment methods, preview and create Advanced +Trade orders, inspect fills and portfolios, retrieve prices, exchange rates, +candles, market trades, and order books, and manage Coinbase Commerce charges +with a Commerce API key. ## License diff --git a/integrations/coinbase/docs/SPEC.md b/integrations/coinbase/docs/SPEC.md index 0c36341f99..7b0b1c8fb5 100644 --- a/integrations/coinbase/docs/SPEC.md +++ b/integrations/coinbase/docs/SPEC.md @@ -2,30 +2,23 @@ ## Overview -Coinbase is a cryptocurrency exchange platform that allows users to buy, sell, store, and manage digital assets. It provides APIs for trading, account management, market data access, payment processing (via Coinbase Commerce), and onchain data. The platform serves individual traders, developers, and institutions across multiple API products including Coinbase App, Advanced Trade, Exchange, and Commerce. +Coinbase is a cryptocurrency exchange platform that allows users to buy, sell, store, and manage digital assets. This integration focuses on Coinbase App wallet APIs, Advanced Trade trading and market data APIs, and Coinbase Commerce charge management. ## Authentication -Coinbase supports two primary authentication methods: +This integration supports Coinbase App OAuth2 for Coinbase App and Advanced Trade +tools, plus Coinbase Commerce API key authentication for Commerce charge tools. +Coinbase also supports CDP API key JWT authentication for own-account server +automation, but this integration's App and Advanced Trade tools are OAuth-based. -### 1. CDP API Key Authentication (JWT-based) - -CDP API keys are used to generate a JSON Web Token (JWT), which is then set as an Authorization Bearer header to make authenticated requests. - -- **Setup:** Log into the Coinbase Developer Platform (CDP), navigate to Access → API keys, and configure: API key nickname, portfolio (e.g., Default), and permission level (View, Trade, Transfer). -- The key automatically downloads as a JSON file containing the API Key Name (in the format `organizations/{org_id}/apiKeys/{key_id}`) and a Private Key (EC private key). -- **Signature Algorithm:** When using Coinbase App SDKs, Ed25519 (EdDSA) keys are NOT supported. You must use ES256 key format (ECDSA with P-256 curve). -- **IP Allowlisting:** For enhanced API Key security, Coinbase recommends whitelisting IP addresses permitted to make requests with a particular API Key. -- API Key authentication should only be used to access your own account. - -### 2. OAuth2 ("Sign in with Coinbase") +### 1. OAuth2 ("Sign in with Coinbase") The Sign In With Coinbase API supports the OAuth2 protocol so that developers can let Coinbase users grant a 3rd party application full or partial access to their account, without sharing the account's API key or login credentials. - **Authorization URL:** `https://login.coinbase.com/oauth2/auth` -- **Token URL:** `https://api.coinbase.com/oauth/token` +- **Token URL:** `https://login.coinbase.com/oauth2/token` - **Grant Type:** Authorization Code -- **Scopes:** Permissions are specified via a `scope` parameter. For example, an app may only need to view accounts and transaction history. Multiple permissions are separated with commas (e.g., `&scope=wallet:accounts:read,wallet:transactions:read`). Common scopes include: +- **Scopes:** Permissions are specified via a `scope` parameter. Multiple permissions are separated with commas (e.g., `&scope=wallet:accounts:read,wallet:transactions:read`). Common scopes used by this integration include: - `wallet:user:read`, `wallet:user:email` — Read user profile and email - `wallet:accounts:read`, `wallet:accounts:create`, `wallet:accounts:update`, `wallet:accounts:delete` — Account management - `wallet:transactions:read`, `wallet:transactions:send` — View and send transactions @@ -34,7 +27,14 @@ The Sign In With Coinbase API supports the OAuth2 protocol so that developers ca - `wallet:deposits:read`, `wallet:deposits:create` — Deposits - `wallet:withdrawals:read`, `wallet:withdrawals:create` — Withdrawals - `wallet:addresses:read`, `wallet:addresses:create` — Wallet addresses -- **Token Refresh:** When first authenticating, the app receives an access token and a refresh token. The access token expires after about two hours. The refresh token allows obtaining a new access/refresh token pair but can only be used once. + - `wallet:payment-methods:read` — Payment methods + - `wallet:trades:read`, `wallet:trades:create` — Advanced Trade reads, previews, and order creation + - `offline_access` — Return a refresh token for token renewal +- **Token Refresh:** When first authenticating with `offline_access`, the app receives an access token and a refresh token. The access token expires in one hour. Refresh tokens can be exchanged once for a new access/refresh token pair and must be preserved after each rotation. + +### 2. Coinbase Commerce API Key + +Coinbase Commerce charges use `X-CC-Api-Key` and `X-CC-Version` headers against `https://api.commerce.coinbase.com`. Commerce tools are scoped to the `commerce_api_key` auth method and are not available under Coinbase App OAuth. ## Features @@ -44,7 +44,7 @@ View and manage cryptocurrency wallets and accounts on Coinbase. List all accoun ### Trading (Advanced Trade) -Automate market, limit, and stop-limit orders by building with the REST API. Supports trading in over 550 markets, including 237 USDC pairs. Manage orders (create, cancel, list), view order history, and access portfolio details. Permission levels can be set to View, Trade, or Transfer. +Preview and automate market, limit, and stop-limit orders through the Advanced Trade REST API. Manage orders (preview, create, cancel, list, get), view fills, inspect transaction summary/fee tier data, list payment methods, and access portfolio breakdowns. ### Transactions @@ -56,19 +56,11 @@ Programmatically buy and sell cryptocurrency using linked payment methods. Depos ### Market Data -Market Data APIs are public and do not require authentication. Access real-time and historical prices, exchange rates, order books, and product/trading pair information. Retrieve spot prices, buy/sell prices, and currency information. +Access real-time and historical prices, exchange rates, order books, market trades, candles, and product/trading pair information. Retrieve spot prices, buy/sell prices, and currency information. ### Coinbase Commerce (Payments) -Accept cryptocurrency payments from customers. Create charges and checkouts for goods and services. Supports fixed-price and donation-style payments. Track payment status through charge lifecycle states (NEW, PENDING, COMPLETED, EXPIRED, etc.). - -### Onchain Data - -Access onchain data like balances, balance history and transaction history directly in your app. Query blockchain data across supported networks. - -### Real-Time Market Data (WebSocket) - -Coinbase provides a WebSocket feed that enables developers to stream live market data in real-time. Subscribe to channels for ticker updates, order book snapshots, trade executions, and user-level order/fill updates. Filter by product IDs (trading pairs). +Accept cryptocurrency payments from customers with Commerce charges. Create, list, get, cancel, and resolve fixed-price or no-price charges. Track payment status through charge lifecycle states (NEW, PENDING, COMPLETED, EXPIRED, etc.). ### User Profile @@ -93,12 +85,3 @@ Webhooks for crypto payment processing, notifying you of charge lifecycle events - Event types include: `charge:created`, `charge:confirmed`, `charge:failed`, `charge:pending`, `charge:delayed`, `charge:resolved`. - Webhook payloads are signed, and you should verify the signature to ensure the webhook is genuine. Verification uses the `X-CC-Webhook-Signature` header with HMAC-SHA256. - -### CDP Onchain Webhooks - -Real-time notifications for onchain activity such as ERC-20 transfers, NFT movements, and smart contract events on Base and other supported networks. - -- Primary event type: `onchain.activity.detected`. -- Can be filtered by contract address and event name (e.g., `Transfer`). -- Currently focused on Base with limited coverage of other networks. The event types require developers to understand contract ABIs and event signatures for filtering. -- Subscriptions are managed programmatically via the CDP API. diff --git a/integrations/coinbase/package.json b/integrations/coinbase/package.json index 3ee453944b..ebf49d8d30 100644 --- a/integrations/coinbase/package.json +++ b/integrations/coinbase/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/coinbase/slate.json b/integrations/coinbase/slate.json index fcb407744f..5eac2ab872 100644 --- a/integrations/coinbase/slate.json +++ b/integrations/coinbase/slate.json @@ -1,18 +1,18 @@ { "name": "@metorial/coinbase", - "description": "Buy, sell, send, and receive cryptocurrency. Manage wallets and accounts, create market/limit/stop-limit orders, and view transaction history. Access real-time and historical market data including prices, exchange rates, and order books. Accept crypto payments via Coinbase Commerce by creating charges and checkouts. Query onchain data such as balances and transaction history across supported networks. Stream live market data via WebSocket. Receive webhooks for account events, payment lifecycle changes, and onchain activity.", + "description": "Buy, sell, send, and receive cryptocurrency. Manage Coinbase App wallets and addresses, view transactions, list payment methods, preview and create Advanced Trade orders, inspect fills and portfolios, retrieve prices, exchange rates, candles, market trades, and order books, and manage Coinbase Commerce charges with a Commerce API key.", "categories": ["e-commerce-and-retail", "financial-data-and-stock-market"], "skills": [ "buy and sell cryptocurrency", "send and receive crypto", "manage crypto wallets", - "create trading orders", + "preview and create trading orders", "retrieve market prices", "accept crypto payments", - "query onchain data", + "inspect portfolio balances", "view transaction history", - "stream real-time market data", - "manage payment checkouts" + "view order fills", + "read market order books" ], "logoUrl": "https://provider-logos.metorial-cdn.com/coinbase.svg" } diff --git a/integrations/coinbase/src/auth.ts b/integrations/coinbase/src/auth.ts index 19bf05b2dc..702a0145c4 100644 --- a/integrations/coinbase/src/auth.ts +++ b/integrations/coinbase/src/auth.ts @@ -1,18 +1,84 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { coinbaseApiError, coinbaseServiceError } from './lib/errors'; -let coinbaseAxios = createAxios({ +let coinbaseApiAxios = createAxios({ baseURL: 'https://api.coinbase.com' }); +let coinbaseOAuthAxios = createAxios({ + baseURL: 'https://login.coinbase.com' +}); + +let coinbaseCommerceAxios = createAxios({ + baseURL: 'https://api.commerce.coinbase.com', + headers: { + 'X-CC-Version': '2018-03-22' + } +}); + +coinbaseApiAxios.interceptors.response.use( + response => response, + error => { + throw coinbaseApiError(error, 'profile request'); + } +); + +coinbaseOAuthAxios.interceptors.response.use( + response => response, + error => { + throw coinbaseApiError(error, 'OAuth request'); + } +); + +coinbaseCommerceAxios.interceptors.response.use( + response => response, + error => { + throw coinbaseApiError(error, 'Commerce profile request'); + } +); + +let expiresAtFromSeconds = (expiresIn?: number) => + typeof expiresIn === 'number' && Number.isFinite(expiresIn) + ? new Date(Date.now() + expiresIn * 1000).toISOString() + : undefined; + export let auth = SlateAuth.create() .output( z.object({ - token: z.string().describe('Coinbase OAuth access token'), + token: z.string().describe('Coinbase OAuth access token or Commerce API key'), refreshToken: z.string().optional().describe('Coinbase OAuth refresh token'), expiresAt: z.string().optional().describe('Token expiration timestamp (ISO 8601)') }) ) + .addTokenAuth({ + type: 'auth.token', + name: 'Coinbase Commerce API Key', + key: 'commerce_api_key', + inputSchema: z.object({ + token: z.string().min(1).describe('Coinbase Commerce API key') + }), + getOutput: async ctx => { + return { + output: { + token: ctx.input.token + } + }; + }, + getProfile: async (ctx: { output: { token: string }; input: { token: string } }) => { + await coinbaseCommerceAxios.get('/charges?limit=1', { + headers: { + 'X-CC-Api-Key': ctx.output.token + } + }); + + return { + profile: { + name: 'Coinbase Commerce API key' + } + }; + } + }) .addOauth({ type: 'auth.oauth', name: 'OAuth (Sign in with Coinbase)', @@ -108,6 +174,26 @@ export let auth = SlateAuth.create() title: 'Create Addresses', description: 'Create new cryptocurrency receive addresses', scope: 'wallet:addresses:create' + }, + { + title: 'Read Payment Methods', + description: 'List linked payment methods for buys, sells, deposits, and withdrawals', + scope: 'wallet:payment-methods:read' + }, + { + title: 'Read Trades', + description: 'View Coinbase Advanced Trade orders and fills', + scope: 'wallet:trades:read' + }, + { + title: 'Create Trades', + description: 'Preview and create Coinbase Advanced Trade orders', + scope: 'wallet:trades:create' + }, + { + title: 'Offline Access', + description: 'Receive a refresh token so Coinbase access can be renewed', + scope: 'offline_access' } ], @@ -126,46 +212,68 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let response = await coinbaseAxios.post('/oauth/token', { - grant_type: 'authorization_code', - code: ctx.code, - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - redirect_uri: ctx.redirectUri - }); + let response = await coinbaseOAuthAxios.post( + '/oauth2/token', + new URLSearchParams({ + grant_type: 'authorization_code', + code: ctx.code, + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + redirect_uri: ctx.redirectUri + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); let data = response.data; - let expiresAt = data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined; + if (!data.access_token) { + throw coinbaseServiceError('Coinbase OAuth response did not include an access token.'); + } return { output: { token: data.access_token, refreshToken: data.refresh_token, - expiresAt + expiresAt: expiresAtFromSeconds(data.expires_in) } }; }, handleTokenRefresh: async (ctx: any) => { - let response = await coinbaseAxios.post('/oauth/token', { - grant_type: 'refresh_token', - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - refresh_token: ctx.output.refreshToken - }); + if (!ctx.output.refreshToken) { + throw coinbaseServiceError('Coinbase OAuth refresh requires a refresh token.'); + } + + let response = await coinbaseOAuthAxios.post( + '/oauth2/token', + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + refresh_token: ctx.output.refreshToken + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); let data = response.data; - let expiresAt = data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined; + if (!data.access_token) { + throw coinbaseServiceError( + 'Coinbase OAuth refresh response did not include an access token.' + ); + } return { output: { token: data.access_token, - refreshToken: data.refresh_token, - expiresAt + refreshToken: data.refresh_token || ctx.output.refreshToken, + expiresAt: expiresAtFromSeconds(data.expires_in) } }; }, @@ -175,7 +283,7 @@ export let auth = SlateAuth.create() input: {}; scopes: string[]; }) => { - let response = await coinbaseAxios.get('/v2/user', { + let response = await coinbaseApiAxios.get('/v2/user', { headers: { Authorization: `Bearer ${ctx.output.token}` } diff --git a/integrations/coinbase/src/index.ts b/integrations/coinbase/src/index.ts index abb3bd3404..c844400eb5 100644 --- a/integrations/coinbase/src/index.ts +++ b/integrations/coinbase/src/index.ts @@ -5,14 +5,19 @@ import { depositWithdraw, getCandles, getExchangeRates, + getMarketData, getPrices, + getTransactionSummary, getUserProfile, + listFills, + listPaymentMethods, listProducts, listTransactions, manageAccounts, manageAddresses, manageCommerceCharges, manageOrders, + managePortfolios, sendCrypto } from './tools'; import { accountNotifications, commerceChargeEvents, transactionPolling } from './triggers'; @@ -26,8 +31,13 @@ export let provider = Slate.create({ buySellCrypto, depositWithdraw, manageOrders, + managePortfolios, listProducts, getCandles, + getMarketData, + listFills, + getTransactionSummary, + listPaymentMethods, getPrices, getExchangeRates, getUserProfile, diff --git a/integrations/coinbase/src/lib/advanced-trade-client.ts b/integrations/coinbase/src/lib/advanced-trade-client.ts index b99d028e90..2a4c194bee 100644 --- a/integrations/coinbase/src/lib/advanced-trade-client.ts +++ b/integrations/coinbase/src/lib/advanced-trade-client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { coinbaseApiError, coinbaseServiceError } from './errors'; export interface AdvancedTradeClientConfig { token: string; @@ -8,6 +9,10 @@ export class AdvancedTradeClient { private api: ReturnType; constructor(config: AdvancedTradeClientConfig) { + if (!config.token?.trim()) { + throw coinbaseServiceError('Coinbase OAuth access token is required.'); + } + this.api = createAxios({ baseURL: 'https://api.coinbase.com/api/v3/brokerage', headers: { @@ -15,6 +20,13 @@ export class AdvancedTradeClient { 'Content-Type': 'application/json' } }); + + this.api.interceptors.response.use( + response => response, + error => { + throw coinbaseApiError(error, 'Advanced Trade request'); + } + ); } // --- Accounts --- @@ -51,6 +63,19 @@ export class AdvancedTradeClient { return response.data; } + async previewOrder(params: { + productId: string; + side: 'BUY' | 'SELL'; + orderConfiguration: Record; + }): Promise { + let response = await this.api.post('/orders/preview', { + product_id: params.productId, + side: params.side, + order_configuration: params.orderConfiguration + }); + return response.data; + } + async cancelOrders(orderIds: string[]): Promise { let response = await this.api.post('/orders/batch_cancel', { order_ids: orderIds @@ -83,6 +108,50 @@ export class AdvancedTradeClient { return response.data; } + async listFills(params?: { + orderIds?: string[]; + tradeIds?: string[]; + productIds?: string[]; + startSequenceTimestamp?: string; + endSequenceTimestamp?: string; + retailPortfolioId?: string; + limit?: number; + cursor?: string; + sortBy?: string; + assetFilters?: string[]; + orderTypes?: string[]; + orderSide?: string; + productTypes?: string[]; + proofToken?: string; + }): Promise { + let query = new URLSearchParams(); + for (let orderId of params?.orderIds ?? []) query.append('order_ids', orderId); + for (let tradeId of params?.tradeIds ?? []) query.append('trade_ids', tradeId); + for (let productId of params?.productIds ?? []) query.append('product_ids', productId); + if (params?.startSequenceTimestamp) { + query.set('start_sequence_timestamp', params.startSequenceTimestamp); + } + if (params?.endSequenceTimestamp) { + query.set('end_sequence_timestamp', params.endSequenceTimestamp); + } + if (params?.retailPortfolioId) query.set('retail_portfolio_id', params.retailPortfolioId); + if (params?.limit) query.set('limit', String(params.limit)); + if (params?.cursor) query.set('cursor', params.cursor); + if (params?.sortBy) query.set('sort_by', params.sortBy); + for (let asset of params?.assetFilters ?? []) query.append('asset_filters', asset); + for (let orderType of params?.orderTypes ?? []) query.append('order_types', orderType); + if (params?.orderSide) query.set('order_side', params.orderSide); + for (let productType of params?.productTypes ?? []) { + query.append('product_types', productType); + } + if (params?.proofToken) query.set('proof_token', params.proofToken); + + let qs = query.toString(); + let url = qs ? `/orders/historical/fills?${qs}` : '/orders/historical/fills'; + let response = await this.api.get(url); + return response.data; + } + async getOrder(orderId: string): Promise { let response = await this.api.get(`/orders/historical/${orderId}`); return response.data.order; @@ -127,23 +196,58 @@ export class AdvancedTradeClient { return response.data; } - async getProductTicker(productId: string, params?: { limit?: number }): Promise { + async getProductTicker( + productId: string, + params?: { limit?: number; start?: string; end?: string } + ): Promise { let query: Record = {}; if (params?.limit) query.limit = String(params.limit); + if (params?.start) query.start = params.start; + if (params?.end) query.end = params.end; let qs = new URLSearchParams(query).toString(); let url = qs ? `/products/${productId}/ticker?${qs}` : `/products/${productId}/ticker`; let response = await this.api.get(url); return response.data; } + async getProductBook( + productId: string, + params?: { limit?: number; aggregationPriceIncrement?: string } + ): Promise { + let query: Record = { product_id: productId }; + if (params?.limit) query.limit = String(params.limit); + if (params?.aggregationPriceIncrement) { + query.aggregation_price_increment = params.aggregationPriceIncrement; + } + let qs = new URLSearchParams(query).toString(); + let response = await this.api.get(`/product_book?${qs}`); + return response.data; + } + + // --- Payment Methods --- + + async listPaymentMethods(): Promise { + let response = await this.api.get('/payment_methods'); + return response.data; + } + + async getPaymentMethod(paymentMethodId: string): Promise { + let response = await this.api.get(`/payment_methods/${paymentMethodId}`); + return response.data.payment_method || response.data; + } + // --- Portfolios --- - async listPortfolios(): Promise { - let response = await this.api.get('/portfolios'); + async listPortfolios(params?: { portfolioType?: string }): Promise { + let query: Record = {}; + if (params?.portfolioType) query.portfolio_type = params.portfolioType; + let qs = new URLSearchParams(query).toString(); + let url = qs ? `/portfolios?${qs}` : '/portfolios'; + let response = await this.api.get(url); return response.data; } - async getPortfolio(portfolioUuid: string): Promise { + async getPortfolioBreakdown(portfolioUuid: string): Promise { let response = await this.api.get(`/portfolios/${portfolioUuid}`); return response.data; } @@ -151,16 +255,14 @@ export class AdvancedTradeClient { // --- Transaction Summary --- async getTransactionSummary(params?: { - startDate?: string; - endDate?: string; - userNativeCurrency?: string; productType?: string; + contractExpiryType?: string; + productVenue?: string; }): Promise { let query: Record = {}; - if (params?.startDate) query.start_date = params.startDate; - if (params?.endDate) query.end_date = params.endDate; - if (params?.userNativeCurrency) query.user_native_currency = params.userNativeCurrency; if (params?.productType) query.product_type = params.productType; + if (params?.contractExpiryType) query.contract_expiry_type = params.contractExpiryType; + if (params?.productVenue) query.product_venue = params.productVenue; let qs = new URLSearchParams(query).toString(); let url = qs ? `/transaction_summary?${qs}` : '/transaction_summary'; let response = await this.api.get(url); diff --git a/integrations/coinbase/src/lib/auth-methods.ts b/integrations/coinbase/src/lib/auth-methods.ts new file mode 100644 index 0000000000..540e0e2829 --- /dev/null +++ b/integrations/coinbase/src/lib/auth-methods.ts @@ -0,0 +1,2 @@ +export let coinbaseOAuthAuthMethods = ['oauth']; +export let coinbaseCommerceAuthMethods = ['commerce_api_key']; diff --git a/integrations/coinbase/src/lib/client.ts b/integrations/coinbase/src/lib/client.ts index c6f4459755..ccb9abebe6 100644 --- a/integrations/coinbase/src/lib/client.ts +++ b/integrations/coinbase/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { coinbaseApiError, coinbaseServiceError } from './errors'; export interface CoinbaseClientConfig { token: string; @@ -8,6 +9,10 @@ export class CoinbaseClient { private v2: ReturnType; constructor(config: CoinbaseClientConfig) { + if (!config.token?.trim()) { + throw coinbaseServiceError('Coinbase OAuth access token is required.'); + } + this.v2 = createAxios({ baseURL: 'https://api.coinbase.com/v2', headers: { @@ -16,6 +21,13 @@ export class CoinbaseClient { 'CB-VERSION': '2024-01-01' } }); + + this.v2.interceptors.response.use( + response => response, + error => { + throw coinbaseApiError(error); + } + ); } // --- User --- diff --git a/integrations/coinbase/src/lib/commerce-client.ts b/integrations/coinbase/src/lib/commerce-client.ts index 0af22ffbf7..d12e6491f2 100644 --- a/integrations/coinbase/src/lib/commerce-client.ts +++ b/integrations/coinbase/src/lib/commerce-client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { coinbaseApiError, coinbaseServiceError } from './errors'; export interface CommerceClientConfig { token: string; @@ -8,6 +9,10 @@ export class CommerceClient { private api: ReturnType; constructor(config: CommerceClientConfig) { + if (!config.token?.trim()) { + throw coinbaseServiceError('Coinbase Commerce API key is required.'); + } + this.api = createAxios({ baseURL: 'https://api.commerce.coinbase.com', headers: { @@ -16,6 +21,13 @@ export class CommerceClient { 'Content-Type': 'application/json' } }); + + this.api.interceptors.response.use( + response => response, + error => { + throw coinbaseApiError(error, 'Commerce request'); + } + ); } // --- Charges --- diff --git a/integrations/coinbase/src/lib/errors.ts b/integrations/coinbase/src/lib/errors.ts new file mode 100644 index 0000000000..cf67e8f27f --- /dev/null +++ b/integrations/coinbase/src/lib/errors.ts @@ -0,0 +1,101 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.error); + pushDetail(details, value.error_description); + pushDetail(details, value.error_details); + pushDetail(details, value.title); + pushDetail(details, value.detail); + pushDetail(details, value.code); + + collectDetails(value.errors, details); + collectDetails(value.error_response, details); +}; + +let getErrorResponse = (error: unknown) => + isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let extractCoinbaseMessage = (error: unknown) => { + let details: string[] = []; + collectDetails(getErrorResponse(error)?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + let code = response.data.code ?? response.data.error; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +}; + +export let coinbaseServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let coinbaseApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = getErrorResponse(error); + let serviceError = coinbaseServiceError( + `Coinbase API ${operation} failed: ${statusLabelFor(response)}${extractCoinbaseMessage(error)}` + ); + serviceError.data.reason = 'coinbase_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/coinbase/src/tools/buy-sell-crypto.ts b/integrations/coinbase/src/tools/buy-sell-crypto.ts index 9b4068de87..b18d8c590f 100644 --- a/integrations/coinbase/src/tools/buy-sell-crypto.ts +++ b/integrations/coinbase/src/tools/buy-sell-crypto.ts @@ -1,6 +1,8 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { CoinbaseClient } from '../lib/client'; +import { coinbaseServiceError } from '../lib/errors'; import { spec } from '../spec'; export let buySellCrypto = SlateTool.create(spec, { @@ -16,6 +18,7 @@ export let buySellCrypto = SlateTool.create(spec, { readOnly: false } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ action: z.enum(['buy', 'sell']).describe('Whether to buy or sell'), @@ -53,6 +56,13 @@ export let buySellCrypto = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new CoinbaseClient({ token: ctx.auth.token }); + if (!ctx.input.amount && !ctx.input.total) { + throw coinbaseServiceError('Provide either amount or total.'); + } + if (ctx.input.amount && ctx.input.total) { + throw coinbaseServiceError('Provide only one of amount or total.'); + } + let params = { amount: ctx.input.amount, total: ctx.input.total, diff --git a/integrations/coinbase/src/tools/deposit-withdraw.ts b/integrations/coinbase/src/tools/deposit-withdraw.ts index 7a0203c023..be3a8c1ee2 100644 --- a/integrations/coinbase/src/tools/deposit-withdraw.ts +++ b/integrations/coinbase/src/tools/deposit-withdraw.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { CoinbaseClient } from '../lib/client'; import { spec } from '../spec'; @@ -12,6 +13,7 @@ export let depositWithdraw = SlateTool.create(spec, { readOnly: false } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ action: z.enum(['deposit', 'withdraw']).describe('Whether to deposit or withdraw'), diff --git a/integrations/coinbase/src/tools/get-candles.ts b/integrations/coinbase/src/tools/get-candles.ts index f5e41c4fb5..e3cf6e6574 100644 --- a/integrations/coinbase/src/tools/get-candles.ts +++ b/integrations/coinbase/src/tools/get-candles.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AdvancedTradeClient } from '../lib/advanced-trade-client'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { spec } from '../spec'; export let getCandles = SlateTool.create(spec, { @@ -12,6 +13,7 @@ export let getCandles = SlateTool.create(spec, { readOnly: true } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ productId: z.string().describe('Trading pair (e.g., "BTC-USD")'), @@ -25,6 +27,7 @@ export let getCandles = SlateTool.create(spec, { 'THIRTY_MINUTE', 'ONE_HOUR', 'TWO_HOUR', + 'FOUR_HOUR', 'SIX_HOUR', 'ONE_DAY' ]) diff --git a/integrations/coinbase/src/tools/get-exchange-rates.ts b/integrations/coinbase/src/tools/get-exchange-rates.ts index 06bef7e640..e5795fd60f 100644 --- a/integrations/coinbase/src/tools/get-exchange-rates.ts +++ b/integrations/coinbase/src/tools/get-exchange-rates.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { CoinbaseClient } from '../lib/client'; import { spec } from '../spec'; @@ -12,6 +13,7 @@ export let getExchangeRates = SlateTool.create(spec, { readOnly: true } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ currency: z diff --git a/integrations/coinbase/src/tools/get-market-data.ts b/integrations/coinbase/src/tools/get-market-data.ts new file mode 100644 index 0000000000..805923307f --- /dev/null +++ b/integrations/coinbase/src/tools/get-market-data.ts @@ -0,0 +1,123 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AdvancedTradeClient } from '../lib/advanced-trade-client'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; +import { spec } from '../spec'; + +let tradeSchema = z.object({ + tradeId: z.string().optional().describe('Trade ID'), + productId: z.string().optional().describe('Product ID'), + price: z.string().optional().describe('Trade price'), + size: z.string().optional().describe('Trade size'), + time: z.string().optional().describe('Trade timestamp'), + side: z.string().optional().describe('Trade side'), + exchange: z.string().optional().describe('Exchange') +}); + +let bookLevelSchema = z.object({ + price: z.string().optional().describe('Price level'), + size: z.string().optional().describe('Size at level') +}); + +export let getMarketData = SlateTool.create(spec, { + name: 'Get Market Data', + key: 'get_market_data', + description: + 'Get Advanced Trade market data for a product: recent trades and best bid/ask, or order book levels.', + tags: { + destructive: false, + readOnly: true + } +}) + .authMethods(coinbaseOAuthAuthMethods) + .input( + z.object({ + action: z.enum(['ticker', 'book']).describe('Market data view to retrieve'), + productId: z.string().describe('Trading pair (e.g., BTC-USD)'), + limit: z.number().optional().describe('Number of trades or order book levels'), + start: z.string().optional().describe('Unix timestamp start filter for ticker trades'), + end: z.string().optional().describe('Unix timestamp end filter for ticker trades'), + aggregationPriceIncrement: z + .string() + .optional() + .describe('Order book price aggregation increment') + }) + ) + .output( + z.object({ + ticker: z + .object({ + bestBid: z.string().optional(), + bestAsk: z.string().optional(), + trades: z.array(tradeSchema).optional() + }) + .optional() + .describe('Ticker trades and best bid/ask'), + orderBook: z + .object({ + productId: z.string().optional(), + bids: z.array(bookLevelSchema).optional(), + asks: z.array(bookLevelSchema).optional(), + time: z.string().optional(), + last: z.string().optional(), + midMarket: z.string().optional(), + spreadBps: z.string().optional(), + spreadAbsolute: z.string().optional() + }) + .optional() + .describe('Order book snapshot') + }) + ) + .handleInvocation(async ctx => { + let client = new AdvancedTradeClient({ token: ctx.auth.token }); + + if (ctx.input.action === 'book') { + let result = await client.getProductBook(ctx.input.productId, { + limit: ctx.input.limit, + aggregationPriceIncrement: ctx.input.aggregationPriceIncrement + }); + let pricebook = result.pricebook || {}; + + return { + output: { + orderBook: { + productId: pricebook.product_id, + bids: pricebook.bids || [], + asks: pricebook.asks || [], + time: pricebook.time, + last: result.last, + midMarket: result.mid_market, + spreadBps: result.spread_bps, + spreadAbsolute: result.spread_absolute + } + }, + message: `Retrieved order book for **${ctx.input.productId}**` + }; + } + + let result = await client.getProductTicker(ctx.input.productId, { + limit: ctx.input.limit, + start: ctx.input.start, + end: ctx.input.end + }); + + return { + output: { + ticker: { + bestBid: result.best_bid, + bestAsk: result.best_ask, + trades: (result.trades || []).map((trade: any) => ({ + tradeId: trade.trade_id, + productId: trade.product_id, + price: trade.price, + size: trade.size, + time: trade.time, + side: trade.side, + exchange: trade.exchange + })) + } + }, + message: `Retrieved market trades for **${ctx.input.productId}**` + }; + }) + .build(); diff --git a/integrations/coinbase/src/tools/get-prices.ts b/integrations/coinbase/src/tools/get-prices.ts index 9f15e44b62..1b73139238 100644 --- a/integrations/coinbase/src/tools/get-prices.ts +++ b/integrations/coinbase/src/tools/get-prices.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { CoinbaseClient } from '../lib/client'; import { spec } from '../spec'; @@ -12,6 +13,7 @@ export let getPrices = SlateTool.create(spec, { readOnly: true } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ currencyPair: z.string().describe('Currency pair (e.g., "BTC-USD", "ETH-EUR")'), diff --git a/integrations/coinbase/src/tools/get-transaction-summary.ts b/integrations/coinbase/src/tools/get-transaction-summary.ts new file mode 100644 index 0000000000..6db29fcba0 --- /dev/null +++ b/integrations/coinbase/src/tools/get-transaction-summary.ts @@ -0,0 +1,94 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AdvancedTradeClient } from '../lib/advanced-trade-client'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; +import { spec } from '../spec'; + +export let getTransactionSummary = SlateTool.create(spec, { + name: 'Get Transaction Summary', + key: 'get_transaction_summary', + description: + 'Get an Advanced Trade transaction summary, including fee tier, volume, fees, and total balance.', + tags: { + destructive: false, + readOnly: true + } +}) + .authMethods(coinbaseOAuthAuthMethods) + .input( + z.object({ + productType: z + .enum(['UNKNOWN_PRODUCT_TYPE', 'SPOT', 'FUTURE']) + .optional() + .describe('Product type filter'), + contractExpiryType: z + .enum(['UNKNOWN_CONTRACT_EXPIRY_TYPE', 'EXPIRING', 'PERPETUAL']) + .optional() + .describe('Contract expiry filter for futures'), + productVenue: z + .enum(['UNKNOWN_VENUE_TYPE', 'CBE', 'FCM', 'INTX']) + .optional() + .describe('Product venue filter') + }) + ) + .output( + z.object({ + totalFees: z.number().optional().describe('Total fees across assets in USD'), + totalBalance: z.string().optional().describe('Total balance'), + advancedTradeOnlyVolume: z.number().optional().describe('Advanced Trade-only volume'), + advancedTradeOnlyFees: z.number().optional().describe('Advanced Trade-only fees'), + coinbaseProVolume: z.number().optional().describe('Coinbase Pro volume'), + coinbaseProFees: z.number().optional().describe('Coinbase Pro fees'), + hasCostPlusCommission: z.boolean().optional().describe('Cost-plus commission flag'), + feeTier: z + .object({ + pricingTier: z.string().optional(), + takerFeeRate: z.string().optional(), + makerFeeRate: z.string().optional(), + aopFrom: z.string().optional(), + aopTo: z.string().optional() + }) + .optional() + .describe('Current fee tier'), + volumeBreakdown: z + .array( + z.object({ + volumeType: z.string().optional(), + volume: z.number().optional() + }) + ) + .optional() + .describe('Volume by product type') + }) + ) + .handleInvocation(async ctx => { + let client = new AdvancedTradeClient({ token: ctx.auth.token }); + let summary = await client.getTransactionSummary(ctx.input); + + return { + output: { + totalFees: summary.total_fees, + totalBalance: summary.total_balance, + advancedTradeOnlyVolume: summary.advanced_trade_only_volume, + advancedTradeOnlyFees: summary.advanced_trade_only_fees, + coinbaseProVolume: summary.coinbase_pro_volume, + coinbaseProFees: summary.coinbase_pro_fees, + hasCostPlusCommission: summary.has_cost_plus_commission, + feeTier: summary.fee_tier + ? { + pricingTier: summary.fee_tier.pricing_tier, + takerFeeRate: summary.fee_tier.taker_fee_rate, + makerFeeRate: summary.fee_tier.maker_fee_rate, + aopFrom: summary.fee_tier.aop_from, + aopTo: summary.fee_tier.aop_to + } + : undefined, + volumeBreakdown: (summary.volume_breakdown || []).map((item: any) => ({ + volumeType: item.volume_type, + volume: item.volume + })) + }, + message: `Retrieved transaction summary${summary.fee_tier?.pricing_tier ? ` for fee tier **${summary.fee_tier.pricing_tier}**` : ''}` + }; + }) + .build(); diff --git a/integrations/coinbase/src/tools/get-user-profile.ts b/integrations/coinbase/src/tools/get-user-profile.ts index b45241cac6..256b032b3a 100644 --- a/integrations/coinbase/src/tools/get-user-profile.ts +++ b/integrations/coinbase/src/tools/get-user-profile.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { CoinbaseClient } from '../lib/client'; import { spec } from '../spec'; @@ -12,6 +13,7 @@ export let getUserProfile = SlateTool.create(spec, { readOnly: true } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ includePaymentMethods: z diff --git a/integrations/coinbase/src/tools/index.ts b/integrations/coinbase/src/tools/index.ts index 3cff1cef77..70ae7b80c6 100644 --- a/integrations/coinbase/src/tools/index.ts +++ b/integrations/coinbase/src/tools/index.ts @@ -2,12 +2,17 @@ export * from './buy-sell-crypto'; export * from './deposit-withdraw'; export * from './get-candles'; export * from './get-exchange-rates'; +export * from './get-market-data'; export * from './get-prices'; +export * from './get-transaction-summary'; export * from './get-user-profile'; +export * from './list-fills'; +export * from './list-payment-methods'; export * from './list-products'; export * from './list-transactions'; export * from './manage-accounts'; export * from './manage-addresses'; export * from './manage-commerce-charges'; export * from './manage-orders'; +export * from './manage-portfolios'; export * from './send-crypto'; diff --git a/integrations/coinbase/src/tools/list-fills.ts b/integrations/coinbase/src/tools/list-fills.ts new file mode 100644 index 0000000000..55f4dad401 --- /dev/null +++ b/integrations/coinbase/src/tools/list-fills.ts @@ -0,0 +1,120 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AdvancedTradeClient } from '../lib/advanced-trade-client'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; +import { spec } from '../spec'; + +let fillSchema = z.object({ + fillId: z.string().optional().describe('Fill entry ID'), + tradeId: z.string().optional().describe('Trade ID'), + orderId: z.string().optional().describe('Order ID'), + productId: z.string().optional().describe('Product ID'), + tradeTime: z.string().optional().describe('Trade timestamp'), + tradeType: z.string().optional().describe('Trade type'), + price: z.string().optional().describe('Fill price'), + size: z.string().optional().describe('Fill size'), + commission: z.string().optional().describe('Commission amount'), + side: z.string().optional().describe('BUY or SELL'), + liquidityIndicator: z.string().optional().describe('Liquidity indicator'), + sequenceTimestamp: z.string().optional().describe('Sequence timestamp'), + sizeInQuote: z.boolean().optional().describe('Whether size is denominated in quote'), + retailPortfolioId: z.string().optional().describe('Retail portfolio ID') +}); + +let mapFill = (fill: any): z.infer => ({ + fillId: fill.entry_id, + tradeId: fill.trade_id, + orderId: fill.order_id, + productId: fill.product_id, + tradeTime: fill.trade_time, + tradeType: fill.trade_type, + price: fill.price, + size: fill.size, + commission: fill.commission, + side: fill.side, + liquidityIndicator: fill.liquidity_indicator, + sequenceTimestamp: fill.sequence_timestamp, + sizeInQuote: fill.size_in_quote, + retailPortfolioId: fill.retail_portfolio_id +}); + +export let listFills = SlateTool.create(spec, { + name: 'List Fills', + key: 'list_fills', + description: + 'List Advanced Trade fills, filtered by order, trade, product, asset, side, type, or time range.', + tags: { + destructive: false, + readOnly: true + } +}) + .authMethods(coinbaseOAuthAuthMethods) + .input( + z.object({ + orderIds: z.array(z.string()).optional().describe('Order IDs to filter by'), + tradeIds: z.array(z.string()).optional().describe('Trade IDs to filter by'), + productIds: z.array(z.string()).optional().describe('Product IDs to filter by'), + startSequenceTimestamp: z + .string() + .optional() + .describe('Only fills after this RFC3339 timestamp'), + endSequenceTimestamp: z + .string() + .optional() + .describe('Only fills before this RFC3339 timestamp'), + retailPortfolioId: z.string().optional().describe('Retail portfolio ID filter'), + limit: z.number().optional().describe('Number of fills to return'), + cursor: z.string().optional().describe('Pagination cursor'), + sortBy: z.enum(['UNKNOWN_SORT_BY', 'PRICE', 'TRADE_TIME']).optional(), + assetFilters: z.array(z.string()).optional().describe('Asset symbols to filter by'), + orderTypes: z + .array( + z.enum([ + 'UNKNOWN_ORDER_TYPE', + 'MARKET', + 'LIMIT', + 'STOP', + 'STOP_LIMIT', + 'BRACKET', + 'TWAP', + 'ROLL_OPEN', + 'ROLL_CLOSE', + 'LIQUIDATION', + 'SCALED' + ]) + ) + .optional() + .describe('Order types to filter by'), + orderSide: z.enum(['BUY', 'SELL']).optional().describe('Order side filter'), + productTypes: z + .array(z.enum(['UNKNOWN_PRODUCT_TYPE', 'SPOT', 'FUTURE'])) + .optional() + .describe('Product types to filter by'), + proofToken: z + .string() + .optional() + .describe('Optional proof token for SCA-protected fill history') + }) + ) + .output( + z.object({ + fills: z.array(fillSchema).describe('Matching fills'), + cursor: z.string().optional().describe('Next page cursor'), + proofTokenRequired: z.boolean().optional().describe('Whether proof token is required') + }) + ) + .handleInvocation(async ctx => { + let client = new AdvancedTradeClient({ token: ctx.auth.token }); + let result = await client.listFills(ctx.input); + let fills = result.fills || []; + + return { + output: { + fills: fills.map(mapFill), + cursor: result.cursor, + proofTokenRequired: result.proof_token_required + }, + message: `Found **${fills.length}** fill(s)` + }; + }) + .build(); diff --git a/integrations/coinbase/src/tools/list-payment-methods.ts b/integrations/coinbase/src/tools/list-payment-methods.ts new file mode 100644 index 0000000000..8f660b0398 --- /dev/null +++ b/integrations/coinbase/src/tools/list-payment-methods.ts @@ -0,0 +1,85 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AdvancedTradeClient } from '../lib/advanced-trade-client'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; +import { spec } from '../spec'; + +let paymentMethodSchema = z.object({ + paymentMethodId: z.string().describe('Payment method ID'), + paymentMethodType: z.string().optional().describe('Payment method type (ACH, CARD, etc.)'), + name: z.string().optional().describe('Display name'), + currency: z.string().optional().describe('Currency code'), + verified: z.boolean().optional().describe('Whether the method is verified'), + allowBuy: z.boolean().optional().describe('Whether buys are allowed'), + allowSell: z.boolean().optional().describe('Whether sells are allowed'), + allowDeposit: z.boolean().optional().describe('Whether deposits are allowed'), + allowWithdraw: z.boolean().optional().describe('Whether withdrawals are allowed'), + createdAt: z.string().optional().describe('Creation timestamp'), + updatedAt: z.string().optional().describe('Update timestamp') +}); + +let mapPaymentMethod = (paymentMethod: any): z.infer => ({ + paymentMethodId: paymentMethod.id, + paymentMethodType: paymentMethod.type, + name: paymentMethod.name, + currency: paymentMethod.currency, + verified: paymentMethod.verified, + allowBuy: paymentMethod.allow_buy, + allowSell: paymentMethod.allow_sell, + allowDeposit: paymentMethod.allow_deposit, + allowWithdraw: paymentMethod.allow_withdraw, + createdAt: paymentMethod.created_at, + updatedAt: paymentMethod.updated_at +}); + +export let listPaymentMethods = SlateTool.create(spec, { + name: 'List Payment Methods', + key: 'list_payment_methods', + description: + 'List linked Coinbase payment methods, or get one payment method by ID. Useful before buys, sells, deposits, and withdrawals.', + tags: { + destructive: false, + readOnly: true + } +}) + .authMethods(coinbaseOAuthAuthMethods) + .input( + z.object({ + paymentMethodId: z + .string() + .optional() + .describe('Specific payment method ID to retrieve. If omitted, lists methods.') + }) + ) + .output( + z.object({ + paymentMethod: paymentMethodSchema.optional().describe('Single payment method'), + paymentMethods: z + .array(paymentMethodSchema) + .optional() + .describe('Linked payment methods') + }) + ) + .handleInvocation(async ctx => { + let client = new AdvancedTradeClient({ token: ctx.auth.token }); + + if (ctx.input.paymentMethodId) { + let paymentMethod = await client.getPaymentMethod(ctx.input.paymentMethodId); + return { + output: { + paymentMethod: mapPaymentMethod(paymentMethod) + }, + message: `Retrieved payment method **${paymentMethod.name || paymentMethod.id}**` + }; + } + + let result = await client.listPaymentMethods(); + let paymentMethods = result.payment_methods || []; + return { + output: { + paymentMethods: paymentMethods.map(mapPaymentMethod) + }, + message: `Found **${paymentMethods.length}** payment method(s)` + }; + }) + .build(); diff --git a/integrations/coinbase/src/tools/list-products.ts b/integrations/coinbase/src/tools/list-products.ts index 82ba85d469..57ddec3ad8 100644 --- a/integrations/coinbase/src/tools/list-products.ts +++ b/integrations/coinbase/src/tools/list-products.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AdvancedTradeClient } from '../lib/advanced-trade-client'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { spec } from '../spec'; export let listProducts = SlateTool.create(spec, { @@ -12,6 +13,7 @@ export let listProducts = SlateTool.create(spec, { readOnly: true } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ productId: z diff --git a/integrations/coinbase/src/tools/list-transactions.ts b/integrations/coinbase/src/tools/list-transactions.ts index 70ff721da6..121e282971 100644 --- a/integrations/coinbase/src/tools/list-transactions.ts +++ b/integrations/coinbase/src/tools/list-transactions.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { CoinbaseClient } from '../lib/client'; import { spec } from '../spec'; @@ -24,6 +25,7 @@ export let listTransactions = SlateTool.create(spec, { readOnly: true } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ accountId: z.string().describe('Account ID to list transactions for'), diff --git a/integrations/coinbase/src/tools/manage-accounts.ts b/integrations/coinbase/src/tools/manage-accounts.ts index 33656280db..d8826d32f5 100644 --- a/integrations/coinbase/src/tools/manage-accounts.ts +++ b/integrations/coinbase/src/tools/manage-accounts.ts @@ -1,6 +1,8 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { CoinbaseClient } from '../lib/client'; +import { coinbaseServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageAccounts = SlateTool.create(spec, { @@ -12,6 +14,7 @@ export let manageAccounts = SlateTool.create(spec, { readOnly: false } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ action: z @@ -64,7 +67,9 @@ export let manageAccounts = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); + if (!ctx.input.name) { + throw coinbaseServiceError('name is required for create action'); + } let account = await client.createAccount(ctx.input.name); return { output: { @@ -82,7 +87,9 @@ export let manageAccounts = SlateTool.create(spec, { } if (action === 'get') { - if (!ctx.input.accountId) throw new Error('accountId is required for get action'); + if (!ctx.input.accountId) { + throw coinbaseServiceError('accountId is required for get action'); + } let account = await client.getAccount(ctx.input.accountId); return { output: { @@ -100,8 +107,12 @@ export let manageAccounts = SlateTool.create(spec, { } if (action === 'update') { - if (!ctx.input.accountId) throw new Error('accountId is required for update action'); - if (!ctx.input.name) throw new Error('name is required for update action'); + if (!ctx.input.accountId) { + throw coinbaseServiceError('accountId is required for update action'); + } + if (!ctx.input.name) { + throw coinbaseServiceError('name is required for update action'); + } let account = await client.updateAccount(ctx.input.accountId, ctx.input.name); return { output: { @@ -119,7 +130,9 @@ export let manageAccounts = SlateTool.create(spec, { } if (action === 'delete') { - if (!ctx.input.accountId) throw new Error('accountId is required for delete action'); + if (!ctx.input.accountId) { + throw coinbaseServiceError('accountId is required for delete action'); + } await client.deleteAccount(ctx.input.accountId); return { output: { diff --git a/integrations/coinbase/src/tools/manage-addresses.ts b/integrations/coinbase/src/tools/manage-addresses.ts index 08020486b2..aa385928c4 100644 --- a/integrations/coinbase/src/tools/manage-addresses.ts +++ b/integrations/coinbase/src/tools/manage-addresses.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { CoinbaseClient } from '../lib/client'; import { spec } from '../spec'; @@ -12,6 +13,7 @@ export let manageAddresses = SlateTool.create(spec, { readOnly: false } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ action: z.enum(['list', 'create']).describe('Operation to perform'), diff --git a/integrations/coinbase/src/tools/manage-commerce-charges.ts b/integrations/coinbase/src/tools/manage-commerce-charges.ts index 0dc2965c0b..e294cd0f70 100644 --- a/integrations/coinbase/src/tools/manage-commerce-charges.ts +++ b/integrations/coinbase/src/tools/manage-commerce-charges.ts @@ -1,6 +1,8 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseCommerceAuthMethods } from '../lib/auth-methods'; import { CommerceClient } from '../lib/commerce-client'; +import { coinbaseServiceError } from '../lib/errors'; import { spec } from '../spec'; let chargeSchema = z.object({ @@ -40,6 +42,7 @@ export let manageCommerceCharges = SlateTool.create(spec, { readOnly: false } }) + .authMethods(coinbaseCommerceAuthMethods) .input( z.object({ action: z @@ -85,9 +88,19 @@ export let manageCommerceCharges = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create'); - if (!ctx.input.description) throw new Error('description is required for create'); - if (!ctx.input.pricingType) throw new Error('pricingType is required for create'); + if (!ctx.input.name) throw coinbaseServiceError('name is required for create'); + if (!ctx.input.description) { + throw coinbaseServiceError('description is required for create'); + } + if (!ctx.input.pricingType) { + throw coinbaseServiceError('pricingType is required for create'); + } + if ( + ctx.input.pricingType === 'fixed_price' && + (!ctx.input.amount || !ctx.input.currency) + ) { + throw coinbaseServiceError('amount and currency are required for fixed_price charges'); + } let localPrice = ctx.input.pricingType === 'fixed_price' && ctx.input.amount && ctx.input.currency @@ -113,7 +126,9 @@ export let manageCommerceCharges = SlateTool.create(spec, { } if (action === 'get') { - if (!ctx.input.chargeCodeOrId) throw new Error('chargeCodeOrId is required for get'); + if (!ctx.input.chargeCodeOrId) { + throw coinbaseServiceError('chargeCodeOrId is required for get'); + } let charge = await client.getCharge(ctx.input.chargeCodeOrId); return { output: { charge: mapCharge(charge) }, @@ -122,7 +137,9 @@ export let manageCommerceCharges = SlateTool.create(spec, { } if (action === 'cancel') { - if (!ctx.input.chargeCodeOrId) throw new Error('chargeCodeOrId is required for cancel'); + if (!ctx.input.chargeCodeOrId) { + throw coinbaseServiceError('chargeCodeOrId is required for cancel'); + } let charge = await client.cancelCharge(ctx.input.chargeCodeOrId); return { output: { charge: mapCharge(charge) }, @@ -131,7 +148,9 @@ export let manageCommerceCharges = SlateTool.create(spec, { } if (action === 'resolve') { - if (!ctx.input.chargeCodeOrId) throw new Error('chargeCodeOrId is required for resolve'); + if (!ctx.input.chargeCodeOrId) { + throw coinbaseServiceError('chargeCodeOrId is required for resolve'); + } let charge = await client.resolveCharge(ctx.input.chargeCodeOrId); return { output: { charge: mapCharge(charge) }, diff --git a/integrations/coinbase/src/tools/manage-orders.ts b/integrations/coinbase/src/tools/manage-orders.ts index 3fca1c7c5e..f4a644a2fa 100644 --- a/integrations/coinbase/src/tools/manage-orders.ts +++ b/integrations/coinbase/src/tools/manage-orders.ts @@ -1,6 +1,8 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AdvancedTradeClient } from '../lib/advanced-trade-client'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; +import { coinbaseServiceError } from '../lib/errors'; import { spec } from '../spec'; let orderOutputSchema = z.object({ @@ -17,66 +19,207 @@ let orderOutputSchema = z.object({ completionPercentage: z.string().optional().describe('Completion percentage') }); +let previewOutputSchema = z.object({ + previewId: z.string().optional().describe('Preview ID returned by Coinbase'), + orderTotal: z.string().optional().describe('Estimated total order value'), + commissionTotal: z.string().optional().describe('Estimated total commission'), + quoteSize: z.string().optional().describe('Quote size'), + baseSize: z.string().optional().describe('Base size'), + bestBid: z.string().optional().describe('Best bid at preview time'), + bestAsk: z.string().optional().describe('Best ask at preview time'), + estimatedAverageFilledPrice: z + .string() + .optional() + .describe('Estimated average filled price'), + slippage: z.string().optional().describe('Estimated slippage'), + errors: z.array(z.string()).optional().describe('Preview errors returned by Coinbase'), + warnings: z.array(z.string()).optional().describe('Preview warnings returned by Coinbase') +}); + +let manageOrdersInputSchema = z.object({ + action: z + .enum(['preview', 'create', 'list', 'get', 'cancel']) + .describe('Operation to perform'), + orderId: z.string().optional().describe('Order ID (required for get)'), + orderIds: z + .array(z.string()) + .optional() + .describe('Order IDs to cancel (required for cancel)'), + productId: z + .string() + .optional() + .describe( + 'Trading pair, e.g., "BTC-USD" (required for preview/create, optional filter for list)' + ), + side: z + .enum(['BUY', 'SELL']) + .optional() + .describe('Order side (required for preview/create)'), + orderType: z + .enum(['market', 'limit_gtc', 'limit_gtd', 'stop_limit_gtc', 'stop_limit_gtd']) + .optional() + .describe('Order type (required for preview/create)'), + baseSize: z.string().optional().describe('Amount of base currency (crypto) to trade'), + quoteSize: z + .string() + .optional() + .describe('Amount of quote currency (fiat) to spend; required for market buys'), + limitPrice: z + .string() + .optional() + .describe('Limit price; required for limit and stop-limit orders'), + stopPrice: z.string().optional().describe('Stop trigger price for stop-limit orders'), + stopDirection: z + .enum(['STOP_DIRECTION_STOP_UP', 'STOP_DIRECTION_STOP_DOWN']) + .optional() + .describe('Stop direction for stop-limit orders'), + endTime: z.string().optional().describe('Expiration time for GTD orders (ISO 8601)'), + postOnly: z.boolean().optional().describe('Whether limit orders should be post-only'), + orderStatus: z + .array(z.string()) + .optional() + .describe('Filter by status for list (e.g., ["OPEN", "FILLED"])'), + limit: z.number().optional().describe('Max results to return (for list)'), + cursor: z.string().optional().describe('Pagination cursor (for list)') +}); + +type ManageOrdersInput = z.infer; + +let requireValue = (value: T | undefined | null, message: string): T => { + if (value === undefined || value === null || value === '') { + throw coinbaseServiceError(message); + } + return value; +}; + +let buildOrderConfiguration = (input: ManageOrdersInput) => { + requireValue(input.productId, 'productId is required for preview/create'); + let side = requireValue(input.side, 'side is required for preview/create'); + let orderType = requireValue(input.orderType, 'orderType is required for preview/create'); + + if (orderType === 'market') { + if (side === 'BUY') { + let quoteSize = requireValue( + input.quoteSize, + 'quoteSize is required for market buy orders' + ); + return { market_market_ioc: { quote_size: quoteSize } }; + } + + let baseSize = requireValue(input.baseSize, 'baseSize is required for market sell orders'); + return { market_market_ioc: { base_size: baseSize } }; + } + + if (orderType === 'limit_gtc' || orderType === 'limit_gtd') { + let baseSize = requireValue(input.baseSize, 'baseSize is required for limit orders'); + let limitPrice = requireValue(input.limitPrice, 'limitPrice is required for limit orders'); + + let limitConfig: Record = { + base_size: baseSize, + limit_price: limitPrice, + post_only: input.postOnly ?? false + }; + if (input.quoteSize) limitConfig.quote_size = input.quoteSize; + + if (orderType === 'limit_gtd') { + limitConfig.end_time = requireValue( + input.endTime, + 'endTime is required for limit_gtd orders' + ); + return { limit_limit_gtd: limitConfig }; + } + + return { limit_limit_gtc: limitConfig }; + } + + let baseSize = requireValue(input.baseSize, 'baseSize is required for stop-limit orders'); + let limitPrice = requireValue( + input.limitPrice, + 'limitPrice is required for stop-limit orders' + ); + let stopPrice = requireValue(input.stopPrice, 'stopPrice is required for stop-limit orders'); + let stopDirection = requireValue( + input.stopDirection, + 'stopDirection is required for stop-limit orders' + ); + + let stopLimitConfig: Record = { + base_size: baseSize, + limit_price: limitPrice, + stop_price: stopPrice, + stop_direction: stopDirection + }; + + if (orderType === 'stop_limit_gtd') { + stopLimitConfig.end_time = requireValue( + input.endTime, + 'endTime is required for stop_limit_gtd orders' + ); + return { stop_limit_stop_limit_gtd: stopLimitConfig }; + } + + return { stop_limit_stop_limit_gtc: stopLimitConfig }; +}; + +let requireOrderId = (order: any) => { + let orderId = order?.order_id || order?.id; + if (!orderId) { + throw coinbaseServiceError('Coinbase order response did not include an order ID.'); + } + return String(orderId); +}; + +let mapOrder = (order: any): z.infer => ({ + orderId: requireOrderId(order), + productId: order.product_id, + side: order.side, + orderType: order.order_type, + status: order.status, + filledSize: order.filled_size, + filledValue: order.filled_value, + averageFilledPrice: order.average_filled_price, + totalFees: order.total_fees, + createdTime: order.created_time, + completionPercentage: order.completion_percentage +}); + +let mapPreview = (preview: any): z.infer => ({ + previewId: preview.preview_id, + orderTotal: preview.order_total, + commissionTotal: preview.commission_total, + quoteSize: preview.quote_size === undefined ? undefined : String(preview.quote_size), + baseSize: preview.base_size === undefined ? undefined : String(preview.base_size), + bestBid: preview.best_bid, + bestAsk: preview.best_ask, + estimatedAverageFilledPrice: preview.est_average_filled_price, + slippage: preview.slippage, + errors: preview.errs, + warnings: preview.warning +}); + export let manageOrders = SlateTool.create(spec, { name: 'Manage Orders', key: 'manage_orders', - description: `Create, list, get, or cancel trading orders via the Advanced Trade API. Supports market, limit, and stop-limit orders across 550+ markets. Use **action** to specify the operation.`, + description: `Preview, create, list, get, or cancel trading orders via the Advanced Trade API. Supports market, limit, and stop-limit orders. Use **action** to specify the operation.`, instructions: [ - 'For market buy: provide quoteSize (fiat amount) in orderConfiguration.', - 'For market sell: provide baseSize (crypto amount) in orderConfiguration.', - 'For limit orders: provide baseSize, limitPrice, and optionally endTime.', - 'For stop-limit orders: provide baseSize, limitPrice, and stopPrice.' + 'Use action=preview to validate and estimate an order before creating it.', + 'For market buy: provide quoteSize.', + 'For market sell: provide baseSize.', + 'For limit orders: provide baseSize, limitPrice, and optionally postOnly.', + 'For stop-limit orders: provide baseSize, limitPrice, stopPrice, and stopDirection.' ], tags: { - destructive: false, + destructive: true, readOnly: false } }) - .input( - z.object({ - action: z.enum(['create', 'list', 'get', 'cancel']).describe('Operation to perform'), - orderId: z.string().optional().describe('Order ID (required for get)'), - orderIds: z - .array(z.string()) - .optional() - .describe('Order IDs to cancel (required for cancel)'), - productId: z - .string() - .optional() - .describe( - 'Trading pair, e.g., "BTC-USD" (required for create, optional filter for list)' - ), - side: z.enum(['BUY', 'SELL']).optional().describe('Order side (required for create)'), - orderType: z - .enum(['market', 'limit_gtc', 'limit_gtd', 'stop_limit_gtc', 'stop_limit_gtd']) - .optional() - .describe('Order type (required for create)'), - baseSize: z.string().optional().describe('Amount of base currency (crypto) to trade'), - quoteSize: z - .string() - .optional() - .describe('Amount of quote currency (fiat) to spend — market buy only'), - limitPrice: z - .string() - .optional() - .describe('Limit price — required for limit and stop-limit orders'), - stopPrice: z - .string() - .optional() - .describe('Stop/trigger price — required for stop-limit orders'), - endTime: z.string().optional().describe('Expiration time for GTD orders (ISO 8601)'), - orderStatus: z - .array(z.string()) - .optional() - .describe('Filter by status for list (e.g., ["OPEN", "FILLED"])'), - limit: z.number().optional().describe('Max results to return (for list)'), - cursor: z.string().optional().describe('Pagination cursor (for list)') - }) - ) + .authMethods(coinbaseOAuthAuthMethods) + .input(manageOrdersInputSchema) .output( z.object({ order: orderOutputSchema.optional().describe('Single order details'), orders: z.array(orderOutputSchema).optional().describe('List of orders'), + preview: previewOutputSchema.optional().describe('Order preview details'), successfulOrderIds: z .array(z.string()) .optional() @@ -90,98 +233,66 @@ export let manageOrders = SlateTool.create(spec, { let client = new AdvancedTradeClient({ token: ctx.auth.token }); let { action } = ctx.input; - if (action === 'create') { - if (!ctx.input.productId) throw new Error('productId is required for create'); - if (!ctx.input.side) throw new Error('side is required for create'); - if (!ctx.input.orderType) throw new Error('orderType is required for create'); - - let orderConfig: Record = {}; - - if (ctx.input.orderType === 'market') { - if (ctx.input.side === 'BUY') { - orderConfig.market_market_ioc = { quote_size: ctx.input.quoteSize }; - } else { - orderConfig.market_market_ioc = { base_size: ctx.input.baseSize }; - } - } else if (ctx.input.orderType === 'limit_gtc') { - orderConfig.limit_limit_gtc = { - base_size: ctx.input.baseSize, - limit_price: ctx.input.limitPrice, - post_only: false - }; - } else if (ctx.input.orderType === 'limit_gtd') { - orderConfig.limit_limit_gtd = { - base_size: ctx.input.baseSize, - limit_price: ctx.input.limitPrice, - end_time: ctx.input.endTime, - post_only: false - }; - } else if (ctx.input.orderType === 'stop_limit_gtc') { - orderConfig.stop_limit_stop_limit_gtc = { - base_size: ctx.input.baseSize, - limit_price: ctx.input.limitPrice, - stop_price: ctx.input.stopPrice - }; - } else if (ctx.input.orderType === 'stop_limit_gtd') { - orderConfig.stop_limit_stop_limit_gtd = { - base_size: ctx.input.baseSize, - limit_price: ctx.input.limitPrice, - stop_price: ctx.input.stopPrice, - end_time: ctx.input.endTime - }; - } + if (action === 'preview') { + let preview = await client.previewOrder({ + productId: ctx.input.productId!, + side: ctx.input.side!, + orderConfiguration: buildOrderConfiguration(ctx.input) + }); + + return { + output: { + preview: mapPreview(preview) + }, + message: `Previewed ${ctx.input.side} ${ctx.input.orderType} order for **${ctx.input.productId}**` + }; + } - let clientOrderId = `slate_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + if (action === 'create') { let result = await client.createOrder({ - clientOrderId, - productId: ctx.input.productId, - side: ctx.input.side, - orderConfiguration: orderConfig + clientOrderId: `slate_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + productId: ctx.input.productId!, + side: ctx.input.side!, + orderConfiguration: buildOrderConfiguration(ctx.input) }); + if (!result.success) { + let failure = result.error_response || {}; + throw coinbaseServiceError( + `Order creation failed: ${failure.message || failure.error_details || failure.error || 'unknown error'}` + ); + } + let order = result.success_response || result; return { output: { order: { - orderId: order.order_id || order.id, - productId: ctx.input.productId, - side: ctx.input.side, + orderId: requireOrderId(order), + productId: order.product_id || ctx.input.productId, + side: order.side || ctx.input.side, orderType: ctx.input.orderType, - status: result.success ? 'PENDING' : 'FAILED' + status: 'PENDING' } }, - message: result.success - ? `Created ${ctx.input.side} ${ctx.input.orderType} order for **${ctx.input.productId}**` - : `Order creation failed: ${result.failure_reason || 'unknown error'}` + message: `Created ${ctx.input.side} ${ctx.input.orderType} order for **${ctx.input.productId}**` }; } if (action === 'get') { - if (!ctx.input.orderId) throw new Error('orderId is required for get'); + if (!ctx.input.orderId) throw coinbaseServiceError('orderId is required for get'); let order = await client.getOrder(ctx.input.orderId); return { output: { - order: { - orderId: order.order_id, - productId: order.product_id, - side: order.side, - orderType: order.order_type, - status: order.status, - filledSize: order.filled_size, - filledValue: order.filled_value, - averageFilledPrice: order.average_filled_price, - totalFees: order.total_fees, - createdTime: order.created_time, - completionPercentage: order.completion_percentage - } + order: mapOrder(order) }, message: `Order **${order.order_id}** — ${order.side} ${order.product_id} — Status: ${order.status}` }; } if (action === 'cancel') { - if (!ctx.input.orderIds || ctx.input.orderIds.length === 0) - throw new Error('orderIds is required for cancel'); + if (!ctx.input.orderIds || ctx.input.orderIds.length === 0) { + throw coinbaseServiceError('orderIds is required for cancel'); + } let result = await client.cancelOrders(ctx.input.orderIds); let results = result.results || []; let successful = results.filter((r: any) => r.success).map((r: any) => r.order_id); @@ -195,7 +306,6 @@ export let manageOrders = SlateTool.create(spec, { }; } - // list let result = await client.listOrders({ productId: ctx.input.productId, orderStatus: ctx.input.orderStatus, @@ -206,19 +316,7 @@ export let manageOrders = SlateTool.create(spec, { let orders = result.orders || []; return { output: { - orders: orders.map((o: any) => ({ - orderId: o.order_id, - productId: o.product_id, - side: o.side, - orderType: o.order_type, - status: o.status, - filledSize: o.filled_size, - filledValue: o.filled_value, - averageFilledPrice: o.average_filled_price, - totalFees: o.total_fees, - createdTime: o.created_time, - completionPercentage: o.completion_percentage - })), + orders: orders.map(mapOrder), hasNext: result.has_next, cursor: result.cursor }, diff --git a/integrations/coinbase/src/tools/manage-portfolios.ts b/integrations/coinbase/src/tools/manage-portfolios.ts new file mode 100644 index 0000000000..861adeb59c --- /dev/null +++ b/integrations/coinbase/src/tools/manage-portfolios.ts @@ -0,0 +1,145 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AdvancedTradeClient } from '../lib/advanced-trade-client'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; +import { coinbaseServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let portfolioSchema = z.object({ + portfolioUuid: z.string().describe('Portfolio UUID'), + name: z.string().optional().describe('Portfolio name'), + portfolioType: z.string().optional().describe('Portfolio type'), + deleted: z.boolean().optional().describe('Whether the portfolio is deleted') +}); + +let balanceSchema = z.object({ + value: z.string().optional().describe('Balance value'), + currency: z.string().optional().describe('Balance currency') +}); + +let spotPositionSchema = z.object({ + asset: z.string().optional().describe('Asset symbol'), + accountUuid: z.string().optional().describe('Account UUID'), + totalBalanceFiat: z.number().optional().describe('Total balance in fiat'), + totalBalanceCrypto: z.number().optional().describe('Total balance in crypto'), + availableToTradeFiat: z.number().optional().describe('Available fiat to trade'), + availableToTradeCrypto: z.number().optional().describe('Available crypto to trade'), + availableToTransferFiat: z.number().optional().describe('Available fiat to transfer'), + availableToTransferCrypto: z.number().optional().describe('Available crypto to transfer'), + allocation: z.number().optional().describe('Portfolio allocation'), + costBasis: balanceSchema.optional().describe('Cost basis'), + averageEntryPrice: balanceSchema.optional().describe('Average entry price'), + unrealizedPnl: z.number().optional().describe('Unrealized profit and loss'), + accountType: z.string().optional().describe('Account type') +}); + +let mapPortfolio = (portfolio: any): z.infer => ({ + portfolioUuid: portfolio.uuid, + name: portfolio.name, + portfolioType: portfolio.type, + deleted: portfolio.deleted +}); + +let mapBalance = (value: any): z.infer | undefined => + value ? { value: value.value, currency: value.currency } : undefined; + +let mapSpotPosition = (position: any): z.infer => ({ + asset: position.asset, + accountUuid: position.account_uuid, + totalBalanceFiat: position.total_balance_fiat, + totalBalanceCrypto: position.total_balance_crypto, + availableToTradeFiat: position.available_to_trade_fiat, + availableToTradeCrypto: position.available_to_trade_crypto, + availableToTransferFiat: position.available_to_transfer_fiat, + availableToTransferCrypto: position.available_to_transfer_crypto, + allocation: position.allocation, + costBasis: mapBalance(position.cost_basis), + averageEntryPrice: mapBalance(position.average_entry_price), + unrealizedPnl: position.unrealized_pnl, + accountType: position.account_type +}); + +export let managePortfolios = SlateTool.create(spec, { + name: 'Manage Portfolios', + key: 'manage_portfolios', + description: + 'List Coinbase Advanced Trade portfolios or retrieve a portfolio breakdown with balances and spot positions.', + tags: { + destructive: false, + readOnly: true + } +}) + .authMethods(coinbaseOAuthAuthMethods) + .input( + z.object({ + action: z.enum(['list', 'get_breakdown']).describe('Operation to perform'), + portfolioUuid: z + .string() + .optional() + .describe('Portfolio UUID (required for get_breakdown)'), + portfolioType: z + .enum(['UNDEFINED', 'DEFAULT', 'CONSUMER', 'INTX']) + .optional() + .describe('Optional portfolio type filter for list') + }) + ) + .output( + z.object({ + portfolios: z.array(portfolioSchema).optional().describe('List of portfolios'), + portfolio: portfolioSchema.optional().describe('Portfolio details'), + portfolioBalances: z + .object({ + totalBalance: balanceSchema.optional(), + totalFuturesBalance: balanceSchema.optional(), + totalCashEquivalentBalance: balanceSchema.optional(), + totalCryptoBalance: balanceSchema.optional(), + futuresUnrealizedPnl: balanceSchema.optional(), + perpUnrealizedPnl: balanceSchema.optional() + }) + .optional() + .describe('Portfolio balance totals'), + spotPositions: z + .array(spotPositionSchema) + .optional() + .describe('Spot positions in the portfolio') + }) + ) + .handleInvocation(async ctx => { + let client = new AdvancedTradeClient({ token: ctx.auth.token }); + + if (ctx.input.action === 'get_breakdown') { + if (!ctx.input.portfolioUuid) { + throw coinbaseServiceError('portfolioUuid is required for get_breakdown'); + } + + let result = await client.getPortfolioBreakdown(ctx.input.portfolioUuid); + let breakdown = result.breakdown || {}; + let balances = breakdown.portfolio_balances || {}; + + return { + output: { + portfolio: breakdown.portfolio ? mapPortfolio(breakdown.portfolio) : undefined, + portfolioBalances: { + totalBalance: mapBalance(balances.total_balance), + totalFuturesBalance: mapBalance(balances.total_futures_balance), + totalCashEquivalentBalance: mapBalance(balances.total_cash_equivalent_balance), + totalCryptoBalance: mapBalance(balances.total_crypto_balance), + futuresUnrealizedPnl: mapBalance(balances.futures_unrealized_pnl), + perpUnrealizedPnl: mapBalance(balances.perp_unrealized_pnl) + }, + spotPositions: (breakdown.spot_positions || []).map(mapSpotPosition) + }, + message: `Retrieved portfolio breakdown for **${ctx.input.portfolioUuid}**` + }; + } + + let result = await client.listPortfolios({ portfolioType: ctx.input.portfolioType }); + let portfolios = result.portfolios || []; + return { + output: { + portfolios: portfolios.map(mapPortfolio) + }, + message: `Found **${portfolios.length}** portfolio(s)` + }; + }) + .build(); diff --git a/integrations/coinbase/src/tools/send-crypto.ts b/integrations/coinbase/src/tools/send-crypto.ts index 047c83720f..4698c77115 100644 --- a/integrations/coinbase/src/tools/send-crypto.ts +++ b/integrations/coinbase/src/tools/send-crypto.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { coinbaseOAuthAuthMethods } from '../lib/auth-methods'; import { CoinbaseClient } from '../lib/client'; import { spec } from '../spec'; @@ -17,6 +18,7 @@ export let sendCrypto = SlateTool.create(spec, { readOnly: false } }) + .authMethods(coinbaseOAuthAuthMethods) .input( z.object({ accountId: z.string().describe('Source account ID to send from'), diff --git a/integrations/convertkit/README.md b/integrations/convertkit/README.md index 1494ad03cd..42c576364c 100644 --- a/integrations/convertkit/README.md +++ b/integrations/convertkit/README.md @@ -1,52 +1,76 @@ -# Convertkit +# ConvertKit -Manage email subscribers, tags, forms, sequences, and broadcasts for creator-focused email marketing. Create and send broadcast emails, enroll subscribers in automated drip sequences, organize subscribers with tags and custom fields, import purchase data, and build subscriber segments. Supports webhooks for subscriber events including activations, unsubscribes, bounces, form subscriptions, tag changes, link clicks, and product purchases. +Manage Kit email marketing data for creator-focused newsletters, forms, sequences, broadcasts, purchases, posts, and reusable snippets. The integration uses Kit API V4 and supports OAuth 2.0 plus V4 API key authentication where the upstream API allows it. Purchase creation is OAuth-only in Kit API V4. ## Tools ### Create Purchase -Import a purchase/transaction record into Kit. Associates a purchase with a subscriber by email. Includes product details, pricing, and transaction metadata. +Import a purchase or transaction record into Kit and associate it with a subscriber by email. Includes product details, pricing, transaction metadata, and the created purchase id. ### Get Account -Retrieve your Kit account information including account name, plan type, primary email address, and timezone settings. +Retrieve Kit account information including account name, plan type, primary email address, timezone, and sending addresses returned by the API. + +### Get Account Insights + +Retrieve account-level insight data from Kit API V4, including creator profile details, email stats, or growth stats. ### List Email Templates -List all available email templates that can be used when creating broadcasts. +List available email templates that can be used when creating broadcasts. ### List Segments -List all subscriber segments. Segments are saved filters for grouping subscribers based on various criteria configured in the Kit dashboard. +List saved subscriber segments configured in the Kit dashboard. ### List Subscribers -List and search subscribers in your Kit account. Filter by status, email, date ranges, or retrieve subscribers for a specific tag, form, or sequence. +List and search subscribers in your Kit account. Filter by status, email, date ranges, or list subscribers for a specific tag, form, or sequence. ### Manage Broadcasts -Create, update, get, list, or delete email broadcasts. Broadcasts are one-time email sends to your subscribers. Set a \ +Create, update, get, list, delete, or inspect analytics for one-time email broadcasts. Supports scheduled sends, broadcast stats, and broadcast click analytics. ### Manage Custom Fields -List, create, update, or delete custom fields for storing additional subscriber data. Custom field values are set on individual subscribers when creating or updating them. +List, create, update, or delete custom fields used to store additional subscriber data. ### Manage Forms -List forms and landing pages, or add subscribers to a form. Adding a subscriber to a form may trigger opt-in confirmation depending on form settings. +List forms and landing pages, or add subscribers to a form. Adding a subscriber may trigger opt-in confirmation depending on form settings. + +### Manage Posts + +List or retrieve public Kit posts by id. + +### Manage Purchases + +List or retrieve existing purchase records, including subscriber, product, status, and transaction metadata returned by Kit. + +### Manage Sequence Emails + +List, create, get, update, or delete emails within a sequence. ### Manage Sequences -List email sequences or enroll subscribers in a sequence. Sequences are automated drip email campaigns that send a series of emails over time. +Create, update, get, delete, list email sequences, or enroll subscribers in a sequence. Sequences are automated drip campaigns that send a series of emails over time. + +### Manage Snippets + +List, create, get, update, archive, or restore reusable Kit snippets. Supports both inline snippet content and block snippets with HTML document attributes. ### Manage Subscriber -Create, update, get, or unsubscribe a subscriber. Use this to add new subscribers, update existing subscriber info (name, email, custom fields), look up subscriber details by ID, or unsubscribe a subscriber. +Create, update, get, unsubscribe, list tags for, or retrieve engagement stats for a subscriber. ### Manage Tags -Create, update, list tags, or manage tag-subscriber associations. Use this to organize subscribers by adding/removing tags, creating new tags, or listing all tags. +Create, update, list tags, or manage tag-subscriber associations. + +## Events + +Supports webhooks for subscriber activation, unsubscribe, bounce, complaint, form subscription, tag changes, sequence subscription/completion, purchase creation, link click, and product purchase events. ## License diff --git a/integrations/convertkit/docs/SPEC.md b/integrations/convertkit/docs/SPEC.md index ede2f45c88..6a4f5d36c4 100644 --- a/integrations/convertkit/docs/SPEC.md +++ b/integrations/convertkit/docs/SPEC.md @@ -1,131 +1,109 @@ -Now let me fetch the V4 API authentication docs to get more details:Now I have enough information to write the specification. - # Slates Specification for ConvertKit ## Overview -ConvertKit (now rebranded as Kit) is an email marketing platform designed for creators such as bloggers, podcasters, and course creators. It provides tools for managing email subscribers, sending broadcasts, creating automated email sequences, building forms and landing pages, and selling digital products. The API (currently V4, with legacy V3 still available) allows programmatic access to manage subscribers, tags, forms, sequences, broadcasts, custom fields, purchases, and webhooks. +ConvertKit is now branded as Kit. It is an email marketing platform for creators, newsletters, courses, memberships, and digital products. This integration exposes the practical high-value Kit API V4 surfaces for subscriber management, tags, forms, sequences, sequence emails, broadcasts, purchases, account insights, posts, snippets, segments, and email templates. + +The implementation targets Kit API V4 at `https://api.kit.com/v4`. Legacy API V3 is deprecated and is not used for integration business logic. ## Authentication -ConvertKit supports multiple authentication methods depending on the API version. +Kit API V4 supports two authentication modes. -### API V4 (Current — recommended) +### OAuth 2.0 -**1. OAuth 2.0** (required for public apps listed in the Kit App Store) +OAuth is the supported mode for public applications and is required by selected V4 endpoints, including purchase creation. -- When a user installs your app from the Kit App Store, Kit redirects them to the Authorization URL you've configured. Your app should present the user a screen to sign in. Kit appends a `redirect` query parameter. After the user authenticates, redirect them to Kit's OAuth server at `https://app.kit.com/oauth/authorize`. -- Token endpoint: `https://api.convertkit.com/oauth/token` -- Register your OAuth application in the OAuth Applications section at `https://app.kit.com/account_settings/advanced_settings`. Using the supplied Client ID and secret, redirect the user to Kit to grant your application access. -- The OAuth flow returns an access token and a refresh token. Access tokens expire and must be refreshed using the refresh token. -- If your app will be used in an insecure location where the client secret can't be kept confidential (such as mobile or single page apps), you must use the PKCE flow. -- Some endpoints require OAuth authentication — for example, bulk and purchase creation endpoints. +- Authorization URL: `https://api.kit.com/v4/oauth/authorize` +- Token URL: `https://api.kit.com/v4/oauth/token` +- Refresh URL: `https://api.kit.com/v4/oauth/token` +- Access tokens are sent as `Authorization: Bearer `. +- Refresh responses can rotate both the access token and refresh token. -**2. API Key** (for personal account automation and testing only) +### V4 API Key -- API key authentication is the simplest way to access V4 of the API, tailored for programmatic access to your own Kit account for simple account automation, or for pulling account data for deeper external analysis. -- To use V4 API key authentication, pass the key alongside a `X-Kit-Api-Key` header when making requests. -- If you are looking to publish an app for public listing and installation, you must use OAuth and not API Keys. V4 API keys are only meant for individual use — for testing and for you to automate your own workflows. -- Create a V4 API key from the "Developer" tab in account settings. -- Base URL: `https://api.kit.com/v4/` +API key authentication is intended for personal account automation and testing. V4 API keys are sent with `X-Kit-Api-Key: `. API key authentication does not grant access to every V4 endpoint, so tools that call OAuth-only endpoints surface upstream authorization failures as `ServiceError`. -### API V3 (Legacy — deprecated) +## Tool Coverage -- All API calls require the `api_key` parameter. Some API calls require the `api_secret` parameter. You can find your API Key and Secret in the ConvertKit Account page. -- The API key and secret are passed as query parameters or in the request body. -- Base URL: `https://api.convertkit.com/v3/` -- V4 API Keys are not compatible with V3. +### Account -## Features +- `get_account`: retrieve account name, plan, primary email, timezone, and sending addresses. +- `get_account_insights`: retrieve creator profile, email stats, or growth stats. -### Subscriber Management +### Subscribers -Create, update, list, and search subscribers. Subscribers can be filtered by status (active, inactive, bounced, complained, cancelled), date ranges, and custom fields. You can unsubscribe subscribers or update their information such as name, email, and custom field values. +- `list_subscribers`: list/search subscribers, including tag, form, or sequence filtered subscriber lists. +- `manage_subscriber`: create, update, get, unsubscribe, list subscriber tags, or fetch subscriber stats. ### Tags -Create and manage tags to organize and segment subscribers. Tags can be added to or removed from individual subscribers. You can list all subscribers associated with a specific tag. +- `manage_tags`: list, create, update, tag subscribers, untag subscribers, or list subscribers for a tag. -### Forms and Landing Pages +### Forms -List and retrieve forms and landing pages. Add subscribers directly to forms, which can trigger opt-in confirmation depending on form settings. +- `manage_forms`: list forms and landing pages, or add a subscriber to a form. -### Email Sequences +### Sequences and Sequence Emails -List and manage email sequences (automated drip campaigns). Add subscribers to sequences to enroll them in automated email series. You can view sequence details and list subscribers in a sequence. +- `manage_sequences`: list, create, get, update, delete, or add a subscriber to a sequence. +- `manage_sequence_emails`: list, create, get, update, or delete sequence emails. ### Broadcasts -Create, update, list, and delete email broadcasts (one-time email sends). You can create and send broadcasts to your subscribers. V4 includes improved HTML support and access to subscriber filters for targeting. +- `manage_broadcasts`: list, create, get, update, delete, retrieve stats, or list click analytics for broadcasts. ### Custom Fields -Create, update, list, and delete custom fields for storing additional subscriber data. Custom field values can be set when creating or updating subscribers. +- `manage_custom_fields`: list, create, update, or delete custom fields. ### Purchases -Import purchase/transaction data, including product details, pricing, and transaction metadata. Purchase creation endpoints require OAuth authentication. +- `create_purchase`: create purchase records. This is OAuth-only in Kit API V4. +- `manage_purchases`: list or retrieve purchase records. -### Segments +### Segments and Templates -Access subscriber segments, which are saved filters for grouping subscribers based on various criteria. +- `list_segments`: list saved subscriber segments. +- `list_email_templates`: list email templates. -### Email Templates +### Posts -List available email templates that can be used with broadcasts. +- `manage_posts`: list or retrieve Kit posts. -### Account Information +### Snippets -Retrieve account-level information such as account name and plan details. +- `manage_snippets`: list, create, get, update, archive, or restore reusable snippets. Supports inline content snippets and block snippets with HTML document attributes. ## Events -ConvertKit supports webhooks that send a POST request with a JSON payload to a specified URL when subscriber events occur. - -### Subscriber Activation - -Webhooks are automations that will receive subscriber data when a subscriber event is triggered. Fires when a subscriber becomes active. No additional parameters required. - -### Subscriber Unsubscribe - -Fires when a subscriber unsubscribes. No additional parameters required. - -### Subscriber Bounce - -Fires when a subscriber's email bounces. No additional parameters required. - -### Subscriber Complaint - -Fires when a subscriber marks an email as spam. No additional parameters required. - -### Form Subscription - -Fires when a subscriber subscribes to a specific form. Requires specifying a `form_id`. - -### Sequence Subscription - -Fires when a subscriber is added to a specific sequence. Requires specifying a `sequence_id`. - -### Sequence Completion - -Fires when a subscriber completes a specific sequence. Requires specifying a `sequence_id`. - -### Link Click +The integration supports Kit webhook automation events for: -Fires when a subscriber clicks a specific link in an email. Requires specifying the link URL via `initiator_value`. +- Subscriber activation +- Subscriber unsubscribe +- Subscriber bounce +- Subscriber complaint +- Form subscription +- Tag added +- Tag removed +- Sequence subscription +- Sequence completion +- Purchase created +- Link click +- Product purchase -### Product Purchase +## Non-Goals -Fires when a subscriber purchases a specific product. Requires specifying a `product_id`. +The integration intentionally does not expose every administrative or dashboard-only detail from Kit. Low-value or highly UI-specific dashboard workflows are left out unless they become necessary for agent-driven automation. File-returning/export workflows are not currently exposed; any future file-producing tool must return content through Slate attachments instead of inline payload fields. -### Tag Added +## Validation and Error Handling -Fires when a specific tag is added to a subscriber. Requires specifying a `tag_id`. +Tool inputs use top-level `z.object` schemas for MCP/OpenAI tool bridge compatibility. Branching actions are modeled with an `action` enum plus optional action-specific fields and runtime validation. -### Tag Removed +User-facing validation failures and upstream Kit API failures are surfaced with `ServiceError` from `@lowerdeck/error`. -Fires when a specific tag is removed from a subscriber. Requires specifying a `tag_id`. +## Verification -### Purchase Created +The package includes a schema regression test that serializes every tool input schema with `z.toJSONSchema` and asserts the top-level schema is an object without top-level `oneOf`, `anyOf`, or `allOf`. -Fires when a new purchase record is created. No additional parameters required. +Private live E2E coverage lives at `tests/integrations/convertkit/tools.e2e.ts` and covers each tool with safe setup and cleanup. Live E2E should be run only with an appropriate private profile and was not run as part of this update. diff --git a/integrations/convertkit/package.json b/integrations/convertkit/package.json index d1d16d5f44..fc5a47f15d 100644 --- a/integrations/convertkit/package.json +++ b/integrations/convertkit/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/convertkit/src/auth.ts b/integrations/convertkit/src/auth.ts index 6fa9cb71a3..3858d2b3e5 100644 --- a/integrations/convertkit/src/auth.ts +++ b/integrations/convertkit/src/auth.ts @@ -1,12 +1,14 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { kitApiError, kitServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( z.object({ token: z.string(), refreshToken: z.string().optional(), - expiresAt: z.string().optional() + expiresAt: z.string().optional(), + authMethod: z.enum(['oauth', 'api_key']).optional() }) ) .addOauth({ @@ -35,66 +37,82 @@ export let auth = SlateAuth.create() } return { - url: `https://app.kit.com/oauth/authorize?${params.toString()}` + url: `https://api.kit.com/v4/oauth/authorize?${params.toString()}` }; }, handleCallback: async ctx => { let http = createAxios({ - baseURL: 'https://api.kit.com' + baseURL: 'https://api.kit.com/v4' }); - let response = await http.post('/oauth/token', { - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - grant_type: 'authorization_code', - code: ctx.code, - redirect_uri: ctx.redirectUri - }); - - let data = response.data as { + let data: { access_token: string; refresh_token: string; expires_in: number; created_at: number; }; + try { + let response = await http.post('/oauth/token', { + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + grant_type: 'authorization_code', + code: ctx.code, + redirect_uri: ctx.redirectUri + }); + data = response.data as typeof data; + } catch (error) { + throw kitApiError(error, 'OAuth token exchange'); + } + let expiresAt = new Date((data.created_at + data.expires_in) * 1000).toISOString(); return { output: { token: data.access_token, refreshToken: data.refresh_token, - expiresAt + expiresAt, + authMethod: 'oauth' as const } }; }, handleTokenRefresh: async (ctx: any) => { - let http = createAxios({ - baseURL: 'https://api.kit.com' - }); + if (!ctx.output.refreshToken) { + throw kitServiceError('Kit OAuth refresh token is missing.'); + } - let response = await http.post('/oauth/token', { - client_id: ctx.clientId, - grant_type: 'refresh_token', - refresh_token: ctx.output.refreshToken + let http = createAxios({ + baseURL: 'https://api.kit.com/v4' }); - let data = response.data as { + let data: { access_token: string; refresh_token: string; expires_in: number; created_at: number; }; + try { + let response = await http.post('/oauth/token', { + client_id: ctx.clientId, + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken + }); + data = response.data as typeof data; + } catch (error) { + throw kitApiError(error, 'OAuth token refresh'); + } + let expiresAt = new Date((data.created_at + data.expires_in) * 1000).toISOString(); return { output: { token: data.access_token, refreshToken: data.refresh_token, - expiresAt + expiresAt, + authMethod: 'oauth' as const } }; }, @@ -107,12 +125,18 @@ export let auth = SlateAuth.create() } }); - let response = await http.get('/account'); - let data = response.data as { + let data: { user: { id: number; email: string }; account: { id: number; name: string }; }; + try { + let response = await http.get('/account'); + data = response.data as typeof data; + } catch (error) { + throw kitApiError(error, 'OAuth profile lookup'); + } + return { profile: { id: String(data.user.id), @@ -136,7 +160,8 @@ export let auth = SlateAuth.create() getOutput: async ctx => { return { output: { - token: ctx.input.apiKey + token: ctx.input.apiKey, + authMethod: 'api_key' as const } }; }, @@ -149,12 +174,18 @@ export let auth = SlateAuth.create() } }); - let response = await http.get('/account'); - let data = response.data as { + let data: { user: { id: number; email: string }; account: { id: number; name: string }; }; + try { + let response = await http.get('/account'); + data = response.data as typeof data; + } catch (error) { + throw kitApiError(error, 'API key profile lookup'); + } + return { profile: { id: String(data.user.id), diff --git a/integrations/convertkit/src/index.ts b/integrations/convertkit/src/index.ts index 2f9b17d754..77bf7b3656 100644 --- a/integrations/convertkit/src/index.ts +++ b/integrations/convertkit/src/index.ts @@ -3,13 +3,18 @@ import { spec } from './spec'; import { createPurchase, getAccount, + getAccountInsights, listEmailTemplates, listSegments, listSubscribers, manageBroadcasts, manageCustomFields, manageForms, + managePosts, + managePurchases, + manageSequenceEmails, manageSequences, + manageSnippets, manageSubscriber, manageTags } from './tools'; @@ -35,8 +40,13 @@ export let provider = Slate.create({ manageBroadcasts.build(), manageCustomFields.build(), createPurchase.build(), + managePurchases.build(), + getAccountInsights.build(), listSegments.build(), - listEmailTemplates.build() + listEmailTemplates.build(), + managePosts.build(), + manageSnippets.build(), + manageSequenceEmails.build() ], triggers: [ subscriberEvent.build(), diff --git a/integrations/convertkit/src/lib/client.ts b/integrations/convertkit/src/lib/client.ts index 6c6f776ec6..e9ddae4800 100644 --- a/integrations/convertkit/src/lib/client.ts +++ b/integrations/convertkit/src/lib/client.ts @@ -1,15 +1,25 @@ import { createAxios } from 'slates'; +import { kitApiError, kitServiceError } from './errors'; import type { Account, Broadcast, + BroadcastClick, + BroadcastStats, + CreatorProfile, CustomField, + EmailStats, EmailTemplate, Form, + GrowthStats, PaginationInfo, + Post, Purchase, Segment, Sequence, + SequenceEmail, + Snippet, Subscriber, + SubscriberStats, Tag, Webhook } from './types'; @@ -19,12 +29,29 @@ interface ClientConfig { authMethod?: 'oauth' | 'api_key'; } +export type ClientAuth = { + token: string; + refreshToken?: string; + authMethod?: 'oauth' | 'api_key'; +}; + +export let createClient = (auth: ClientAuth) => + new Client({ + token: auth.token, + authMethod: auth.authMethod ?? (auth.refreshToken ? 'oauth' : 'api_key') + }); + export class Client { private http: ReturnType; constructor(config: ClientConfig) { + if (!config.token?.trim()) { + throw kitServiceError('Kit access token or API key is required.'); + } + let headers: Record = { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Accept: 'application/json' }; if (config.authMethod === 'api_key') { @@ -37,6 +64,13 @@ export class Client { baseURL: 'https://api.kit.com/v4', headers }); + + this.http.interceptors.response.use( + response => response, + error => { + throw kitApiError(error); + } + ); } // ── Account ── @@ -46,6 +80,29 @@ export class Client { return response.data as Account; } + async getCreatorProfile(): Promise { + let response = await this.http.get('/account/creator_profile'); + let data = response.data as { profile: CreatorProfile }; + return data.profile; + } + + async getEmailStats(): Promise { + let response = await this.http.get('/account/email_stats'); + let data = response.data as { stats: EmailStats }; + return data.stats; + } + + async getGrowthStats(params?: { starting?: string; ending?: string }): Promise { + let response = await this.http.get('/account/growth_stats', { + params: { + ...(params?.starting ? { starting: params.starting } : {}), + ...(params?.ending ? { ending: params.ending } : {}) + } + }); + let data = response.data as { stats: GrowthStats }; + return data.stats; + } + // ── Subscribers ── async listSubscribers(params?: { @@ -130,6 +187,23 @@ export class Client { return response.data as { tags: Tag[]; pagination: PaginationInfo }; } + async getSubscriberStats( + subscriberId: number, + params?: { + emailSentAfter?: string; + emailSentBefore?: string; + } + ): Promise { + let response = await this.http.get(`/subscribers/${subscriberId}/stats`, { + params: { + ...(params?.emailSentAfter ? { email_sent_after: params.emailSentAfter } : {}), + ...(params?.emailSentBefore ? { email_sent_before: params.emailSentBefore } : {}) + } + }); + let data = response.data as { subscriber: { id: number; stats: SubscriberStats } }; + return data.subscriber.stats; + } + // ── Tags ── async listTags(params?: { @@ -242,6 +316,80 @@ export class Client { return response.data as { sequences: Sequence[]; pagination: PaginationInfo }; } + async createSequence(params: { + name: string; + emailAddress?: string; + emailTemplateId?: number; + sendDays?: string[]; + sendHour?: number; + timeZone?: string; + active?: boolean; + repeat?: boolean; + hold?: boolean; + excludeSubscriberSources?: { type: string; ids: number[] }[]; + }): Promise { + let body: Record = { name: params.name }; + if (params.emailAddress !== undefined) body.email_address = params.emailAddress; + if (params.emailTemplateId !== undefined) body.email_template_id = params.emailTemplateId; + if (params.sendDays !== undefined) body.send_days = params.sendDays; + if (params.sendHour !== undefined) body.send_hour = params.sendHour; + if (params.timeZone !== undefined) body.time_zone = params.timeZone; + if (params.active !== undefined) body.active = params.active; + if (params.repeat !== undefined) body.repeat = params.repeat; + if (params.hold !== undefined) body.hold = params.hold; + if (params.excludeSubscriberSources !== undefined) { + body.exclude_subscriber_sources = params.excludeSubscriberSources; + } + + let response = await this.http.post('/sequences', body); + let data = response.data as { sequence: Sequence }; + return data.sequence; + } + + async getSequence(sequenceId: number): Promise { + let response = await this.http.get(`/sequences/${sequenceId}`); + let data = response.data as { sequence: Sequence }; + return data.sequence; + } + + async updateSequence( + sequenceId: number, + params: { + name?: string; + emailAddress?: string; + emailTemplateId?: number; + sendDays?: string[]; + sendHour?: number; + timeZone?: string; + active?: boolean; + repeat?: boolean; + hold?: boolean; + excludeSubscriberSources?: { type: string; ids: number[] }[]; + } + ): Promise { + let body: Record = {}; + if (params.name !== undefined) body.name = params.name; + if (params.emailAddress !== undefined) body.email_address = params.emailAddress; + if (params.emailTemplateId !== undefined) body.email_template_id = params.emailTemplateId; + if (params.sendDays !== undefined) body.send_days = params.sendDays; + if (params.sendHour !== undefined) body.send_hour = params.sendHour; + if (params.timeZone !== undefined) body.time_zone = params.timeZone; + if (params.active !== undefined) body.active = params.active; + if (params.repeat !== undefined) body.repeat = params.repeat; + if (params.hold !== undefined) body.hold = params.hold; + if (params.excludeSubscriberSources !== undefined) { + body.exclude_subscriber_sources = params.excludeSubscriberSources; + } + + let response = await this.http.put(`/sequences/${sequenceId}`, body); + let data = response.data as { sequence: Sequence }; + return data.sequence; + } + + async deleteSequence(sequenceId: number): Promise { + await this.http.delete(`/sequences/${sequenceId}`); + } + async addSubscriberToSequenceById(sequenceId: number, subscriberId: number): Promise { await this.http.post(`/sequences/${sequenceId}/subscribers/${subscriberId}`, {}); } @@ -274,6 +422,98 @@ export class Client { return response.data as { subscribers: Subscriber[]; pagination: PaginationInfo }; } + async listSequenceEmails( + sequenceId: number, + params?: { + includeContent?: boolean; + perPage?: number; + after?: string; + } + ): Promise<{ emails: SequenceEmail[]; pagination: PaginationInfo }> { + let query: Record = {}; + if (params?.includeContent !== undefined) + query.include_content = String(params.includeContent); + if (params?.perPage) query.per_page = String(params.perPage); + if (params?.after) query.after = params.after; + + let response = await this.http.get(`/sequences/${sequenceId}/emails`, { + params: query + }); + return response.data as { emails: SequenceEmail[]; pagination: PaginationInfo }; + } + + async createSequenceEmail( + sequenceId: number, + params: { + subject: string; + previewText?: string | null; + content?: string | null; + delayValue: number; + delayUnit: string; + emailTemplateId?: number | null; + published?: boolean; + sendDays?: string[] | null; + position?: number | null; + } + ): Promise { + let body: Record = { + subject: params.subject, + delay_value: params.delayValue, + delay_unit: params.delayUnit + }; + if (params.previewText !== undefined) body.preview_text = params.previewText; + if (params.content !== undefined) body.content = params.content; + if (params.emailTemplateId !== undefined) body.email_template_id = params.emailTemplateId; + if (params.published !== undefined) body.published = params.published; + if (params.sendDays !== undefined) body.send_days = params.sendDays; + if (params.position !== undefined) body.position = params.position; + + let response = await this.http.post(`/sequences/${sequenceId}/emails`, body); + let data = response.data as { email: SequenceEmail }; + return data.email; + } + + async getSequenceEmail(sequenceId: number, emailId: number): Promise { + let response = await this.http.get(`/sequences/${sequenceId}/emails/${emailId}`); + let data = response.data as { email: SequenceEmail }; + return data.email; + } + + async updateSequenceEmail( + sequenceId: number, + emailId: number, + params: { + subject?: string; + previewText?: string | null; + content?: string | null; + delayValue?: number; + delayUnit?: string; + emailTemplateId?: number | null; + published?: boolean; + sendDays?: string[] | null; + position?: number | null; + } + ): Promise { + let body: Record = {}; + if (params.subject !== undefined) body.subject = params.subject; + if (params.previewText !== undefined) body.preview_text = params.previewText; + if (params.content !== undefined) body.content = params.content; + if (params.delayValue !== undefined) body.delay_value = params.delayValue; + if (params.delayUnit !== undefined) body.delay_unit = params.delayUnit; + if (params.emailTemplateId !== undefined) body.email_template_id = params.emailTemplateId; + if (params.published !== undefined) body.published = params.published; + if (params.sendDays !== undefined) body.send_days = params.sendDays; + if (params.position !== undefined) body.position = params.position; + + let response = await this.http.put(`/sequences/${sequenceId}/emails/${emailId}`, body); + let data = response.data as { email: SequenceEmail }; + return data.email; + } + + async deleteSequenceEmail(sequenceId: number, emailId: number): Promise { + await this.http.delete(`/sequences/${sequenceId}/emails/${emailId}`); + } + // ── Broadcasts ── async listBroadcasts(params?: { @@ -369,6 +609,33 @@ export class Client { await this.http.delete(`/broadcasts/${broadcastId}`); } + async getBroadcastStats(broadcastId: number): Promise { + let response = await this.http.get(`/broadcasts/${broadcastId}/stats`); + let data = response.data as { broadcast: { id: number; stats: BroadcastStats } }; + return data.broadcast.stats; + } + + async getBroadcastClicks( + broadcastId: number, + params?: { + perPage?: number; + after?: string; + } + ): Promise<{ clicks: BroadcastClick[]; pagination: PaginationInfo }> { + let query: Record = {}; + if (params?.perPage) query.per_page = String(params.perPage); + if (params?.after) query.after = params.after; + + let response = await this.http.get(`/broadcasts/${broadcastId}/clicks`, { + params: query + }); + let data = response.data as { + broadcast: { id: number; clicks: BroadcastClick[] }; + pagination: PaginationInfo; + }; + return { clicks: data.broadcast.clicks, pagination: data.pagination }; + } + // ── Custom Fields ── async listCustomFields(params?: { @@ -500,6 +767,98 @@ export class Client { return { emailTemplates: data.email_templates, pagination: data.pagination }; } + // ── Posts ── + + async listPosts(params?: { + includeContent?: boolean; + perPage?: number; + after?: string; + }): Promise<{ posts: Post[]; pagination: PaginationInfo }> { + let query: Record = {}; + if (params?.includeContent !== undefined) + query.include_content = String(params.includeContent); + if (params?.perPage) query.per_page = String(params.perPage); + if (params?.after) query.after = params.after; + + let response = await this.http.get('/posts', { params: query }); + return response.data as { posts: Post[]; pagination: PaginationInfo }; + } + + async getPost(postId: number): Promise { + let response = await this.http.get(`/posts/${postId}`); + let data = response.data as { post: Post }; + return data.post; + } + + // ── Snippets ── + + async listSnippets(params?: { + snippetType?: string; + archived?: boolean; + includeContent?: boolean; + perPage?: number; + after?: string; + }): Promise<{ snippets: Snippet[]; pagination: PaginationInfo }> { + let query: Record = {}; + if (params?.snippetType) query.snippet_type = params.snippetType; + if (params?.archived !== undefined) query.archived = String(params.archived); + if (params?.includeContent !== undefined) + query.include_content = String(params.includeContent); + if (params?.perPage) query.per_page = String(params.perPage); + if (params?.after) query.after = params.after; + + let response = await this.http.get('/snippets', { params: query }); + return response.data as { snippets: Snippet[]; pagination: PaginationInfo }; + } + + async createSnippet(params: { + name: string; + snippetType: string; + content?: string; + blockHtml?: string; + }): Promise { + let body: Record = { + name: params.name, + snippet_type: params.snippetType + }; + if (params.content !== undefined) body.content = params.content; + if (params.blockHtml !== undefined) + body.document_attributes = { value_html: params.blockHtml }; + + let response = await this.http.post('/snippets', body); + let data = response.data as { snippet: Snippet }; + return data.snippet; + } + + async getSnippet(snippetId: number): Promise { + let response = await this.http.get(`/snippets/${snippetId}`); + let data = response.data as { snippet: Snippet }; + return data.snippet; + } + + async updateSnippet( + snippetId: number, + params: { + name?: string; + snippetType?: string; + archived?: boolean; + content?: string; + blockHtml?: string; + } + ): Promise { + let body: Record = {}; + if (params.name !== undefined) body.name = params.name; + if (params.snippetType !== undefined) body.snippet_type = params.snippetType; + if (params.archived !== undefined) body.archived = params.archived; + if (params.content !== undefined) body.content = params.content; + if (params.blockHtml !== undefined) + body.document_attributes = { value_html: params.blockHtml }; + + let response = await this.http.put(`/snippets/${snippetId}`, body); + let data = response.data as { snippet: Snippet }; + return data.snippet; + } + // ── Webhooks ── async listWebhooks(): Promise<{ webhooks: Webhook[]; pagination: PaginationInfo }> { diff --git a/integrations/convertkit/src/lib/errors.ts b/integrations/convertkit/src/lib/errors.ts new file mode 100644 index 0000000000..2c274ef94c --- /dev/null +++ b/integrations/convertkit/src/lib/errors.ts @@ -0,0 +1,88 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let message = String(value).trim(); + if (message && !messages.includes(message)) { + messages.push(message); + } +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectMessages(item, messages); + } + return; + } + + if (!isRecord(value)) { + pushMessage(messages, value); + return; + } + + pushMessage(messages, value.message); + pushMessage(messages, value.error); + pushMessage(messages, value.error_description); + + let errors = value.errors; + if (errors !== undefined) { + collectMessages(errors, messages); + } +}; + +let extractKitMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectMessages(response?.data, messages); + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let kitServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let kitApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = kitServiceError( + `Kit API ${operation} failed: ${statusLabelFor(response)}${extractKitMessage(error)}` + ); + serviceError.data.reason = 'kit_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/convertkit/src/lib/types.ts b/integrations/convertkit/src/lib/types.ts index 5fd7804bfa..46f650bc99 100644 --- a/integrations/convertkit/src/lib/types.ts +++ b/integrations/convertkit/src/lib/types.ts @@ -31,6 +31,31 @@ export interface Sequence { hold: boolean; repeat: boolean; created_at: string; + updated_at?: string; + email_address?: string | null; + email_template_id?: number | null; + send_days?: string[]; + send_hour?: number; + time_zone?: string; + active?: boolean; + exclude_subscriber_sources?: { type: string; ids: number[] }[]; + email_count?: number; + subscriber_count?: number; +} + +export interface SequenceEmail { + id: number; + sequence_id: number; + subject: string; + preview_text: string | null; + email_address: string; + email_template_id: number | null; + published: boolean; + position: number | null; + delay_value: number; + delay_unit: string; + send_days: string[]; + content?: string | null; } export interface Broadcast { @@ -52,6 +77,28 @@ export interface Broadcast { subscriber_filter: any[] | null; } +export interface BroadcastStats { + recipients: number; + open_rate: number; + emails_opened: number; + click_rate: number; + unsubscribe_rate: number; + unsubscribes: number; + total_clicks: number; + show_total_clicks: boolean; + status: string; + progress: number; + open_tracking_disabled: boolean; + click_tracking_disabled: boolean; +} + +export interface BroadcastClick { + url: string; + unique_clicks: number; + click_to_delivery_rate: number; + click_to_open_rate: number; +} + export interface CustomField { id: number; name: string; @@ -64,6 +111,7 @@ export interface Purchase { transaction_id: string; status: string; email_address: string; + subscriber_id?: number; currency: string; subtotal: number; tax: number; @@ -76,11 +124,11 @@ export interface Purchase { export interface PurchaseProduct { name: string; - pid: string; - lid: string; + pid: string | null; + lid: string | null; quantity: number; unit_price: number; - sku: string; + sku: string | null; } export interface Segment { @@ -95,6 +143,44 @@ export interface EmailTemplate { is_default: boolean; } +export interface Post { + id: number; + publication_id: number; + created_at: string; + title: string; + slug: string | null; + description: string | null; + meta_description: string | null; + status: string; + published_at: string | null; + sent_at: string | null; + thumbnail_alt: string | null; + thumbnail_url: string | null; + is_paid: boolean; + public_url: string | null; + content?: string | null; +} + +export interface SnippetDocument { + id: number; + value: string | null; + value_html: string | null; + value_plain: string | null; + version: number; +} + +export interface Snippet { + id: number; + name: string; + snippet_type: string; + archived: boolean; + key: string; + created_at: string; + updated_at: string; + content?: string | null; + document?: SnippetDocument | null; +} + export interface Webhook { id: number; account_id: number; @@ -118,9 +204,68 @@ export interface Account { plan_type: string; primary_email_address: string; created_at: string; + timezone?: { + name: string; + friendly_name: string; + utc_offset: string; + }; + sending_addresses?: { + email_address: string; + from_name: string; + status: string; + is_default: boolean; + is_verified: boolean; + is_dmarc_configured: boolean; + }[]; }; } +export interface CreatorProfile { + name: string; + byline: string; + bio: string; + image_url: string; + profile_url: string; +} + +export interface EmailStats { + sent: number; + clicked: number; + opened: number; + email_stats_mode: string; + open_tracking_enabled: boolean; + click_tracking_enabled: boolean; + starting: string; + ending: string; + open_rate?: number; + click_rate?: number; + unsubscribe_rate?: number; + bounce_rate?: number; +} + +export interface GrowthStats { + cancellations: number; + net_new_subscribers: number; + new_subscribers: number; + subscribers: number; + starting: string; + ending: string; +} + +export interface SubscriberStats { + sent: number; + opened: number; + clicked: number; + bounced: number; + open_rate: number; + click_rate: number; + last_sent: string | null; + last_opened: string | null; + last_clicked: string | null; + sends_since_last_open: number; + sends_since_last_click: number; +} + export interface PaginationInfo { has_previous_page: boolean; has_next_page: boolean; diff --git a/integrations/convertkit/src/tools.schema.test.ts b/integrations/convertkit/src/tools.schema.test.ts new file mode 100644 index 0000000000..c6c7700094 --- /dev/null +++ b/integrations/convertkit/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('ConvertKit tool input schemas', provider.actions); diff --git a/integrations/convertkit/src/tools/create-purchase.ts b/integrations/convertkit/src/tools/create-purchase.ts index d6f186b820..06abb261ab 100644 --- a/integrations/convertkit/src/tools/create-purchase.ts +++ b/integrations/convertkit/src/tools/create-purchase.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; import { spec } from '../spec'; export let createPurchase = SlateTool.create(spec, { @@ -53,7 +53,7 @@ export let createPurchase = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let input = ctx.input; let purchase = await client.createPurchase({ diff --git a/integrations/convertkit/src/tools/get-account-insights.ts b/integrations/convertkit/src/tools/get-account-insights.ts new file mode 100644 index 0000000000..a4463ec88a --- /dev/null +++ b/integrations/convertkit/src/tools/get-account-insights.ts @@ -0,0 +1,136 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getAccountInsights = SlateTool.create(spec, { + name: 'Get Account Insights', + key: 'get_account_insights', + description: + 'Retrieve Kit creator profile details, account-wide email stats, or subscriber growth stats.', + instructions: [ + 'Use insight "creator_profile" to retrieve public creator profile details.', + 'Use insight "email_stats" to retrieve last-90-day account email engagement stats.', + 'Use insight "growth_stats" to retrieve subscriber growth stats. Optionally provide starting and ending as YYYY-MM-DD dates.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + insight: z + .enum(['creator_profile', 'email_stats', 'growth_stats']) + .describe('Account insight to retrieve'), + starting: z + .string() + .optional() + .describe('For growth_stats, period start date in YYYY-MM-DD format'), + ending: z + .string() + .optional() + .describe('For growth_stats, period end date in YYYY-MM-DD format') + }) + ) + .output( + z.object({ + profile: z + .object({ + name: z.string(), + byline: z.string(), + bio: z.string(), + imageUrl: z.string(), + profileUrl: z.string() + }) + .optional() + .describe('Creator profile details'), + emailStats: z + .object({ + sent: z.number(), + clicked: z.number(), + opened: z.number(), + emailStatsMode: z.string(), + openTrackingEnabled: z.boolean(), + clickTrackingEnabled: z.boolean(), + starting: z.string(), + ending: z.string(), + openRate: z.number().optional(), + clickRate: z.number().optional(), + unsubscribeRate: z.number().optional(), + bounceRate: z.number().optional() + }) + .optional() + .describe('Account email engagement stats'), + growthStats: z + .object({ + cancellations: z.number(), + netNewSubscribers: z.number(), + newSubscribers: z.number(), + subscribers: z.number(), + starting: z.string(), + ending: z.string() + }) + .optional() + .describe('Subscriber growth stats') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth); + + if (ctx.input.insight === 'creator_profile') { + let profile = await client.getCreatorProfile(); + return { + output: { + profile: { + name: profile.name, + byline: profile.byline, + bio: profile.bio, + imageUrl: profile.image_url, + profileUrl: profile.profile_url + } + }, + message: `Creator profile **${profile.name}**` + }; + } + + if (ctx.input.insight === 'email_stats') { + let stats = await client.getEmailStats(); + return { + output: { + emailStats: { + sent: stats.sent, + clicked: stats.clicked, + opened: stats.opened, + emailStatsMode: stats.email_stats_mode, + openTrackingEnabled: stats.open_tracking_enabled, + clickTrackingEnabled: stats.click_tracking_enabled, + starting: stats.starting, + ending: stats.ending, + openRate: stats.open_rate, + clickRate: stats.click_rate, + unsubscribeRate: stats.unsubscribe_rate, + bounceRate: stats.bounce_rate + } + }, + message: `Retrieved email stats for ${stats.starting} through ${stats.ending}.` + }; + } + + let stats = await client.getGrowthStats({ + starting: ctx.input.starting, + ending: ctx.input.ending + }); + return { + output: { + growthStats: { + cancellations: stats.cancellations, + netNewSubscribers: stats.net_new_subscribers, + newSubscribers: stats.new_subscribers, + subscribers: stats.subscribers, + starting: stats.starting, + ending: stats.ending + } + }, + message: `Retrieved growth stats for ${stats.starting} through ${stats.ending}.` + }; + }); diff --git a/integrations/convertkit/src/tools/get-account.ts b/integrations/convertkit/src/tools/get-account.ts index efcf4b3529..cf1a39fafe 100644 --- a/integrations/convertkit/src/tools/get-account.ts +++ b/integrations/convertkit/src/tools/get-account.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; import { spec } from '../spec'; export let getAccount = SlateTool.create(spec, { @@ -19,12 +19,28 @@ export let getAccount = SlateTool.create(spec, { planType: z.string().describe('Current subscription plan type'), primaryEmail: z.string().describe('Primary email address for the account'), createdAt: z.string().describe('Account creation timestamp'), + timezoneName: z.string().optional().describe('IANA account timezone name'), + timezoneFriendlyName: z.string().optional().describe('Human-readable timezone name'), + timezoneUtcOffset: z.string().optional().describe('Account timezone UTC offset'), + sendingAddresses: z + .array( + z.object({ + emailAddress: z.string(), + fromName: z.string(), + status: z.string(), + isDefault: z.boolean(), + isVerified: z.boolean(), + isDmarcConfigured: z.boolean() + }) + ) + .optional() + .describe('Configured sending addresses'), userId: z.number().describe('User ID associated with the account'), userEmail: z.string().describe('User email address') }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let data = await client.getAccount(); return { @@ -34,6 +50,17 @@ export let getAccount = SlateTool.create(spec, { planType: data.account.plan_type, primaryEmail: data.account.primary_email_address, createdAt: data.account.created_at, + timezoneName: data.account.timezone?.name, + timezoneFriendlyName: data.account.timezone?.friendly_name, + timezoneUtcOffset: data.account.timezone?.utc_offset, + sendingAddresses: data.account.sending_addresses?.map(address => ({ + emailAddress: address.email_address, + fromName: address.from_name, + status: address.status, + isDefault: address.is_default, + isVerified: address.is_verified, + isDmarcConfigured: address.is_dmarc_configured + })), userId: data.user.id, userEmail: data.user.email }, diff --git a/integrations/convertkit/src/tools/index.ts b/integrations/convertkit/src/tools/index.ts index e308523c62..ded28d521d 100644 --- a/integrations/convertkit/src/tools/index.ts +++ b/integrations/convertkit/src/tools/index.ts @@ -1,11 +1,16 @@ export * from './create-purchase'; export * from './get-account'; +export * from './get-account-insights'; export * from './list-email-templates'; export * from './list-segments'; export * from './list-subscribers'; export * from './manage-broadcasts'; export * from './manage-custom-fields'; export * from './manage-forms'; +export * from './manage-posts'; +export * from './manage-purchases'; +export * from './manage-sequence-emails'; export * from './manage-sequences'; +export * from './manage-snippets'; export * from './manage-subscriber'; export * from './manage-tags'; diff --git a/integrations/convertkit/src/tools/list-email-templates.ts b/integrations/convertkit/src/tools/list-email-templates.ts index 4dd9d39c4b..d7b15b5533 100644 --- a/integrations/convertkit/src/tools/list-email-templates.ts +++ b/integrations/convertkit/src/tools/list-email-templates.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; import { spec } from '../spec'; export let listEmailTemplates = SlateTool.create(spec, { @@ -31,7 +31,7 @@ export let listEmailTemplates = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let result = await client.listEmailTemplates({ perPage: ctx.input.perPage, after: ctx.input.cursor diff --git a/integrations/convertkit/src/tools/list-segments.ts b/integrations/convertkit/src/tools/list-segments.ts index 10b7fc4f57..22d744e4e7 100644 --- a/integrations/convertkit/src/tools/list-segments.ts +++ b/integrations/convertkit/src/tools/list-segments.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; import { spec } from '../spec'; export let listSegments = SlateTool.create(spec, { @@ -31,7 +31,7 @@ export let listSegments = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let result = await client.listSegments({ perPage: ctx.input.perPage, after: ctx.input.cursor diff --git a/integrations/convertkit/src/tools/list-subscribers.ts b/integrations/convertkit/src/tools/list-subscribers.ts index c920728529..b32329d79b 100644 --- a/integrations/convertkit/src/tools/list-subscribers.ts +++ b/integrations/convertkit/src/tools/list-subscribers.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listSubscribers = SlateTool.create(spec, { @@ -67,8 +68,14 @@ export let listSubscribers = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let input = ctx.input; + let scopedSources = [input.tagId, input.formId, input.sequenceId].filter( + value => value !== undefined + ); + if (scopedSources.length > 1) { + throw kitServiceError('Provide only one of tagId, formId, or sequenceId.'); + } let result: any; diff --git a/integrations/convertkit/src/tools/manage-broadcasts.ts b/integrations/convertkit/src/tools/manage-broadcasts.ts index 090751bf15..7696a18f56 100644 --- a/integrations/convertkit/src/tools/manage-broadcasts.ts +++ b/integrations/convertkit/src/tools/manage-broadcasts.ts @@ -1,24 +1,27 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageBroadcasts = SlateTool.create(spec, { name: 'Manage Broadcasts', key: 'manage_broadcasts', - description: `Create, update, get, list, or delete email broadcasts. Broadcasts are one-time email sends to your subscribers. Set a \`sendAt\` time to schedule sending.`, + description: `Create, update, get, list, delete, or inspect analytics for email broadcasts. Broadcasts are one-time email sends to your subscribers. Set a \`sendAt\` time to schedule sending.`, instructions: [ 'Use action "list" to see all broadcasts.', 'Use action "get" to fetch a specific broadcast by ID.', 'Use action "create" to draft a new broadcast. Provide at least subject and content.', 'Use action "update" to modify a draft broadcast.', - 'Use action "delete" to remove a draft broadcast. Broadcasts already sending cannot be deleted.' + 'Use action "delete" to remove a draft broadcast. Broadcasts already sending cannot be deleted.', + 'Use action "get_stats" to retrieve delivery and engagement stats for a broadcast.', + 'Use action "list_clicks" to retrieve tracked link click stats for a broadcast.' ] }) .input( z.object({ action: z - .enum(['list', 'get', 'create', 'update', 'delete']) + .enum(['list', 'get', 'create', 'update', 'delete', 'get_stats', 'list_clicks']) .describe('Action to perform'), broadcastId: z .number() @@ -35,8 +38,8 @@ export let manageBroadcasts = SlateTool.create(spec, { emailAddress: z.string().optional().describe('Sender email address override'), thumbnailUrl: z.string().optional().describe('Thumbnail image URL'), thumbnailAlt: z.string().optional().describe('Thumbnail alt text'), - perPage: z.number().optional().describe('Results per page (for list)'), - cursor: z.string().optional().describe('Pagination cursor (for list)') + perPage: z.number().optional().describe('Results per page (for list or list_clicks)'), + cursor: z.string().optional().describe('Pagination cursor (for list or list_clicks)') }) ) .output( @@ -71,12 +74,40 @@ export let manageBroadcasts = SlateTool.create(spec, { }) .optional() .describe('Single broadcast (for get, create, update)'), + stats: z + .object({ + recipients: z.number(), + openRate: z.number(), + emailsOpened: z.number(), + clickRate: z.number(), + unsubscribeRate: z.number(), + unsubscribes: z.number(), + totalClicks: z.number(), + showTotalClicks: z.boolean(), + status: z.string(), + progress: z.number(), + openTrackingDisabled: z.boolean(), + clickTrackingDisabled: z.boolean() + }) + .optional() + .describe('Broadcast analytics (for get_stats action)'), + clicks: z + .array( + z.object({ + url: z.string(), + uniqueClicks: z.number(), + clickToDeliveryRate: z.number(), + clickToOpenRate: z.number() + }) + ) + .optional() + .describe('Tracked link click stats (for list_clicks action)'), hasNextPage: z.boolean().optional(), nextCursor: z.string().nullable().optional() }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let input = ctx.input; if (input.action === 'list') { @@ -104,7 +135,7 @@ export let manageBroadcasts = SlateTool.create(spec, { } if (input.action === 'get') { - if (!input.broadcastId) throw new Error('broadcastId is required for get'); + if (!input.broadcastId) throw kitServiceError('broadcastId is required for get'); let b = await client.getBroadcast(input.broadcastId); return { output: { @@ -161,7 +192,7 @@ export let manageBroadcasts = SlateTool.create(spec, { } if (input.action === 'update') { - if (!input.broadcastId) throw new Error('broadcastId is required for update'); + if (!input.broadcastId) throw kitServiceError('broadcastId is required for update'); let b = await client.updateBroadcast(input.broadcastId, { subject: input.subject, content: input.content, @@ -196,7 +227,7 @@ export let manageBroadcasts = SlateTool.create(spec, { } if (input.action === 'delete') { - if (!input.broadcastId) throw new Error('broadcastId is required for delete'); + if (!input.broadcastId) throw kitServiceError('broadcastId is required for delete'); await client.deleteBroadcast(input.broadcastId); return { output: {}, @@ -204,5 +235,51 @@ export let manageBroadcasts = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${input.action}`); + if (input.action === 'get_stats') { + if (!input.broadcastId) throw kitServiceError('broadcastId is required for get_stats'); + let stats = await client.getBroadcastStats(input.broadcastId); + return { + output: { + stats: { + recipients: stats.recipients, + openRate: stats.open_rate, + emailsOpened: stats.emails_opened, + clickRate: stats.click_rate, + unsubscribeRate: stats.unsubscribe_rate, + unsubscribes: stats.unsubscribes, + totalClicks: stats.total_clicks, + showTotalClicks: stats.show_total_clicks, + status: stats.status, + progress: stats.progress, + openTrackingDisabled: stats.open_tracking_disabled, + clickTrackingDisabled: stats.click_tracking_disabled + } + }, + message: `Retrieved stats for broadcast #${input.broadcastId}.` + }; + } + + if (input.action === 'list_clicks') { + if (!input.broadcastId) throw kitServiceError('broadcastId is required for list_clicks'); + let result = await client.getBroadcastClicks(input.broadcastId, { + perPage: input.perPage, + after: input.cursor + }); + let clicks = result.clicks.map(click => ({ + url: click.url, + uniqueClicks: click.unique_clicks, + clickToDeliveryRate: click.click_to_delivery_rate, + clickToOpenRate: click.click_to_open_rate + })); + return { + output: { + clicks, + hasNextPage: result.pagination.has_next_page, + nextCursor: result.pagination.end_cursor + }, + message: `Found **${clicks.length}** tracked link(s) for broadcast #${input.broadcastId}.` + }; + } + + throw kitServiceError(`Unknown action: ${input.action}`); }); diff --git a/integrations/convertkit/src/tools/manage-custom-fields.ts b/integrations/convertkit/src/tools/manage-custom-fields.ts index 743eb25543..1283deed08 100644 --- a/integrations/convertkit/src/tools/manage-custom-fields.ts +++ b/integrations/convertkit/src/tools/manage-custom-fields.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCustomFields = SlateTool.create(spec, { @@ -54,7 +55,7 @@ export let manageCustomFields = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let input = ctx.input; if (input.action === 'list') { @@ -79,7 +80,7 @@ export let manageCustomFields = SlateTool.create(spec, { } if (input.action === 'create') { - if (!input.label) throw new Error('label is required for create'); + if (!input.label) throw kitServiceError('label is required for create'); let f = await client.createCustomField(input.label); return { output: { @@ -95,8 +96,8 @@ export let manageCustomFields = SlateTool.create(spec, { } if (input.action === 'update') { - if (!input.fieldId) throw new Error('fieldId is required for update'); - if (!input.label) throw new Error('label is required for update'); + if (!input.fieldId) throw kitServiceError('fieldId is required for update'); + if (!input.label) throw kitServiceError('label is required for update'); let f = await client.updateCustomField(input.fieldId, input.label); return { output: { @@ -112,7 +113,7 @@ export let manageCustomFields = SlateTool.create(spec, { } if (input.action === 'delete') { - if (!input.fieldId) throw new Error('fieldId is required for delete'); + if (!input.fieldId) throw kitServiceError('fieldId is required for delete'); await client.deleteCustomField(input.fieldId); return { output: {}, @@ -120,5 +121,5 @@ export let manageCustomFields = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${input.action}`); + throw kitServiceError(`Unknown action: ${input.action}`); }); diff --git a/integrations/convertkit/src/tools/manage-forms.ts b/integrations/convertkit/src/tools/manage-forms.ts index 1bdf77456e..d5a42a92f9 100644 --- a/integrations/convertkit/src/tools/manage-forms.ts +++ b/integrations/convertkit/src/tools/manage-forms.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageForms = SlateTool.create(spec, { @@ -47,7 +48,7 @@ export let manageForms = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let input = ctx.input; if (input.action === 'list') { @@ -76,7 +77,7 @@ export let manageForms = SlateTool.create(spec, { } if (input.action === 'add_subscriber') { - if (!input.formId) throw new Error('formId is required for add_subscriber'); + if (!input.formId) throw kitServiceError('formId is required for add_subscriber'); if (input.subscriberId) { await client.addSubscriberToFormById(input.formId, input.subscriberId); return { @@ -90,8 +91,8 @@ export let manageForms = SlateTool.create(spec, { message: `Added **${input.subscriberEmail}** to form #${input.formId}` }; } - throw new Error('subscriberId or subscriberEmail is required for add_subscriber'); + throw kitServiceError('subscriberId or subscriberEmail is required for add_subscriber'); } - throw new Error(`Unknown action: ${input.action}`); + throw kitServiceError(`Unknown action: ${input.action}`); }); diff --git a/integrations/convertkit/src/tools/manage-posts.ts b/integrations/convertkit/src/tools/manage-posts.ts new file mode 100644 index 0000000000..500c612b64 --- /dev/null +++ b/integrations/convertkit/src/tools/manage-posts.ts @@ -0,0 +1,130 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; +import type { Post } from '../lib/types'; +import { spec } from '../spec'; + +let formatPost = (post: Post) => ({ + postId: post.id, + publicationId: post.publication_id, + title: post.title, + slug: post.slug, + description: post.description, + metaDescription: post.meta_description, + status: post.status, + createdAt: post.created_at, + publishedAt: post.published_at, + sentAt: post.sent_at, + thumbnailAlt: post.thumbnail_alt, + thumbnailUrl: post.thumbnail_url, + isPaid: post.is_paid, + publicUrl: post.public_url, + content: post.content +}); + +export let managePosts = SlateTool.create(spec, { + name: 'Manage Posts', + key: 'manage_posts', + description: + 'List or retrieve Kit posts, including newsletter/web posts and optional HTML content.', + instructions: [ + 'Use action "list" to retrieve posts. Set includeContent to true only when post body HTML is needed.', + 'Use action "get" with postId to retrieve one post including content.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + action: z.enum(['list', 'get']).describe('Action to perform'), + postId: z.number().optional().describe('Post ID (required for get)'), + includeContent: z + .boolean() + .optional() + .describe('For list, include post content in each returned post'), + perPage: z.number().optional().describe('Results per page for list'), + cursor: z.string().optional().describe('Pagination cursor for list') + }) + ) + .output( + z.object({ + posts: z + .array( + z.object({ + postId: z.number(), + publicationId: z.number(), + title: z.string(), + slug: z.string().nullable(), + description: z.string().nullable(), + metaDescription: z.string().nullable(), + status: z.string(), + createdAt: z.string(), + publishedAt: z.string().nullable(), + sentAt: z.string().nullable(), + thumbnailAlt: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + isPaid: z.boolean(), + publicUrl: z.string().nullable(), + content: z.string().nullable().optional() + }) + ) + .optional() + .describe('Post records for list action'), + post: z + .object({ + postId: z.number(), + publicationId: z.number(), + title: z.string(), + slug: z.string().nullable(), + description: z.string().nullable(), + metaDescription: z.string().nullable(), + status: z.string(), + createdAt: z.string(), + publishedAt: z.string().nullable(), + sentAt: z.string().nullable(), + thumbnailAlt: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + isPaid: z.boolean(), + publicUrl: z.string().nullable(), + content: z.string().nullable().optional() + }) + .optional() + .describe('Post record for get action'), + hasNextPage: z.boolean().optional(), + nextCursor: z.string().nullable().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth); + + if (ctx.input.action === 'list') { + let result = await client.listPosts({ + includeContent: ctx.input.includeContent, + perPage: ctx.input.perPage, + after: ctx.input.cursor + }); + let posts = result.posts.map(formatPost); + return { + output: { + posts, + hasNextPage: result.pagination.has_next_page, + nextCursor: result.pagination.end_cursor + }, + message: `Found **${posts.length}** post(s)${result.pagination.has_next_page ? ' (more available)' : ''}.` + }; + } + + if (!ctx.input.postId) { + throw kitServiceError('postId is required for get'); + } + + let post = await client.getPost(ctx.input.postId); + return { + output: { + post: formatPost(post) + }, + message: `Post **${post.title}** (#${post.id})` + }; + }); diff --git a/integrations/convertkit/src/tools/manage-purchases.ts b/integrations/convertkit/src/tools/manage-purchases.ts new file mode 100644 index 0000000000..471542e95b --- /dev/null +++ b/integrations/convertkit/src/tools/manage-purchases.ts @@ -0,0 +1,144 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; +import type { Purchase } from '../lib/types'; +import { spec } from '../spec'; + +let formatPurchase = (purchase: Purchase) => ({ + purchaseId: purchase.id, + transactionId: purchase.transaction_id, + subscriberId: purchase.subscriber_id, + status: purchase.status, + emailAddress: purchase.email_address, + currency: purchase.currency, + subtotal: purchase.subtotal, + discount: purchase.discount, + tax: purchase.tax, + shipping: purchase.shipping, + total: purchase.total, + transactionTime: purchase.transaction_time, + products: purchase.products.map(product => ({ + productName: product.name, + productId: product.pid, + lineItemId: product.lid, + quantity: product.quantity, + unitPrice: product.unit_price, + sku: product.sku + })) +}); + +export let managePurchases = SlateTool.create(spec, { + name: 'Manage Purchases', + key: 'manage_purchases', + description: + 'List or retrieve Kit purchase records imported into the account. Use Create Purchase to import a new purchase.', + instructions: [ + 'Use action "list" to retrieve recent purchases.', + 'Use action "get" with purchaseId to retrieve one purchase record.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + action: z.enum(['list', 'get']).describe('Action to perform'), + purchaseId: z.number().optional().describe('Purchase ID (required for get)'), + perPage: z.number().optional().describe('Results per page for list'), + cursor: z.string().optional().describe('Pagination cursor for list') + }) + ) + .output( + z.object({ + purchases: z + .array( + z.object({ + purchaseId: z.number(), + transactionId: z.string(), + subscriberId: z.number().optional(), + status: z.string(), + emailAddress: z.string(), + currency: z.string(), + subtotal: z.number(), + discount: z.number(), + tax: z.number(), + shipping: z.number(), + total: z.number(), + transactionTime: z.string(), + products: z.array( + z.object({ + productName: z.string(), + productId: z.string().nullable(), + lineItemId: z.string().nullable(), + quantity: z.number(), + unitPrice: z.number(), + sku: z.string().nullable() + }) + ) + }) + ) + .optional() + .describe('Purchase records for list action'), + purchase: z + .object({ + purchaseId: z.number(), + transactionId: z.string(), + subscriberId: z.number().optional(), + status: z.string(), + emailAddress: z.string(), + currency: z.string(), + subtotal: z.number(), + discount: z.number(), + tax: z.number(), + shipping: z.number(), + total: z.number(), + transactionTime: z.string(), + products: z.array( + z.object({ + productName: z.string(), + productId: z.string().nullable(), + lineItemId: z.string().nullable(), + quantity: z.number(), + unitPrice: z.number(), + sku: z.string().nullable() + }) + ) + }) + .optional() + .describe('Purchase record for get action'), + hasNextPage: z.boolean().optional(), + nextCursor: z.string().nullable().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth); + + if (ctx.input.action === 'list') { + let result = await client.listPurchases({ + perPage: ctx.input.perPage, + after: ctx.input.cursor + }); + let purchases = result.purchases.map(formatPurchase); + return { + output: { + purchases, + hasNextPage: result.pagination.has_next_page, + nextCursor: result.pagination.end_cursor + }, + message: `Found **${purchases.length}** purchase(s)${result.pagination.has_next_page ? ' (more available)' : ''}.` + }; + } + + if (!ctx.input.purchaseId) { + throw kitServiceError('purchaseId is required for get'); + } + + let purchase = await client.getPurchase(ctx.input.purchaseId); + return { + output: { + purchase: formatPurchase(purchase) + }, + message: `Purchase **${purchase.transaction_id}** — ${purchase.currency} ${purchase.total}` + }; + }); diff --git a/integrations/convertkit/src/tools/manage-sequence-emails.ts b/integrations/convertkit/src/tools/manage-sequence-emails.ts new file mode 100644 index 0000000000..f4c58968c4 --- /dev/null +++ b/integrations/convertkit/src/tools/manage-sequence-emails.ts @@ -0,0 +1,231 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; +import type { SequenceEmail } from '../lib/types'; +import { spec } from '../spec'; + +let formatSequenceEmail = (email: SequenceEmail) => ({ + emailId: email.id, + sequenceId: email.sequence_id, + subject: email.subject, + previewText: email.preview_text, + emailAddress: email.email_address, + emailTemplateId: email.email_template_id, + published: email.published, + position: email.position, + delayValue: email.delay_value, + delayUnit: email.delay_unit, + sendDays: email.send_days, + content: email.content +}); + +export let manageSequenceEmails = SlateTool.create(spec, { + name: 'Manage Sequence Emails', + key: 'manage_sequence_emails', + description: + 'List, create, get, update, or delete emails inside a Kit sequence. Sequence emails are the ordered steps subscribers receive after entering a sequence.', + instructions: [ + 'Use action "list" with sequenceId to retrieve emails in a sequence.', + 'Use action "create" with sequenceId, subject, delayValue, and delayUnit to create a draft sequence email.', + 'Use action "get" with sequenceId and emailId to retrieve one email including content.', + 'Use action "update" with sequenceId, emailId, and fields to change.', + 'Use action "delete" with sequenceId and emailId to permanently remove a sequence email.' + ] +}) + .input( + z.object({ + action: z + .enum(['list', 'create', 'get', 'update', 'delete']) + .describe('Action to perform'), + sequenceId: z.number().optional().describe('Sequence ID (required for all actions)'), + emailId: z + .number() + .optional() + .describe('Sequence email ID (required for get/update/delete)'), + subject: z.string().optional().describe('Subject line for create or update'), + previewText: z + .string() + .nullable() + .optional() + .describe('Preview text for create or update'), + content: z.string().nullable().optional().describe('HTML body content'), + delayValue: z + .number() + .optional() + .describe('Delay amount before sending this email after the previous one'), + delayUnit: z + .enum(['days', 'hours']) + .optional() + .describe( + 'Delay unit. Use days for schedule-aware delivery or hours for fixed delay.' + ), + emailTemplateId: z + .number() + .nullable() + .optional() + .describe('Email template ID, or null to clear on update'), + published: z.boolean().optional().describe('Whether the email is active'), + sendDays: z + .array( + z.enum([ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday' + ]) + ) + .nullable() + .optional() + .describe('Days of week this email may send, or null to reset to all days'), + position: z + .number() + .nullable() + .optional() + .describe('Zero-based position in the sequence, or null to append/reset'), + includeContent: z + .boolean() + .optional() + .describe('For list, include email content in each item'), + perPage: z.number().optional().describe('Results per page for list'), + cursor: z.string().optional().describe('Pagination cursor for list') + }) + ) + .output( + z.object({ + emails: z + .array( + z.object({ + emailId: z.number(), + sequenceId: z.number(), + subject: z.string(), + previewText: z.string().nullable(), + emailAddress: z.string(), + emailTemplateId: z.number().nullable(), + published: z.boolean(), + position: z.number().nullable(), + delayValue: z.number(), + delayUnit: z.string(), + sendDays: z.array(z.string()), + content: z.string().nullable().optional() + }) + ) + .optional() + .describe('Sequence email records for list action'), + email: z + .object({ + emailId: z.number(), + sequenceId: z.number(), + subject: z.string(), + previewText: z.string().nullable(), + emailAddress: z.string(), + emailTemplateId: z.number().nullable(), + published: z.boolean(), + position: z.number().nullable(), + delayValue: z.number(), + delayUnit: z.string(), + sendDays: z.array(z.string()), + content: z.string().nullable().optional() + }) + .optional() + .describe('Sequence email record for create, get, or update'), + deleted: z.boolean().optional().describe('Whether the sequence email was deleted'), + hasNextPage: z.boolean().optional(), + nextCursor: z.string().nullable().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth); + let input = ctx.input; + + if (!input.sequenceId) { + throw kitServiceError('sequenceId is required'); + } + + if (input.action === 'list') { + let result = await client.listSequenceEmails(input.sequenceId, { + includeContent: input.includeContent, + perPage: input.perPage, + after: input.cursor + }); + let emails = result.emails.map(formatSequenceEmail); + return { + output: { + emails, + hasNextPage: result.pagination.has_next_page, + nextCursor: result.pagination.end_cursor + }, + message: `Found **${emails.length}** sequence email(s)${result.pagination.has_next_page ? ' (more available)' : ''}.` + }; + } + + if (input.action === 'create') { + if (!input.subject) throw kitServiceError('subject is required for create'); + if (input.delayValue === undefined) + throw kitServiceError('delayValue is required for create'); + if (!input.delayUnit) throw kitServiceError('delayUnit is required for create'); + + let email = await client.createSequenceEmail(input.sequenceId, { + subject: input.subject, + previewText: input.previewText, + content: input.content, + delayValue: input.delayValue, + delayUnit: input.delayUnit, + emailTemplateId: input.emailTemplateId, + published: input.published, + sendDays: input.sendDays, + position: input.position + }); + return { + output: { + email: formatSequenceEmail(email) + }, + message: `Created sequence email **${email.subject}** (#${email.id})` + }; + } + + if (!input.emailId) { + throw kitServiceError(`emailId is required for ${input.action}`); + } + + if (input.action === 'get') { + let email = await client.getSequenceEmail(input.sequenceId, input.emailId); + return { + output: { + email: formatSequenceEmail(email) + }, + message: `Sequence email **${email.subject}** (#${email.id})` + }; + } + + if (input.action === 'update') { + let email = await client.updateSequenceEmail(input.sequenceId, input.emailId, { + subject: input.subject, + previewText: input.previewText, + content: input.content, + delayValue: input.delayValue, + delayUnit: input.delayUnit, + emailTemplateId: input.emailTemplateId, + published: input.published, + sendDays: input.sendDays, + position: input.position + }); + return { + output: { + email: formatSequenceEmail(email) + }, + message: `Updated sequence email **${email.subject}** (#${email.id})` + }; + } + + await client.deleteSequenceEmail(input.sequenceId, input.emailId); + return { + output: { + deleted: true + }, + message: `Deleted sequence email #${input.emailId}` + }; + }); diff --git a/integrations/convertkit/src/tools/manage-sequences.ts b/integrations/convertkit/src/tools/manage-sequences.ts index 2c56991d0a..69755d434e 100644 --- a/integrations/convertkit/src/tools/manage-sequences.ts +++ b/integrations/convertkit/src/tools/manage-sequences.ts @@ -1,21 +1,80 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; +import type { Sequence } from '../lib/types'; import { spec } from '../spec'; +let formatSequence = (s: Sequence) => ({ + sequenceId: s.id, + sequenceName: s.name, + hold: s.hold, + repeat: s.repeat, + active: s.active, + createdAt: s.created_at, + updatedAt: s.updated_at, + emailAddress: s.email_address, + emailTemplateId: s.email_template_id, + sendDays: s.send_days, + sendHour: s.send_hour, + timeZone: s.time_zone, + emailCount: s.email_count, + subscriberCount: s.subscriber_count +}); + export let manageSequences = SlateTool.create(spec, { name: 'Manage Sequences', key: 'manage_sequences', - description: `List email sequences or enroll subscribers in a sequence. Sequences are automated drip email campaigns that send a series of emails over time.`, + description: `Create, update, get, delete, list email sequences, or enroll subscribers in a sequence. Sequences are automated drip email campaigns that send a series of emails over time.`, instructions: [ 'Use action "list" to view all sequences.', + 'Use action "create" with name to create an empty sequence.', + 'Use action "get" with sequenceId to retrieve sequence settings.', + 'Use action "update" with sequenceId and fields to change sequence settings.', + 'Use action "delete" with sequenceId to soft-delete a sequence.', 'Use action "add_subscriber" to enroll a subscriber — provide sequenceId and either subscriberId or subscriberEmail.' ] }) .input( z.object({ - action: z.enum(['list', 'add_subscriber']).describe('Action to perform'), + action: z + .enum(['list', 'create', 'get', 'update', 'delete', 'add_subscriber']) + .describe('Action to perform'), sequenceId: z.number().optional().describe('Sequence ID (required for add_subscriber)'), + name: z.string().optional().describe('Sequence name (required for create)'), + emailAddress: z.string().optional().describe('Sending email address to use'), + emailTemplateId: z.number().optional().describe('Email template ID to use'), + sendDays: z + .array( + z.enum([ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday' + ]) + ) + .optional() + .describe('Days of week the sequence may send on'), + sendHour: z.number().optional().describe('Hour of day to send, 0 through 23'), + timeZone: z.string().optional().describe('IANA timezone for sequence sends'), + active: z.boolean().optional().describe('Whether the sequence is active'), + repeat: z.boolean().optional().describe('Whether subscribers can restart the sequence'), + hold: z + .boolean() + .optional() + .describe('Whether subscribers stay active after receiving all published emails'), + excludeSubscriberSources: z + .array( + z.object({ + type: z.enum(['tag', 'sequence', 'form', 'segment']), + ids: z.array(z.number()) + }) + ) + .optional() + .describe('Subscriber sources to exclude from the sequence'), subscriberId: z.number().optional().describe('Subscriber ID to enroll'), subscriberEmail: z.string().optional().describe('Subscriber email to enroll'), perPage: z.number().optional().describe('Results per page'), @@ -31,28 +90,51 @@ export let manageSequences = SlateTool.create(spec, { sequenceName: z.string().describe('Sequence name'), hold: z.boolean().describe('Whether the sequence is on hold'), repeat: z.boolean().describe('Whether the sequence repeats'), - createdAt: z.string().describe('Creation timestamp') + active: z.boolean().optional().describe('Whether the sequence is active'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().optional().describe('Last update timestamp'), + emailAddress: z.string().nullable().optional(), + emailTemplateId: z.number().nullable().optional(), + sendDays: z.array(z.string()).optional(), + sendHour: z.number().optional(), + timeZone: z.string().optional(), + emailCount: z.number().optional(), + subscriberCount: z.number().optional() }) ) .optional() .describe('List of sequences (for list action)'), + sequence: z + .object({ + sequenceId: z.number().describe('Sequence ID'), + sequenceName: z.string().describe('Sequence name'), + hold: z.boolean().describe('Whether the sequence is on hold'), + repeat: z.boolean().describe('Whether the sequence repeats'), + active: z.boolean().optional().describe('Whether the sequence is active'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().optional().describe('Last update timestamp'), + emailAddress: z.string().nullable().optional(), + emailTemplateId: z.number().nullable().optional(), + sendDays: z.array(z.string()).optional(), + sendHour: z.number().optional(), + timeZone: z.string().optional(), + emailCount: z.number().optional(), + subscriberCount: z.number().optional() + }) + .optional() + .describe('Single sequence (for create, get, update)'), + deleted: z.boolean().optional().describe('Whether the sequence was deleted'), hasNextPage: z.boolean().optional(), nextCursor: z.string().nullable().optional() }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let input = ctx.input; if (input.action === 'list') { let result = await client.listSequences({ perPage: input.perPage, after: input.cursor }); - let sequences = result.sequences.map(s => ({ - sequenceId: s.id, - sequenceName: s.name, - hold: s.hold, - repeat: s.repeat, - createdAt: s.created_at - })); + let sequences = result.sequences.map(formatSequence); return { output: { sequences, @@ -63,8 +145,75 @@ export let manageSequences = SlateTool.create(spec, { }; } + if (input.action === 'create') { + if (!input.name) throw kitServiceError('name is required for create'); + let sequence = await client.createSequence({ + name: input.name, + emailAddress: input.emailAddress, + emailTemplateId: input.emailTemplateId, + sendDays: input.sendDays, + sendHour: input.sendHour, + timeZone: input.timeZone, + active: input.active, + repeat: input.repeat, + hold: input.hold, + excludeSubscriberSources: input.excludeSubscriberSources + }); + return { + output: { + sequence: formatSequence(sequence) + }, + message: `Created sequence **${sequence.name}** (#${sequence.id})` + }; + } + + if (input.action === 'get') { + if (!input.sequenceId) throw kitServiceError('sequenceId is required for get'); + let sequence = await client.getSequence(input.sequenceId); + return { + output: { + sequence: formatSequence(sequence) + }, + message: `Sequence **${sequence.name}** (#${sequence.id})` + }; + } + + if (input.action === 'update') { + if (!input.sequenceId) throw kitServiceError('sequenceId is required for update'); + let sequence = await client.updateSequence(input.sequenceId, { + name: input.name, + emailAddress: input.emailAddress, + emailTemplateId: input.emailTemplateId, + sendDays: input.sendDays, + sendHour: input.sendHour, + timeZone: input.timeZone, + active: input.active, + repeat: input.repeat, + hold: input.hold, + excludeSubscriberSources: input.excludeSubscriberSources + }); + return { + output: { + sequence: formatSequence(sequence) + }, + message: `Updated sequence **${sequence.name}** (#${sequence.id})` + }; + } + + if (input.action === 'delete') { + if (!input.sequenceId) throw kitServiceError('sequenceId is required for delete'); + await client.deleteSequence(input.sequenceId); + return { + output: { + deleted: true + }, + message: `Deleted sequence #${input.sequenceId}` + }; + } + if (input.action === 'add_subscriber') { - if (!input.sequenceId) throw new Error('sequenceId is required for add_subscriber'); + if (!input.sequenceId) + throw kitServiceError('sequenceId is required for add_subscriber'); if (input.subscriberId) { await client.addSubscriberToSequenceById(input.sequenceId, input.subscriberId); return { @@ -78,8 +227,8 @@ export let manageSequences = SlateTool.create(spec, { message: `Enrolled **${input.subscriberEmail}** in sequence #${input.sequenceId}` }; } - throw new Error('subscriberId or subscriberEmail is required for add_subscriber'); + throw kitServiceError('subscriberId or subscriberEmail is required for add_subscriber'); } - throw new Error(`Unknown action: ${input.action}`); + throw kitServiceError(`Unknown action: ${input.action}`); }); diff --git a/integrations/convertkit/src/tools/manage-snippets.ts b/integrations/convertkit/src/tools/manage-snippets.ts new file mode 100644 index 0000000000..e437eb4c0a --- /dev/null +++ b/integrations/convertkit/src/tools/manage-snippets.ts @@ -0,0 +1,196 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; +import type { Snippet } from '../lib/types'; +import { spec } from '../spec'; + +let formatSnippet = (snippet: Snippet) => ({ + snippetId: snippet.id, + snippetName: snippet.name, + snippetType: snippet.snippet_type, + archived: snippet.archived, + key: snippet.key, + createdAt: snippet.created_at, + updatedAt: snippet.updated_at, + content: snippet.content, + document: snippet.document + ? { + documentId: snippet.document.id, + value: snippet.document.value, + valueHtml: snippet.document.value_html, + valuePlain: snippet.document.value_plain, + version: snippet.document.version + } + : undefined +}); + +export let manageSnippets = SlateTool.create(spec, { + name: 'Manage Snippets', + key: 'manage_snippets', + description: + 'List, create, get, update, archive, or restore Kit snippets used as reusable Liquid content in broadcasts and sequence emails.', + instructions: [ + 'Use action "list" to retrieve snippets. Set includeContent when you need snippet bodies.', + 'Use action "create" with snippetName, snippetType, and either content for inline snippets or blockHtml for block snippets.', + 'Use action "get" with snippetId to retrieve one snippet including content.', + 'Use action "update" with snippetId and fields to change.', + 'Use action "archive" or "restore" with snippetId to hide or reactivate a snippet.' + ] +}) + .input( + z.object({ + action: z + .enum(['list', 'create', 'get', 'update', 'archive', 'restore']) + .describe('Action to perform'), + snippetId: z + .number() + .optional() + .describe('Snippet ID (required for get, update, archive, restore)'), + snippetName: z.string().optional().describe('Snippet name for create or update'), + snippetType: z + .enum(['inline', 'block']) + .optional() + .describe('Snippet type. Inline snippets use content; block snippets use blockHtml.'), + content: z + .string() + .optional() + .describe('Liquid-enabled text content for inline snippets'), + blockHtml: z.string().optional().describe('HTML content for block snippets'), + archived: z.boolean().optional().describe('Filter list by archived state'), + includeContent: z + .boolean() + .optional() + .describe('For list, include content and document fields'), + perPage: z.number().optional().describe('Results per page for list'), + cursor: z.string().optional().describe('Pagination cursor for list') + }) + ) + .output( + z.object({ + snippets: z + .array( + z.object({ + snippetId: z.number(), + snippetName: z.string(), + snippetType: z.string(), + archived: z.boolean(), + key: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + content: z.string().nullable().optional(), + document: z + .object({ + documentId: z.number(), + value: z.string().nullable(), + valueHtml: z.string().nullable(), + valuePlain: z.string().nullable(), + version: z.number() + }) + .optional() + }) + ) + .optional() + .describe('Snippet records for list action'), + snippet: z + .object({ + snippetId: z.number(), + snippetName: z.string(), + snippetType: z.string(), + archived: z.boolean(), + key: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + content: z.string().nullable().optional(), + document: z + .object({ + documentId: z.number(), + value: z.string().nullable(), + valueHtml: z.string().nullable(), + valuePlain: z.string().nullable(), + version: z.number() + }) + .optional() + }) + .optional() + .describe('Snippet record for create, get, update, archive, or restore'), + hasNextPage: z.boolean().optional(), + nextCursor: z.string().nullable().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth); + let input = ctx.input; + + if (input.action === 'list') { + let result = await client.listSnippets({ + snippetType: input.snippetType, + archived: input.archived, + includeContent: input.includeContent, + perPage: input.perPage, + after: input.cursor + }); + let snippets = result.snippets.map(formatSnippet); + return { + output: { + snippets, + hasNextPage: result.pagination.has_next_page, + nextCursor: result.pagination.end_cursor + }, + message: `Found **${snippets.length}** snippet(s)${result.pagination.has_next_page ? ' (more available)' : ''}.` + }; + } + + if (input.action === 'create') { + if (!input.snippetName) throw kitServiceError('snippetName is required for create'); + if (!input.snippetType) throw kitServiceError('snippetType is required for create'); + if (input.snippetType === 'inline' && !input.content) { + throw kitServiceError('content is required when creating an inline snippet'); + } + if (input.snippetType === 'block' && !input.blockHtml) { + throw kitServiceError('blockHtml is required when creating a block snippet'); + } + let snippet = await client.createSnippet({ + name: input.snippetName, + snippetType: input.snippetType, + content: input.content, + blockHtml: input.blockHtml + }); + return { + output: { + snippet: formatSnippet(snippet) + }, + message: `Created snippet **${snippet.name}** (#${snippet.id})` + }; + } + + if (!input.snippetId) { + throw kitServiceError(`snippetId is required for ${input.action}`); + } + + if (input.action === 'get') { + let snippet = await client.getSnippet(input.snippetId); + return { + output: { + snippet: formatSnippet(snippet) + }, + message: `Snippet **${snippet.name}** (#${snippet.id})` + }; + } + + let snippet = await client.updateSnippet(input.snippetId, { + name: input.snippetName, + snippetType: input.snippetType, + archived: + input.action === 'archive' ? true : input.action === 'restore' ? false : undefined, + content: input.content, + blockHtml: input.blockHtml + }); + + return { + output: { + snippet: formatSnippet(snippet) + }, + message: `Updated snippet **${snippet.name}** (#${snippet.id})` + }; + }); diff --git a/integrations/convertkit/src/tools/manage-subscriber.ts b/integrations/convertkit/src/tools/manage-subscriber.ts index 659cfbd16f..ad78c18111 100644 --- a/integrations/convertkit/src/tools/manage-subscriber.ts +++ b/integrations/convertkit/src/tools/manage-subscriber.ts @@ -1,23 +1,26 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageSubscriber = SlateTool.create(spec, { name: 'Manage Subscriber', key: 'manage_subscriber', - description: `Create, update, get, or unsubscribe a subscriber. Use this to add new subscribers, update existing subscriber info (name, email, custom fields), look up subscriber details by ID, or unsubscribe a subscriber.`, + description: `Create, update, get, unsubscribe, list tags for, or retrieve engagement stats for a subscriber. Use this to add new subscribers, update existing subscriber info (name, email, custom fields), look up subscriber details by ID, inspect tags/stats, or unsubscribe a subscriber.`, instructions: [ 'To create a new subscriber, set action to "create" and provide emailAddress.', 'To update, set action to "update" and provide subscriberId plus the fields to change.', 'To unsubscribe, set action to "unsubscribe" and provide subscriberId.', - 'To look up a subscriber, set action to "get" and provide subscriberId.' + 'To look up a subscriber, set action to "get" and provide subscriberId.', + 'To list tags for a subscriber, set action to "list_tags" and provide subscriberId.', + 'To retrieve engagement stats, set action to "get_stats" and provide subscriberId.' ] }) .input( z.object({ action: z - .enum(['create', 'update', 'get', 'unsubscribe']) + .enum(['create', 'update', 'get', 'unsubscribe', 'list_tags', 'get_stats']) .describe('Action to perform on the subscriber'), subscriberId: z .number() @@ -35,7 +38,15 @@ export let manageSubscriber = SlateTool.create(spec, { customFields: z .record(z.string(), z.string()) .optional() - .describe('Custom field values as key-value pairs') + .describe('Custom field values as key-value pairs'), + emailSentAfter: z + .string() + .optional() + .describe('For get_stats, include stats for emails sent after this YYYY-MM-DD date'), + emailSentBefore: z + .string() + .optional() + .describe('For get_stats, include stats for emails sent before this YYYY-MM-DD date') }) ) .output( @@ -49,15 +60,42 @@ export let manageSubscriber = SlateTool.create(spec, { .record(z.string(), z.string().nullable()) .optional() .describe('Custom field values'), - unsubscribed: z.boolean().optional().describe('Whether the subscriber was unsubscribed') + unsubscribed: z.boolean().optional().describe('Whether the subscriber was unsubscribed'), + tags: z + .array( + z.object({ + tagId: z.number(), + tagName: z.string(), + createdAt: z.string() + }) + ) + .optional() + .describe('Tags currently assigned to the subscriber'), + stats: z + .object({ + sent: z.number(), + opened: z.number(), + clicked: z.number(), + bounced: z.number(), + openRate: z.number(), + clickRate: z.number(), + lastSent: z.string().nullable(), + lastOpened: z.string().nullable(), + lastClicked: z.string().nullable(), + sendsSinceLastOpen: z.number(), + sendsSinceLastClick: z.number() + }) + .optional() + .describe('Subscriber email engagement stats') }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let input = ctx.input; if (input.action === 'unsubscribe') { - if (!input.subscriberId) throw new Error('subscriberId is required for unsubscribe'); + if (!input.subscriberId) + throw kitServiceError('subscriberId is required for unsubscribe'); await client.unsubscribeSubscriber(input.subscriberId); return { output: { @@ -69,7 +107,7 @@ export let manageSubscriber = SlateTool.create(spec, { } if (input.action === 'get') { - if (!input.subscriberId) throw new Error('subscriberId is required for get'); + if (!input.subscriberId) throw kitServiceError('subscriberId is required for get'); let sub = await client.getSubscriber(input.subscriberId); return { output: { @@ -85,7 +123,7 @@ export let manageSubscriber = SlateTool.create(spec, { } if (input.action === 'create') { - if (!input.emailAddress) throw new Error('emailAddress is required for create'); + if (!input.emailAddress) throw kitServiceError('emailAddress is required for create'); let sub = await client.createSubscriber({ emailAddress: input.emailAddress, firstName: input.firstName, @@ -106,7 +144,7 @@ export let manageSubscriber = SlateTool.create(spec, { } if (input.action === 'update') { - if (!input.subscriberId) throw new Error('subscriberId is required for update'); + if (!input.subscriberId) throw kitServiceError('subscriberId is required for update'); let sub = await client.updateSubscriber(input.subscriberId, { emailAddress: input.emailAddress, firstName: input.firstName, @@ -125,5 +163,49 @@ export let manageSubscriber = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${input.action}`); + if (input.action === 'list_tags') { + if (!input.subscriberId) throw kitServiceError('subscriberId is required for list_tags'); + let result = await client.getSubscriberTags(input.subscriberId); + let tags = result.tags.map(tag => ({ + tagId: tag.id, + tagName: tag.name, + createdAt: tag.created_at + })); + return { + output: { + subscriberId: input.subscriberId, + tags + }, + message: `Found **${tags.length}** tag(s) for subscriber #${input.subscriberId}.` + }; + } + + if (input.action === 'get_stats') { + if (!input.subscriberId) throw kitServiceError('subscriberId is required for get_stats'); + let stats = await client.getSubscriberStats(input.subscriberId, { + emailSentAfter: input.emailSentAfter, + emailSentBefore: input.emailSentBefore + }); + return { + output: { + subscriberId: input.subscriberId, + stats: { + sent: stats.sent, + opened: stats.opened, + clicked: stats.clicked, + bounced: stats.bounced, + openRate: stats.open_rate, + clickRate: stats.click_rate, + lastSent: stats.last_sent, + lastOpened: stats.last_opened, + lastClicked: stats.last_clicked, + sendsSinceLastOpen: stats.sends_since_last_open, + sendsSinceLastClick: stats.sends_since_last_click + } + }, + message: `Retrieved engagement stats for subscriber #${input.subscriberId}.` + }; + } + + throw kitServiceError(`Unknown action: ${input.action}`); }); diff --git a/integrations/convertkit/src/tools/manage-tags.ts b/integrations/convertkit/src/tools/manage-tags.ts index b0b97a3373..6f49ce472d 100644 --- a/integrations/convertkit/src/tools/manage-tags.ts +++ b/integrations/convertkit/src/tools/manage-tags.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageTags = SlateTool.create(spec, { @@ -61,7 +62,7 @@ export let manageTags = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let input = ctx.input; if (input.action === 'list') { @@ -82,7 +83,7 @@ export let manageTags = SlateTool.create(spec, { } if (input.action === 'create') { - if (!input.tagName) throw new Error('tagName is required for create'); + if (!input.tagName) throw kitServiceError('tagName is required for create'); let tag = await client.createTag(input.tagName); return { output: { @@ -93,8 +94,8 @@ export let manageTags = SlateTool.create(spec, { } if (input.action === 'update') { - if (!input.tagId) throw new Error('tagId is required for update'); - if (!input.tagName) throw new Error('tagName is required for update'); + if (!input.tagId) throw kitServiceError('tagId is required for update'); + if (!input.tagName) throw kitServiceError('tagName is required for update'); let tag = await client.updateTag(input.tagId, input.tagName); return { output: { @@ -105,7 +106,7 @@ export let manageTags = SlateTool.create(spec, { } if (input.action === 'add_to_subscriber') { - if (!input.tagId) throw new Error('tagId is required for add_to_subscriber'); + if (!input.tagId) throw kitServiceError('tagId is required for add_to_subscriber'); if (input.subscriberId) { await client.tagSubscriberById(input.tagId, input.subscriberId); return { @@ -119,13 +120,15 @@ export let manageTags = SlateTool.create(spec, { message: `Added tag #${input.tagId} to subscriber **${input.subscriberEmail}**` }; } - throw new Error('subscriberId or subscriberEmail is required for add_to_subscriber'); + throw kitServiceError( + 'subscriberId or subscriberEmail is required for add_to_subscriber' + ); } if (input.action === 'remove_from_subscriber') { - if (!input.tagId) throw new Error('tagId is required for remove_from_subscriber'); + if (!input.tagId) throw kitServiceError('tagId is required for remove_from_subscriber'); if (!input.subscriberId) - throw new Error('subscriberId is required for remove_from_subscriber'); + throw kitServiceError('subscriberId is required for remove_from_subscriber'); await client.untagSubscriberById(input.tagId, input.subscriberId); return { output: {}, @@ -133,5 +136,5 @@ export let manageTags = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${input.action}`); + throw kitServiceError(`Unknown action: ${input.action}`); }); diff --git a/integrations/convertkit/src/triggers/form-subscription-event.ts b/integrations/convertkit/src/triggers/form-subscription-event.ts index e819e7658a..c6cf36846c 100644 --- a/integrations/convertkit/src/triggers/form-subscription-event.ts +++ b/integrations/convertkit/src/triggers/form-subscription-event.ts @@ -1,6 +1,6 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; import { spec } from '../spec'; export let formSubscriptionEvent = SlateTrigger.create(spec, { @@ -33,7 +33,7 @@ export let formSubscriptionEvent = SlateTrigger.create(spec, { ) .webhook({ autoRegisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let formsResult = await client.listForms({ status: 'active', perPage: 500 }); let webhookIds: number[] = []; @@ -51,7 +51,7 @@ export let formSubscriptionEvent = SlateTrigger.create(spec, { }, autoUnregisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let details = ctx.input.registrationDetails as { webhookIds: number[] }; for (let webhookId of details.webhookIds) { diff --git a/integrations/convertkit/src/triggers/purchase-event.ts b/integrations/convertkit/src/triggers/purchase-event.ts index 07ed14203e..ec9d78ab91 100644 --- a/integrations/convertkit/src/triggers/purchase-event.ts +++ b/integrations/convertkit/src/triggers/purchase-event.ts @@ -1,6 +1,6 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; import { spec } from '../spec'; export let purchaseEvent = SlateTrigger.create(spec, { @@ -52,7 +52,7 @@ export let purchaseEvent = SlateTrigger.create(spec, { ) .webhook({ autoRegisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let webhook = await client.createWebhook(ctx.input.webhookBaseUrl, { name: 'purchase.purchase_create' @@ -64,7 +64,7 @@ export let purchaseEvent = SlateTrigger.create(spec, { }, autoUnregisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let details = ctx.input.registrationDetails as { webhookId: number }; try { diff --git a/integrations/convertkit/src/triggers/sequence-event.ts b/integrations/convertkit/src/triggers/sequence-event.ts index f3c2498810..cbd18aecef 100644 --- a/integrations/convertkit/src/triggers/sequence-event.ts +++ b/integrations/convertkit/src/triggers/sequence-event.ts @@ -1,6 +1,6 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; import { spec } from '../spec'; export let sequenceEvent = SlateTrigger.create(spec, { @@ -34,7 +34,7 @@ export let sequenceEvent = SlateTrigger.create(spec, { ) .webhook({ autoRegisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let sequencesResult = await client.listSequences({ perPage: 500 }); let webhookIds: number[] = []; @@ -58,7 +58,7 @@ export let sequenceEvent = SlateTrigger.create(spec, { }, autoUnregisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let details = ctx.input.registrationDetails as { webhookIds: number[] }; for (let webhookId of details.webhookIds) { diff --git a/integrations/convertkit/src/triggers/subscriber-event.ts b/integrations/convertkit/src/triggers/subscriber-event.ts index 2e60edf2fc..e828f4c7d4 100644 --- a/integrations/convertkit/src/triggers/subscriber-event.ts +++ b/integrations/convertkit/src/triggers/subscriber-event.ts @@ -1,6 +1,6 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; import { spec } from '../spec'; export let subscriberEvent = SlateTrigger.create(spec, { @@ -32,7 +32,7 @@ export let subscriberEvent = SlateTrigger.create(spec, { ) .webhook({ autoRegisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let eventNames = [ 'subscriber.subscriber_activate', @@ -55,7 +55,7 @@ export let subscriberEvent = SlateTrigger.create(spec, { }, autoUnregisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let details = ctx.input.registrationDetails as { webhookIds: number[] }; for (let webhookId of details.webhookIds) { diff --git a/integrations/convertkit/src/triggers/tag-event.ts b/integrations/convertkit/src/triggers/tag-event.ts index b7846aca6a..b5cdaf539b 100644 --- a/integrations/convertkit/src/triggers/tag-event.ts +++ b/integrations/convertkit/src/triggers/tag-event.ts @@ -1,6 +1,6 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { createClient } from '../lib/client'; import { spec } from '../spec'; export let tagEvent = SlateTrigger.create(spec, { @@ -34,7 +34,7 @@ export let tagEvent = SlateTrigger.create(spec, { ) .webhook({ autoRegisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); // We need to register for all tags. Since the API requires a specific tag_id, // we list all tags and register webhooks for each. @@ -63,7 +63,7 @@ export let tagEvent = SlateTrigger.create(spec, { }, autoUnregisterWebhook: async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = createClient(ctx.auth); let details = ctx.input.registrationDetails as { webhookIds: number[] }; for (let webhookId of details.webhookIds) { diff --git a/integrations/convertkit/vitest.config.ts b/integrations/convertkit/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/convertkit/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/crisp/README.md b/integrations/crisp/README.md index 84a7bcc6b3..0fa656f6e9 100644 --- a/integrations/crisp/README.md +++ b/integrations/crisp/README.md @@ -1,6 +1,6 @@ # Crisp -Manage customer conversations, contacts, and support operations across multiple channels. Send and receive messages (text, files, audio, carousels) in conversations, assign conversations to operators, and track conversation states. Create, update, search, and manage contact profiles with custom data, events, and subscription status. Build and manage helpdesk knowledge base articles with multi-language support. Create and manage marketing campaigns including one-shot and automated campaigns. Configure website workspace settings, chatbox appearance, and operator roles. Track visitors in real-time, monitor website availability status, and access analytics for messaging, contacts, ratings, and campaigns. Receive real-time events via webhooks or WebSocket for session changes, messages, campaigns, and more. +Manage customer conversations, contacts, and support operations across multiple channels. Send and receive messages, mark message status, assign conversations to operators, move conversations between inboxes, and track conversation states. Create, update, search, and manage contact profiles with custom data and subscription status. Browse and manage helpdesk knowledge base articles with multi-language support. Configure website workspace settings, inspect operator availability, monitor website availability status, and receive real-time events via polling or inbound webhooks. ## Tools @@ -36,10 +36,22 @@ Check the current online/offline availability status of the Crisp website (works List and search conversations in your Crisp workspace. Supports filtering by status (unread, resolved, assigned), date range, inbox, and text/segment search. Returns paginated conversation summaries with metadata. +### List Conversation Activity + +List pages viewed, custom events, or file messages for a conversation. Use this to inspect visitor context beyond the message transcript. + +### List Helpdesk Locales + +List helpdesk knowledge base locales configured for the workspace. Use locale IDs from this tool before listing or managing articles. + ### List Helpdesk Articles List helpdesk knowledge base articles for a specific locale. Returns paginated article summaries. Use this to browse your knowledge base or find articles to update. +### List Inboxes + +List website inboxes. Use inbox IDs to filter conversations or move a conversation with Update Conversation. + ### List Operators List all operators (team members) of the Crisp workspace. Returns operator details including user ID, email, role, and availability. Useful for finding operator IDs to assign conversations. @@ -52,6 +64,10 @@ List and search contact profiles in the Crisp CRM. Supports searching by name, e Create, update, or delete a helpdesk knowledge base article. Articles are organized by locale for multi-language support. You can set the title, content, category, featured status, and order. +### Manage Message Status + +Mark conversation messages as read, unread, or delivered using message fingerprints from Get Messages or Send Message when needed. + ### Manage Website Settings Get or update the Crisp website (workspace) settings. When no settings are provided, returns the current configuration. When settings are provided, updates them. Settings include chatbox appearance, contact info, email preferences, and more. @@ -70,7 +86,7 @@ Send a message in a Crisp conversation. Supports text messages, notes (internal) ### Update Conversation -Update a conversation's metadata, state, routing assignment, or block status. Combine multiple updates in a single call — set the nickname, assign an operator, change state to resolved, and add segments all at once. +Update a conversation's metadata, state, routing assignment, inbox, or block status. Combine multiple updates in a single call — set the nickname, assign an operator, change state to resolved, move inboxes, and add segments all at once. ### Update Person diff --git a/integrations/crisp/docs/SPEC.md b/integrations/crisp/docs/SPEC.md index 28068ecfee..0d3889fbb8 100644 --- a/integrations/crisp/docs/SPEC.md +++ b/integrations/crisp/docs/SPEC.md @@ -6,22 +6,22 @@ Crisp is a multichannel customer messaging platform that provides live chat, sha ## Authentication -Crisp uses **HTTP Basic Authentication** with a plugin token keypair (identifier and key). +Crisp uses **HTTP Basic Authentication** with an API token keypair (identifier and key). The `X-Crisp-Tier` header must match the token tier: `plugin`, `website`, or `user`. ### How to obtain credentials: -1. Register an account on the Crisp Marketplace to create a plugin and generate your token. -2. A Development token allows you to easily generate a token key/identifier pair which can be used on all plugin tier routes without any scope restriction. Ideal for development and testing purposes, this token however has lower quotas and can only be used on your trusted website (your Crisp workspace). -3. A Production token can be requested once you are ready to deploy your plugin into production. This token requires you to submit the scopes of the routes used by your plugin and allows you to request customizable quotas to fit your needs. +1. Use a plugin token for marketplace/plugin integrations. +2. Use a website token for private single-workspace automations. +3. Use a user token only when a required route is documented as user-tier only. ### How to authenticate: -You can authenticate by adding an Authorization header to all your HTTP calls. The Authorization header is formatted as such: `Authorization: Basic BASE64(identifier:key)`. Also, include the `X-Crisp-Tier` header in your HTTP requests, with the value `plugin`. This lets the REST API know that the token you are using is a plugin token, and not a regular user token. +You can authenticate by adding an Authorization header to all your HTTP calls. The Authorization header is formatted as such: `Authorization: Basic BASE64(identifier:key)`. Also, include the `X-Crisp-Tier` header in your HTTP requests, with the tier value that matches the token. - **Base URL**: `https://api.crisp.chat/v1/` - **Headers required**: - `Authorization: Basic BASE64(identifier:key)` - - `X-Crisp-Tier: plugin` + - `X-Crisp-Tier: plugin`, `website`, or `user` ### Scopes: diff --git a/integrations/crisp/package.json b/integrations/crisp/package.json index ff52afbe1d..b540a7dd77 100644 --- a/integrations/crisp/package.json +++ b/integrations/crisp/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/crisp/src/auth.ts b/integrations/crisp/src/auth.ts index 5db6e7425d..79b49cbad7 100644 --- a/integrations/crisp/src/auth.ts +++ b/integrations/crisp/src/auth.ts @@ -4,24 +4,34 @@ import { z } from 'zod'; export let auth = SlateAuth.create() .output( z.object({ - token: z.string().describe('Base64-encoded identifier:key for Basic Auth') + token: z.string().describe('Base64-encoded identifier:key for Basic Auth'), + tier: z + .enum(['plugin', 'website', 'user']) + .optional() + .describe('Crisp REST API token tier used for the X-Crisp-Tier header') }) ) .addCustomAuth({ type: 'auth.custom', - name: 'Plugin Token', + name: 'Crisp API Token', key: 'plugin_token', inputSchema: z.object({ - identifier: z.string().describe('Plugin token identifier from the Crisp Marketplace'), - key: z.string().describe('Plugin token key from the Crisp Marketplace') + identifier: z.string().describe('Crisp API token identifier'), + key: z.string().describe('Crisp API token key'), + tier: z + .enum(['plugin', 'website', 'user']) + .optional() + .default('plugin') + .describe('Crisp token tier to send as X-Crisp-Tier') }), getOutput: async ctx => { let encoded = btoa(`${ctx.input.identifier}:${ctx.input.key}`); return { output: { - token: encoded + token: encoded, + tier: ctx.input.tier } }; } diff --git a/integrations/crisp/src/index.ts b/integrations/crisp/src/index.ts index dd3dfaf882..2226193524 100644 --- a/integrations/crisp/src/index.ts +++ b/integrations/crisp/src/index.ts @@ -8,11 +8,15 @@ import { getMessages, getPerson, getWebsiteAvailability, + listConversationActivity, listConversations, listHelpdeskArticles, + listHelpdeskLocales, + listInboxes, listOperators, listPeople, manageHelpdeskArticle, + manageMessageStatus, manageWebsiteSettings, removeConversation, removePerson, @@ -38,13 +42,17 @@ export let provider = Slate.create({ removeConversation, sendMessage, getMessages, + manageMessageStatus, + listConversationActivity, listPeople, getPerson, createPerson, updatePerson, removePerson, + listHelpdeskLocales, manageHelpdeskArticle, listHelpdeskArticles, + listInboxes, listOperators, getWebsiteAvailability, batchConversationActions, diff --git a/integrations/crisp/src/lib/client.ts b/integrations/crisp/src/lib/client.ts index ae5fb034fe..596fa744a3 100644 --- a/integrations/crisp/src/lib/client.ts +++ b/integrations/crisp/src/lib/client.ts @@ -1,22 +1,32 @@ import { createAxios } from 'slates'; +import { withCrispErrorHandling } from './errors'; -let axios = createAxios({ - baseURL: 'https://api.crisp.chat/v1' -}); +let axios = withCrispErrorHandling( + createAxios({ + baseURL: 'https://api.crisp.chat/v1' + }), + 'request' +); export class Client { private token: string; private websiteId: string; + private tier: 'plugin' | 'website' | 'user'; - constructor(config: { token: string; websiteId: string }) { + constructor(config: { + token: string; + websiteId: string; + tier?: 'plugin' | 'website' | 'user'; + }) { this.token = config.token; this.websiteId = config.websiteId; + this.tier = config.tier ?? 'plugin'; } private headers() { return { Authorization: `Basic ${this.token}`, - 'X-Crisp-Tier': 'plugin', + 'X-Crisp-Tier': this.tier, 'Content-Type': 'application/json' }; } @@ -167,6 +177,16 @@ export class Client { ); } + async updateConversationInbox(sessionId: string, inboxId: string | null) { + await axios.patch( + this.url(`/conversation/${sessionId}/inbox`), + { inbox_id: inboxId }, + { + headers: this.headers() + } + ); + } + // ── Conversation Block ── async updateConversationBlock(sessionId: string, blocked: boolean) { @@ -239,27 +259,22 @@ export class Client { async markMessagesRead( sessionId: string, - from: string, + from: 'user' | 'operator', origin: string, - fingerprints: string[] + fingerprints?: number[] ) { - await axios.patch( - this.url(`/conversation/${sessionId}/read`), - { - from, - origin, - fingerprints - }, - { - headers: this.headers() - } - ); + let body: Record = { from, origin }; + if (fingerprints !== undefined) body.fingerprints = fingerprints; + + await axios.patch(this.url(`/conversation/${sessionId}/read`), body, { + headers: this.headers() + }); } - async markConversationUnread(sessionId: string) { + async markConversationUnread(sessionId: string, from: 'user') { await axios.patch( this.url(`/conversation/${sessionId}/unread`), - {}, + { from }, { headers: this.headers() } @@ -268,9 +283,9 @@ export class Client { async markMessagesDelivered( sessionId: string, - from: string, + from: 'operator', origin: string, - fingerprints: string[] + fingerprints: number[] ) { await axios.patch( this.url(`/conversation/${sessionId}/delivered`), @@ -572,6 +587,13 @@ export class Client { return response.data.data; } + async listOperatorAvailabilities() { + let response = await axios.get(this.url('/availability/operators'), { + headers: this.headers() + }); + return response.data.data; + } + async getOperator(userId: string) { let response = await axios.get(this.url(`/operator/${userId}`), { headers: this.headers() @@ -628,6 +650,14 @@ export class Client { return response.data.data; } + async listWebsiteInboxes(pageNumber?: number) { + let page = pageNumber ?? 1; + let response = await axios.get(this.url(`/inboxes/list/${page}`), { + headers: this.headers() + }); + return response.data.data; + } + // ── Availability ── async getWebsiteAvailability() { diff --git a/integrations/crisp/src/lib/errors.ts b/integrations/crisp/src/lib/errors.ts new file mode 100644 index 0000000000..7a2c55bbdd --- /dev/null +++ b/integrations/crisp/src/lib/errors.ts @@ -0,0 +1,98 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let message = String(value).trim(); + if (message && !messages.includes(message)) { + messages.push(message); + } +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (Array.isArray(value)) { + for (let item of value) collectMessages(item, messages); + return; + } + + if (!isRecord(value)) { + pushMessage(messages, value); + return; + } + + for (let key of ['message', 'reason', 'error', 'error_description', 'title', 'detail']) { + pushMessage(messages, value[key]); + } + + for (let nested of Object.values(value)) { + if (Array.isArray(nested) || isRecord(nested)) { + collectMessages(nested, messages); + } + } +}; + +let extractCrispMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectMessages(response?.data, messages); + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let crispServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let crispApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = crispServiceError( + `Crisp API ${operation} failed: ${statusLabel}${extractCrispMessage(error)}` + ); + serviceError.data.reason = 'crisp_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let withCrispErrorHandling = ( + http: T, + operation = 'request' +) => { + http.interceptors.response.use( + (response: unknown) => response, + (error: unknown) => Promise.reject(crispApiError(error, operation)) + ); + + return http; +}; diff --git a/integrations/crisp/src/tools.schema.test.ts b/integrations/crisp/src/tools.schema.test.ts new file mode 100644 index 0000000000..7c71c824b5 --- /dev/null +++ b/integrations/crisp/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Crisp tool input schemas', provider.actions); diff --git a/integrations/crisp/src/tools.validation.test.ts b/integrations/crisp/src/tools.validation.test.ts new file mode 100644 index 0000000000..e2b0157fd6 --- /dev/null +++ b/integrations/crisp/src/tools.validation.test.ts @@ -0,0 +1,110 @@ +import { ServiceError } from '@lowerdeck/error'; +import { describe, expect, it } from 'vitest'; +import { + createPerson, + manageHelpdeskArticle, + manageMessageStatus, + sendMessage, + updateConversation, + updatePerson +} from './tools'; + +let baseContext = { + auth: { token: 'token' }, + config: { websiteId: 'website-id' } +}; + +let invoke = (tool: any, input: unknown) => + tool.handleInvocation({ + ...baseContext, + input + }); + +describe('Crisp tool validation', () => { + it('requires identity data when creating a person', async () => { + await expect(invoke(createPerson, {})).rejects.toBeInstanceOf(ServiceError); + }); + + it('rejects empty person updates', async () => { + await expect(invoke(updatePerson, { peopleId: 'people-id' })).rejects.toBeInstanceOf( + ServiceError + ); + }); + + it('rejects empty and conflicting conversation updates', async () => { + await expect( + invoke(updateConversation, { sessionId: 'session-id' }) + ).rejects.toBeInstanceOf(ServiceError); + + await expect( + invoke(updateConversation, { + sessionId: 'session-id', + assignToOperatorId: 'operator-id', + unassign: true + }) + ).rejects.toBeInstanceOf(ServiceError); + + await expect( + invoke(updateConversation, { + sessionId: 'session-id', + inboxId: 'inbox-id', + moveToMainInbox: true + }) + ).rejects.toBeInstanceOf(ServiceError); + }); + + it('validates helpdesk article action-specific requirements', async () => { + await expect( + invoke(manageHelpdeskArticle, { localeId: 'en', delete: true }) + ).rejects.toBeInstanceOf(ServiceError); + + await expect(invoke(manageHelpdeskArticle, { localeId: 'en' })).rejects.toBeInstanceOf( + ServiceError + ); + + await expect( + invoke(manageHelpdeskArticle, { localeId: 'en', articleId: 'article-id' }) + ).rejects.toBeInstanceOf(ServiceError); + }); + + it('validates message status action-specific requirements', async () => { + await expect( + invoke(manageMessageStatus, { + sessionId: 'session-id', + action: 'mark_delivered', + origin: 'chat' + }) + ).rejects.toBeInstanceOf(ServiceError); + + await expect( + invoke(manageMessageStatus, { + sessionId: 'session-id', + action: 'mark_unread', + fingerprints: [123], + origin: 'chat' + }) + ).rejects.toBeInstanceOf(ServiceError); + }); + + it('validates basic message content shapes', async () => { + await expect( + invoke(sendMessage, { + sessionId: 'session-id', + type: 'text', + from: 'operator', + origin: 'chat', + content: { text: 'not a string' } + }) + ).rejects.toBeInstanceOf(ServiceError); + + await expect( + invoke(sendMessage, { + sessionId: 'session-id', + type: 'file', + from: 'operator', + origin: 'chat', + content: { url: 'https://example.com/file.txt' } + }) + ).rejects.toBeInstanceOf(ServiceError); + }); +}); diff --git a/integrations/crisp/src/tools/batch-conversation-actions.ts b/integrations/crisp/src/tools/batch-conversation-actions.ts index dfe71bdfe1..7ac595eb33 100644 --- a/integrations/crisp/src/tools/batch-conversation-actions.ts +++ b/integrations/crisp/src/tools/batch-conversation-actions.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { crispServiceError } from '../lib/errors'; import { spec } from '../spec'; export let batchConversationActions = SlateTool.create(spec, { @@ -29,7 +30,15 @@ export let batchConversationActions = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); + + if (ctx.input.sessionIds.length === 0) { + throw crispServiceError('Provide at least one conversation session ID.'); + } if (ctx.input.action === 'resolve') { await client.batchResolveConversations(ctx.input.sessionIds); diff --git a/integrations/crisp/src/tools/create-conversation.ts b/integrations/crisp/src/tools/create-conversation.ts index be0619247c..865f379133 100644 --- a/integrations/crisp/src/tools/create-conversation.ts +++ b/integrations/crisp/src/tools/create-conversation.ts @@ -18,7 +18,11 @@ export let createConversation = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let result = await client.createConversation(); return { diff --git a/integrations/crisp/src/tools/create-person.ts b/integrations/crisp/src/tools/create-person.ts index 5bed26f917..3dacd6b070 100644 --- a/integrations/crisp/src/tools/create-person.ts +++ b/integrations/crisp/src/tools/create-person.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { crispServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createPerson = SlateTool.create(spec, { @@ -20,7 +21,15 @@ export let createPerson = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); + + if (!ctx.input.email && !ctx.input.nickname) { + throw crispServiceError('Provide at least one of email or nickname.'); + } let body: any = {}; if (ctx.input.email) body.email = ctx.input.email; diff --git a/integrations/crisp/src/tools/get-conversation.ts b/integrations/crisp/src/tools/get-conversation.ts index b351d61f78..5d2f82e6de 100644 --- a/integrations/crisp/src/tools/get-conversation.ts +++ b/integrations/crisp/src/tools/get-conversation.ts @@ -43,7 +43,11 @@ export let getConversation = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let c = await client.getConversation(ctx.input.sessionId); return { diff --git a/integrations/crisp/src/tools/get-messages.ts b/integrations/crisp/src/tools/get-messages.ts index ab197cd0dd..8a8d765699 100644 --- a/integrations/crisp/src/tools/get-messages.ts +++ b/integrations/crisp/src/tools/get-messages.ts @@ -25,7 +25,7 @@ export let getMessages = SlateTool.create(spec, { messages: z .array( z.object({ - fingerprint: z.string().optional().describe('Unique message fingerprint'), + fingerprint: z.number().optional().describe('Unique message fingerprint'), type: z.string().optional().describe('Message type (text, note, file, etc.)'), from: z.string().optional().describe('Sender: operator or user'), origin: z.string().optional().describe('Origin channel'), @@ -41,7 +41,11 @@ export let getMessages = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let results = await client.getMessagesInConversation( ctx.input.sessionId, ctx.input.timestampBefore diff --git a/integrations/crisp/src/tools/get-person.ts b/integrations/crisp/src/tools/get-person.ts index b54f57d8f8..c80863bebd 100644 --- a/integrations/crisp/src/tools/get-person.ts +++ b/integrations/crisp/src/tools/get-person.ts @@ -34,7 +34,11 @@ export let getPerson = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let p = await client.getPeopleProfile(ctx.input.peopleId); return { diff --git a/integrations/crisp/src/tools/get-website-availability.ts b/integrations/crisp/src/tools/get-website-availability.ts index e3f9b80830..7ea16f42fb 100644 --- a/integrations/crisp/src/tools/get-website-availability.ts +++ b/integrations/crisp/src/tools/get-website-availability.ts @@ -14,21 +14,32 @@ export let getWebsiteAvailability = SlateTool.create(spec, { .input(z.object({})) .output( z.object({ + status: z + .enum(['online', 'away', 'offline']) + .optional() + .describe('Crisp availability status for the website'), available: z .boolean() .optional() - .describe('Whether the support team is currently available') + .describe('Whether the support team is currently online'), + since: z.number().optional().describe('Timestamp since the availability status changed') }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let result = await client.getWebsiteAvailability(); return { output: { - available: result?.available + status: result?.status, + available: result?.status === 'online', + since: result?.since }, - message: `Website availability: **${result?.available ? 'Online' : 'Offline'}**.` + message: `Website availability: **${result?.status ?? 'unknown'}**.` }; }) .build(); diff --git a/integrations/crisp/src/tools/index.ts b/integrations/crisp/src/tools/index.ts index 8b628630af..a6217ed438 100644 --- a/integrations/crisp/src/tools/index.ts +++ b/integrations/crisp/src/tools/index.ts @@ -5,11 +5,15 @@ export * from './get-conversation'; export * from './get-messages'; export * from './get-person'; export * from './get-website-availability'; +export * from './list-conversation-activity'; export * from './list-conversations'; export * from './list-helpdesk-articles'; +export * from './list-helpdesk-locales'; +export * from './list-inboxes'; export * from './list-operators'; export * from './list-people'; export * from './manage-helpdesk-article'; +export * from './manage-message-status'; export * from './manage-website-settings'; export * from './remove-conversation'; export * from './remove-person'; diff --git a/integrations/crisp/src/tools/list-conversation-activity.ts b/integrations/crisp/src/tools/list-conversation-activity.ts new file mode 100644 index 0000000000..b069f1f672 --- /dev/null +++ b/integrations/crisp/src/tools/list-conversation-activity.ts @@ -0,0 +1,83 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listConversationActivity = SlateTool.create(spec, { + name: 'List Conversation Activity', + key: 'list_conversation_activity', + description: `List pages viewed, custom events, or file messages for a Crisp conversation. This gives support context beyond the message transcript.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + sessionId: z.string().describe('The session ID of the conversation'), + activityType: z + .enum(['pages', 'events', 'files']) + .describe('Which conversation activity stream to list'), + pageNumber: z.number().optional().describe('Page number for pagination (starts at 1)') + }) + ) + .output( + z.object({ + activityType: z.enum(['pages', 'events', 'files']), + items: z + .array( + z.object({ + pageTitle: z.string().optional().describe('Viewed page title'), + pageUrl: z.string().optional().describe('Viewed page URL'), + pageReferrer: z.string().optional().describe('Viewed page referrer URL'), + text: z.string().optional().describe('Event text'), + data: z.record(z.string(), z.any()).optional().describe('Event data'), + color: z.string().optional().describe('Event color'), + name: z.string().optional().describe('File name'), + mimeType: z.string().optional().describe('File MIME type'), + url: z.string().optional().describe('File URL'), + fingerprint: z.number().optional().describe('File message fingerprint'), + timestamp: z.number().optional().describe('Activity timestamp') + }) + ) + .describe('Conversation activity items') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); + let results: any[]; + + if (ctx.input.activityType === 'pages') { + results = await client.listConversationPages(ctx.input.sessionId, ctx.input.pageNumber); + } else if (ctx.input.activityType === 'events') { + results = await client.listConversationEvents(ctx.input.sessionId, ctx.input.pageNumber); + } else { + results = await client.listConversationFiles(ctx.input.sessionId, ctx.input.pageNumber); + } + + let items = (results || []).map((item: any) => ({ + pageTitle: item.page_title, + pageUrl: item.page_url, + pageReferrer: item.page_referrer, + text: item.text, + data: item.data, + color: item.color, + name: item.name, + mimeType: item.type, + url: item.url, + fingerprint: item.fingerprint, + timestamp: item.timestamp + })); + + return { + output: { + activityType: ctx.input.activityType, + items + }, + message: `Found **${items.length}** ${ctx.input.activityType} entries for conversation **${ctx.input.sessionId}**.` + }; + }) + .build(); diff --git a/integrations/crisp/src/tools/list-conversations.ts b/integrations/crisp/src/tools/list-conversations.ts index a603129b9d..88cd62ce75 100644 --- a/integrations/crisp/src/tools/list-conversations.ts +++ b/integrations/crisp/src/tools/list-conversations.ts @@ -67,7 +67,11 @@ export let listConversations = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let results = await client.listConversations({ pageNumber: ctx.input.pageNumber, diff --git a/integrations/crisp/src/tools/list-helpdesk-articles.ts b/integrations/crisp/src/tools/list-helpdesk-articles.ts index 1b55f6376c..968680bb47 100644 --- a/integrations/crisp/src/tools/list-helpdesk-articles.ts +++ b/integrations/crisp/src/tools/list-helpdesk-articles.ts @@ -36,7 +36,11 @@ export let listHelpdeskArticles = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let results = await client.listHelpdeskArticles(ctx.input.localeId, ctx.input.pageNumber); let articles = (results || []).map((a: any) => ({ diff --git a/integrations/crisp/src/tools/list-helpdesk-locales.ts b/integrations/crisp/src/tools/list-helpdesk-locales.ts new file mode 100644 index 0000000000..b6e4802cb8 --- /dev/null +++ b/integrations/crisp/src/tools/list-helpdesk-locales.ts @@ -0,0 +1,51 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listHelpdeskLocales = SlateTool.create(spec, { + name: 'List Helpdesk Locales', + key: 'list_helpdesk_locales', + description: `List helpdesk knowledge base locales configured for the Crisp workspace. Use this to find locale IDs before listing or managing articles.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + locales: z + .array( + z.object({ + localeId: z.string().optional().describe('Helpdesk locale identifier'), + name: z.string().optional().describe('Locale display name'), + articles: z.number().optional().describe('Number of articles in the locale'), + createdAt: z.string().optional(), + updatedAt: z.string().optional() + }) + ) + .describe('List of helpdesk locales') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); + let results = await client.listHelpdeskLocales(); + + let locales = (results || []).map((locale: any) => ({ + localeId: locale.locale ?? locale.locale_id ?? locale.id, + name: locale.name, + articles: locale.articles, + createdAt: locale.created_at ? String(locale.created_at) : undefined, + updatedAt: locale.updated_at ? String(locale.updated_at) : undefined + })); + + return { + output: { locales }, + message: `Found **${locales.length}** helpdesk locales.` + }; + }) + .build(); diff --git a/integrations/crisp/src/tools/list-inboxes.ts b/integrations/crisp/src/tools/list-inboxes.ts new file mode 100644 index 0000000000..3257b9ae08 --- /dev/null +++ b/integrations/crisp/src/tools/list-inboxes.ts @@ -0,0 +1,63 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listInboxes = SlateTool.create(spec, { + name: 'List Inboxes', + key: 'list_inboxes', + description: `List Crisp website inboxes. Use this to find inbox IDs for conversation filtering or moving conversations between inboxes.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + pageNumber: z.number().optional().describe('Page number for pagination (starts at 1)') + }) + ) + .output( + z.object({ + inboxes: z + .array( + z.object({ + inboxId: z.string().describe('Inbox identifier'), + name: z.string().optional().describe('Inbox name'), + emoji: z.string().optional().describe('Inbox emoji'), + order: z.number().optional().describe('Inbox display order'), + operatorIds: z.array(z.string()).optional().describe('Operator IDs in the inbox'), + operator: z.string().optional().describe('Boolean operator used on conditions'), + conditions: z.array(z.record(z.string(), z.any())).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional() + }) + ) + .describe('List of inboxes') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); + let results = await client.listWebsiteInboxes(ctx.input.pageNumber); + + let inboxes = (results || []).map((inbox: any) => ({ + inboxId: inbox.inbox_id, + name: inbox.name, + emoji: inbox.emoji, + order: inbox.order, + operatorIds: inbox.operators, + operator: inbox.operator, + conditions: inbox.conditions, + createdAt: inbox.created_at ? String(inbox.created_at) : undefined, + updatedAt: inbox.updated_at ? String(inbox.updated_at) : undefined + })); + + return { + output: { inboxes }, + message: `Found **${inboxes.length}** inboxes on page ${ctx.input.pageNumber ?? 1}.` + }; + }) + .build(); diff --git a/integrations/crisp/src/tools/list-operators.ts b/integrations/crisp/src/tools/list-operators.ts index cf29c71e1a..ffc4e3db8c 100644 --- a/integrations/crisp/src/tools/list-operators.ts +++ b/integrations/crisp/src/tools/list-operators.ts @@ -17,7 +17,11 @@ export let listOperators = SlateTool.create(spec, { operators: z .array( z.object({ - userId: z.string().describe('Operator user ID'), + userId: z.string().optional().describe('Operator user ID'), + type: z + .enum(['operator', 'invite', 'sandbox']) + .optional() + .describe('Operator membership type'), email: z.string().optional().describe('Operator email'), nickname: z.string().optional().describe('Operator display name'), avatar: z.string().optional().describe('Operator avatar URL'), @@ -32,16 +36,34 @@ export let listOperators = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); - let results = await client.listOperators(); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); + let [results, availabilities] = await Promise.all([ + client.listOperators(), + client.listOperatorAvailabilities() + ]); + let availabilityByUserId = new Map( + (availabilities || []).map((availability: any) => [ + availability.user_id, + availability.type + ]) + ); let operators = (results || []).map((o: any) => ({ - userId: o.user_id, - email: o.email, - nickname: o.nickname, - avatar: o.avatar, - role: o.role, - availability: o.availability + userId: o.details?.user_id, + type: o.type, + email: o.details?.email, + nickname: + [o.details?.first_name, o.details?.last_name].filter(Boolean).join(' ') || + o.details?.email, + avatar: o.details?.avatar, + role: o.details?.role, + availability: o.details?.user_id + ? availabilityByUserId.get(o.details.user_id) + : undefined })); return { diff --git a/integrations/crisp/src/tools/list-people.ts b/integrations/crisp/src/tools/list-people.ts index 98ff751307..18f1a67aa3 100644 --- a/integrations/crisp/src/tools/list-people.ts +++ b/integrations/crisp/src/tools/list-people.ts @@ -41,7 +41,11 @@ export let listPeople = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let results = await client.listPeopleProfiles({ pageNumber: ctx.input.pageNumber, diff --git a/integrations/crisp/src/tools/manage-helpdesk-article.ts b/integrations/crisp/src/tools/manage-helpdesk-article.ts index d1238e3da7..76d698837f 100644 --- a/integrations/crisp/src/tools/manage-helpdesk-article.ts +++ b/integrations/crisp/src/tools/manage-helpdesk-article.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { crispServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageHelpdeskArticle = SlateTool.create(spec, { @@ -33,9 +34,17 @@ export let manageHelpdeskArticle = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); + + if (ctx.input.delete) { + if (!ctx.input.articleId) { + throw crispServiceError('articleId is required when delete is true.'); + } - if (ctx.input.delete && ctx.input.articleId) { await client.deleteHelpdeskArticle(ctx.input.localeId, ctx.input.articleId); return { output: { articleId: ctx.input.articleId, action: 'deleted' as const }, @@ -52,6 +61,10 @@ export let manageHelpdeskArticle = SlateTool.create(spec, { if (ctx.input.order !== undefined) article.order = ctx.input.order; if (ctx.input.category !== undefined) article.category = ctx.input.category; + if (Object.keys(article).length === 0) { + throw crispServiceError('Provide at least one article field to update.'); + } + await client.updateHelpdeskArticle(ctx.input.localeId, ctx.input.articleId, article); return { output: { articleId: ctx.input.articleId, action: 'updated' as const }, @@ -59,8 +72,12 @@ export let manageHelpdeskArticle = SlateTool.create(spec, { }; } + if (!ctx.input.title) { + throw crispServiceError('title is required to create a helpdesk article.'); + } + let result = await client.createHelpdeskArticle(ctx.input.localeId, { - title: ctx.input.title || 'Untitled', + title: ctx.input.title, description: ctx.input.description, content: ctx.input.content, featured: ctx.input.featured, diff --git a/integrations/crisp/src/tools/manage-message-status.ts b/integrations/crisp/src/tools/manage-message-status.ts new file mode 100644 index 0000000000..52c6403aa1 --- /dev/null +++ b/integrations/crisp/src/tools/manage-message-status.ts @@ -0,0 +1,91 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { crispServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let manageMessageStatus = SlateTool.create(spec, { + name: 'Manage Message Status', + key: 'manage_message_status', + description: `Mark Crisp conversation messages as read, unread, or delivered. Use message fingerprints from Get Messages or Send Message for fingerprint-specific acknowledgements.`, + instructions: [ + 'For mark_read, fingerprints are optional; omit them to mark the whole conversation read for the selected sender side.', + 'For mark_unread, Crisp marks the user side unread and does not accept fingerprints.', + 'For mark_delivered, provide one or more operator message fingerprints.' + ] +}) + .input( + z.object({ + sessionId: z.string().describe('The session ID of the conversation'), + action: z + .enum(['mark_read', 'mark_unread', 'mark_delivered']) + .describe('Message status action to perform'), + from: z + .enum(['user', 'operator']) + .optional() + .default('user') + .describe( + 'Sender side for mark_read. mark_unread uses user; mark_delivered uses operator.' + ), + origin: z + .string() + .optional() + .default('chat') + .describe('Message origin for mark_read or mark_delivered'), + fingerprints: z + .array(z.number()) + .optional() + .describe('Message fingerprints for mark_read or mark_delivered') + }) + ) + .output( + z.object({ + sessionId: z.string().describe('Session ID of the updated conversation'), + action: z.enum(['mark_read', 'mark_unread', 'mark_delivered']), + fingerprintCount: z.number().describe('Number of fingerprints supplied') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); + let fingerprints = ctx.input.fingerprints ?? []; + + if (ctx.input.action === 'mark_unread') { + if (fingerprints.length > 0) { + throw crispServiceError('fingerprints are not supported for mark_unread.'); + } + + await client.markConversationUnread(ctx.input.sessionId, 'user'); + } else if (ctx.input.action === 'mark_delivered') { + if (fingerprints.length === 0) { + throw crispServiceError('Provide at least one fingerprint for mark_delivered.'); + } + + await client.markMessagesDelivered( + ctx.input.sessionId, + 'operator', + ctx.input.origin, + fingerprints + ); + } else { + await client.markMessagesRead( + ctx.input.sessionId, + ctx.input.from, + ctx.input.origin, + fingerprints.length > 0 ? fingerprints : undefined + ); + } + + return { + output: { + sessionId: ctx.input.sessionId, + action: ctx.input.action, + fingerprintCount: fingerprints.length + }, + message: `Applied **${ctx.input.action}** to conversation **${ctx.input.sessionId}**.` + }; + }) + .build(); diff --git a/integrations/crisp/src/tools/manage-website-settings.ts b/integrations/crisp/src/tools/manage-website-settings.ts index 4cab68bf14..c5cdc89937 100644 --- a/integrations/crisp/src/tools/manage-website-settings.ts +++ b/integrations/crisp/src/tools/manage-website-settings.ts @@ -26,7 +26,11 @@ export let manageWebsiteSettings = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); if (ctx.input.settings && Object.keys(ctx.input.settings).length > 0) { await client.updateWebsiteSettings(ctx.input.settings); diff --git a/integrations/crisp/src/tools/remove-conversation.ts b/integrations/crisp/src/tools/remove-conversation.ts index de4da7ebcc..b8fd80750a 100644 --- a/integrations/crisp/src/tools/remove-conversation.ts +++ b/integrations/crisp/src/tools/remove-conversation.ts @@ -22,7 +22,11 @@ export let removeConversation = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); await client.removeConversation(ctx.input.sessionId); return { diff --git a/integrations/crisp/src/tools/remove-person.ts b/integrations/crisp/src/tools/remove-person.ts index ad665beee9..57e3ee2d73 100644 --- a/integrations/crisp/src/tools/remove-person.ts +++ b/integrations/crisp/src/tools/remove-person.ts @@ -22,7 +22,11 @@ export let removePerson = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); await client.removePeopleProfile(ctx.input.peopleId); return { diff --git a/integrations/crisp/src/tools/send-message.ts b/integrations/crisp/src/tools/send-message.ts index b879745d36..9ea1affe2a 100644 --- a/integrations/crisp/src/tools/send-message.ts +++ b/integrations/crisp/src/tools/send-message.ts @@ -1,8 +1,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { crispServiceError } from '../lib/errors'; import { spec } from '../spec'; +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + export let sendMessage = SlateTool.create(spec, { name: 'Send Message', key: 'send_message', @@ -48,11 +52,30 @@ export let sendMessage = SlateTool.create(spec, { ) .output( z.object({ - fingerprint: z.string().optional().describe('Unique fingerprint of the sent message') + fingerprint: z.number().optional().describe('Unique fingerprint of the sent message') }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); + + if (['text', 'note'].includes(ctx.input.type) && typeof ctx.input.content !== 'string') { + throw crispServiceError(`${ctx.input.type} messages require string content.`); + } + + if ( + ['file', 'animation', 'audio'].includes(ctx.input.type) && + (!isRecord(ctx.input.content) || + typeof ctx.input.content.url !== 'string' || + typeof ctx.input.content.type !== 'string') + ) { + throw crispServiceError( + `${ctx.input.type} messages require content with url and MIME type fields.` + ); + } let message: any = { type: ctx.input.type, diff --git a/integrations/crisp/src/tools/update-conversation.ts b/integrations/crisp/src/tools/update-conversation.ts index 02e531da4d..383bb8dcff 100644 --- a/integrations/crisp/src/tools/update-conversation.ts +++ b/integrations/crisp/src/tools/update-conversation.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { crispServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateConversation = SlateTool.create(spec, { @@ -39,6 +40,11 @@ export let updateConversation = SlateTool.create(spec, { .optional() .describe('Assign conversation to operator by user ID'), unassign: z.boolean().optional().describe('Unassign the conversation from any operator'), + inboxId: z.string().optional().describe('Move the conversation to this inbox ID'), + moveToMainInbox: z + .boolean() + .optional() + .describe('Move the conversation back to the main inbox'), blocked: z.boolean().optional().describe('Block or unblock the visitor') }) ) @@ -49,10 +55,22 @@ export let updateConversation = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let updated: string[] = []; let { sessionId } = ctx.input; + if (ctx.input.assignToOperatorId !== undefined && ctx.input.unassign) { + throw crispServiceError('Provide either assignToOperatorId or unassign, not both.'); + } + + if (ctx.input.inboxId !== undefined && ctx.input.moveToMainInbox) { + throw crispServiceError('Provide either inboxId or moveToMainInbox, not both.'); + } + // Update meta if any meta fields provided let meta: Record = {}; if (ctx.input.nickname !== undefined) meta.nickname = ctx.input.nickname; @@ -84,11 +102,23 @@ export let updateConversation = SlateTool.create(spec, { updated.push('routing'); } + if (ctx.input.inboxId !== undefined) { + await client.updateConversationInbox(sessionId, ctx.input.inboxId); + updated.push('inbox'); + } else if (ctx.input.moveToMainInbox) { + await client.updateConversationInbox(sessionId, null); + updated.push('inbox'); + } + if (ctx.input.blocked !== undefined) { await client.updateConversationBlock(sessionId, ctx.input.blocked); updated.push('block'); } + if (updated.length === 0) { + throw crispServiceError('Provide at least one conversation field to update.'); + } + return { output: { sessionId, diff --git a/integrations/crisp/src/tools/update-person.ts b/integrations/crisp/src/tools/update-person.ts index f375f85be8..9d619cbe3f 100644 --- a/integrations/crisp/src/tools/update-person.ts +++ b/integrations/crisp/src/tools/update-person.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { crispServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updatePerson = SlateTool.create(spec, { @@ -38,7 +39,11 @@ export let updatePerson = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let updated: string[] = []; let profile: Record = {}; @@ -69,6 +74,10 @@ export let updatePerson = SlateTool.create(spec, { updated.push('subscription'); } + if (updated.length === 0) { + throw crispServiceError('Provide at least one contact field to update.'); + } + return { output: { peopleId: ctx.input.peopleId, diff --git a/integrations/crisp/src/triggers/conversation-state-changed.ts b/integrations/crisp/src/triggers/conversation-state-changed.ts index ed2c4e9ef8..922c16eedb 100644 --- a/integrations/crisp/src/triggers/conversation-state-changed.ts +++ b/integrations/crisp/src/triggers/conversation-state-changed.ts @@ -33,7 +33,11 @@ export let conversationStateChanged = SlateTrigger.create(spec, { }, pollEvents: async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let conversations = await client.listConversations({ pageNumber: 1, diff --git a/integrations/crisp/src/triggers/new-conversation.ts b/integrations/crisp/src/triggers/new-conversation.ts index 886b1c7313..899d3607e4 100644 --- a/integrations/crisp/src/triggers/new-conversation.ts +++ b/integrations/crisp/src/triggers/new-conversation.ts @@ -34,7 +34,11 @@ export let newConversation = SlateTrigger.create(spec, { }, pollEvents: async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let conversations = await client.listConversations({ pageNumber: 1, diff --git a/integrations/crisp/src/triggers/new-message.ts b/integrations/crisp/src/triggers/new-message.ts index 9c1d1ce24e..2ea80f1371 100644 --- a/integrations/crisp/src/triggers/new-message.ts +++ b/integrations/crisp/src/triggers/new-message.ts @@ -12,7 +12,7 @@ export let newMessage = SlateTrigger.create(spec, { .input( z.object({ sessionId: z.string(), - fingerprint: z.string(), + fingerprint: z.number(), type: z.string(), from: z.string(), origin: z.string().optional(), @@ -24,7 +24,7 @@ export let newMessage = SlateTrigger.create(spec, { .output( z.object({ sessionId: z.string().describe('Session ID of the conversation'), - fingerprint: z.string().describe('Unique message fingerprint'), + fingerprint: z.number().describe('Unique message fingerprint'), type: z.string().describe('Message type (text, note, file, etc.)'), from: z.string().describe('Sender: operator or user'), origin: z.string().optional().describe('Origin channel'), @@ -39,7 +39,11 @@ export let newMessage = SlateTrigger.create(spec, { }, pollEvents: async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let conversations = await client.listConversations({ pageNumber: 1, @@ -90,7 +94,7 @@ export let newMessage = SlateTrigger.create(spec, { handleEvent: async ctx => { return { type: `message.${ctx.input.from === 'operator' ? 'sent' : 'received'}`, - id: ctx.input.fingerprint, + id: String(ctx.input.fingerprint), output: { sessionId: ctx.input.sessionId, fingerprint: ctx.input.fingerprint, diff --git a/integrations/crisp/src/triggers/people-profile-changed.ts b/integrations/crisp/src/triggers/people-profile-changed.ts index 52d85f3f63..c45e508a79 100644 --- a/integrations/crisp/src/triggers/people-profile-changed.ts +++ b/integrations/crisp/src/triggers/people-profile-changed.ts @@ -31,7 +31,11 @@ export let peopleProfileChanged = SlateTrigger.create(spec, { }, pollEvents: async ctx => { - let client = new Client({ token: ctx.auth.token, websiteId: ctx.config.websiteId }); + let client = new Client({ + token: ctx.auth.token, + websiteId: ctx.config.websiteId, + tier: ctx.auth.tier + }); let profiles = await client.listPeopleProfiles({ pageNumber: 1 }); diff --git a/integrations/crisp/vitest.config.ts b/integrations/crisp/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/crisp/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/customer-io/README.md b/integrations/customer-io/README.md index 7e83c757dc..98e13f7ab1 100644 --- a/integrations/customer-io/README.md +++ b/integrations/customer-io/README.md @@ -8,10 +8,18 @@ Track customer behavior and attributes, send targeted messages (email, SMS, push Delete a person from your Customer.io workspace. This removes the person and their data, but does not suppress them — they can be re-added later. Use the suppress action if you want to prevent the person from being re-added. +### Get Broadcast + +Retrieve a Customer.io broadcast and optionally include its actions, metrics, trigger history, trigger status, and trigger errors. + ### Get Campaign Retrieve detailed information about a specific campaign, including its actions, metrics, and configuration. Optionally fetch campaign metrics with configurable time periods. +### Get Message + +Retrieve a specific Customer.io delivery record by message ID, including recipient, parent campaign/broadcast/transactional IDs, metrics, and failure information. + ### Get Person Look up a person in your Customer.io workspace and retrieve their attributes, segments, and recent activity. You can look up a person by their ID, email, or cio_id. @@ -20,6 +28,14 @@ Look up a person in your Customer.io workspace and retrieve their attributes, se Retrieve the people who belong to a specific segment. Returns a paginated list of customer IDs in the segment. +### Get Transactional Message + +Retrieve a Customer.io transactional message definition by ID or trigger name. + +### List Broadcasts + +Retrieve API-triggered broadcasts from your Customer.io workspace, including IDs, state, active status, tags, and message action references. + ### List Campaigns Retrieve campaigns from your Customer.io workspace. Returns information about campaigns including their names, states, types, and tags. @@ -28,10 +44,18 @@ Retrieve campaigns from your Customer.io workspace. Returns information about ca Retrieve all collections in your Customer.io workspace. Collections are sets of reusable data (promotions, events, courses, etc.) that you reference in campaigns with Liquid templates. +### List Messages + +List recent Customer.io delivery records for troubleshooting sends, broadcasts, campaigns, and transactional messages. + ### List Segments Retrieve all segments in your Customer.io workspace. Segments are named groups of people who share characteristics or behaviors. Returns both data-driven and manual segments. +### List Transactional Messages + +List transactional message definitions in Customer.io so you can discover message IDs and trigger names before sending transactional messages. + ### Manage Collection Create, update, or delete a collection in your Customer.io workspace. Collections store reusable data (promotions, events, courses, etc.) that you can reference in campaigns with Liquid. You can provide data as JSON or point to a URL for CSV/JSON data. @@ -40,9 +64,13 @@ Create, update, or delete a collection in your Customer.io workspace. Collection Register or remove a device for push notifications associated with a person. Use this to add mobile devices (iOS/Android) to a person for push notification targeting, or to remove a device when a user logs out or opts out. +### Manage Export + +Create, list, inspect, or download Customer.io exports. Downloaded export file bytes are returned as Slate attachments with structured metadata only. + ### Manage Manual Segment -Add or remove people from a manual segment. Manual segments are static groups that you manage explicitly, unlike data-driven segments that update automatically based on criteria. +Create or delete a manual segment, or add/remove people from one. Manual segments are static groups that you manage explicitly, unlike data-driven segments that update automatically based on criteria. ### Merge People @@ -54,7 +82,7 @@ Search for people in your Customer.io workspace using complex filters. Find peop ### Send Transactional Message -Send a transactional message (email, push notification, or SMS) to a person. Transactional messages are for receipts, password resets, order confirmations, and other messages your audience implicitly expects to receive. You can reference a pre-built template by its transactional message ID, or provide the full message content inline. +Send a transactional message (email, push notification, SMS, in-app, or inbox message) to a person. Transactional messages are for receipts, password resets, order confirmations, and other messages your audience implicitly expects to receive. You can reference a pre-built template by its transactional message ID, or provide the full message content inline. ### Suppress or Unsuppress Person diff --git a/integrations/customer-io/docs/SPEC.md b/integrations/customer-io/docs/SPEC.md index 19e2df4b5b..b78f67b81f 100644 --- a/integrations/customer-io/docs/SPEC.md +++ b/integrations/customer-io/docs/SPEC.md @@ -18,7 +18,7 @@ Track API keys are used to send behavioral tracking activity (events and attribu ### App API — Bearer Token Authentication -App API keys are used to trigger messages and broadcasts, or programmatically retrieve data from your workspace for analysis, troubleshooting, or reporting. API requests to `https://api.customer.io/v1/api/` use App API Keys. +App API keys are used to trigger messages and broadcasts, or programmatically retrieve data from your workspace for analysis, troubleshooting, or reporting. API requests to `https://api.customer.io/v1/` use App API Keys. - Authentication: Bearer token in the `Authorization` header (`Authorization: Bearer `). - App API Keys are shown only once when created and stored as hashed values in Customer.io. Make sure to store these keys in a safe and non-public location. @@ -59,7 +59,7 @@ You can trigger broadcasts: set up a broadcast in the UI and then trigger it wit ### Campaigns and Workflows -Retrieve information about campaigns, their actions, messages, and metrics. You can get campaigns, broadcasts, and messages, and look up information about your workflows and individual messages. +Retrieve information about campaigns and broadcasts, their actions, messages, triggers, and metrics. You can get campaigns, API-triggered broadcasts, broadcast trigger status and errors, transactional message definitions, and individual delivery records. ### Collections @@ -67,7 +67,7 @@ Collections provide a way to store data in your workspace that you can use in ca ### Bulk Import and Export -You can import and export bulk data: import and export people in bulk. Exports can include attribute and event data for selected profiles or segments. +You can import and export bulk data: import and export people in bulk. Exports can include attribute and event data for selected profiles or segments. Export downloads are file-producing operations and should return file bytes as Slate attachments with structured output limited to metadata. ### Device Management diff --git a/integrations/customer-io/package.json b/integrations/customer-io/package.json index 965710a1dd..c39a052b85 100644 --- a/integrations/customer-io/package.json +++ b/integrations/customer-io/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/customer-io/src/index.ts b/integrations/customer-io/src/index.ts index e0f15f1c9b..475fd346ad 100644 --- a/integrations/customer-io/src/index.ts +++ b/integrations/customer-io/src/index.ts @@ -2,14 +2,21 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { deletePerson, + getBroadcast, getCampaign, + getMessage, getPerson, getSegmentMembership, + getTransactionalMessage, + listBroadcasts, listCampaigns, listCollections, + listMessages, listSegments, + listTransactionalMessages, manageCollection, manageDevice, + manageExport, manageManualSegment, mergePeople, searchPeople, @@ -37,8 +44,15 @@ export let provider = Slate.create({ manageManualSegment, listCampaigns, getCampaign, + listBroadcasts, + getBroadcast, triggerBroadcast, + listTransactionalMessages, + getTransactionalMessage, sendTransactionalMessage, + listMessages, + getMessage, + manageExport, manageCollection, listCollections ], diff --git a/integrations/customer-io/src/lib/client.ts b/integrations/customer-io/src/lib/client.ts index 7782c4f4de..0abdf8c57f 100644 --- a/integrations/customer-io/src/lib/client.ts +++ b/integrations/customer-io/src/lib/client.ts @@ -1,13 +1,22 @@ +import { Buffer } from 'node:buffer'; import { createAxios } from 'slates'; +import { customerIoApiError, customerIoServiceError } from './errors'; export type Region = 'us' | 'eu'; +type AxiosResponse = { + data: T; +}; + let getTrackBaseUrl = (region: Region) => region === 'eu' ? 'https://track-eu.customer.io/api/v1' : 'https://track.customer.io/api/v1'; let getAppBaseUrl = (region: Region) => region === 'eu' ? 'https://api-eu.customer.io/v1' : 'https://api.customer.io/v1'; +let withoutUndefined = (value: Record) => + Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); + export class TrackClient { private axios; @@ -22,104 +31,120 @@ export class TrackClient { }); } + private async request(operation: string, run: () => Promise>) { + try { + return (await run()).data; + } catch (error) { + throw customerIoApiError(error, operation); + } + } + async identifyPerson(identifier: string, attributes: Record) { - let response = await this.axios.put( - `/customers/${encodeURIComponent(identifier)}`, - attributes + return await this.request('identify person', () => + this.axios.put(`/customers/${encodeURIComponent(identifier)}`, attributes) ); - return response.data; } async deletePerson(identifier: string) { - let response = await this.axios.delete(`/customers/${encodeURIComponent(identifier)}`); - return response.data; + return await this.request('delete person', () => + this.axios.delete(`/customers/${encodeURIComponent(identifier)}`) + ); } async suppressPerson(identifier: string) { - let response = await this.axios.post( - `/customers/${encodeURIComponent(identifier)}/suppress` + return await this.request('suppress person', () => + this.axios.post(`/customers/${encodeURIComponent(identifier)}/suppress`) ); - return response.data; } async unsuppressPerson(identifier: string) { - let response = await this.axios.post( - `/customers/${encodeURIComponent(identifier)}/unsuppress` + return await this.request('unsuppress person', () => + this.axios.post(`/customers/${encodeURIComponent(identifier)}/unsuppress`) ); - return response.data; } async trackEvent( identifier: string, - event: { name: string; data?: Record; timestamp?: number } + event: { + name: string; + type?: 'event' | 'page' | 'screen'; + id?: string; + data?: Record; + anonymous_id?: string; + timestamp?: number; + } ) { - let response = await this.axios.post( - `/customers/${encodeURIComponent(identifier)}/events`, - event + return await this.request('track customer event', () => + this.axios.post( + `/customers/${encodeURIComponent(identifier)}/events`, + withoutUndefined(event) + ) ); - return response.data; } async trackAnonymousEvent(event: { name: string; + type?: 'event' | 'page' | 'screen'; + id?: string; data?: Record; anonymous_id?: string; timestamp?: number; }) { - let response = await this.axios.post('/events', event); - return response.data; - } - - async trackPageView( - identifier: string, - page: { name: string; data?: Record; timestamp?: number } - ) { - let response = await this.axios.post( - `/customers/${encodeURIComponent(identifier)}/events`, - { - type: 'page', - name: page.name, - data: page.data, - timestamp: page.timestamp - } + return await this.request('track anonymous event', () => + this.axios.post('/events', withoutUndefined(event)) ); - return response.data; } async addDevice( identifier: string, device: { id: string; - platform: string; + platform: 'ios' | 'android'; last_used?: number; attributes?: Record; } ) { - let response = await this.axios.put( - `/customers/${encodeURIComponent(identifier)}/devices`, - { - device - } + return await this.request('add device', () => + this.axios.put(`/customers/${encodeURIComponent(identifier)}/devices`, { + device: withoutUndefined(device) + }) ); - return response.data; } async deleteDevice(identifier: string, deviceToken: string) { - let response = await this.axios.delete( - `/customers/${encodeURIComponent(identifier)}/devices/${encodeURIComponent(deviceToken)}` + return await this.request('delete device', () => + this.axios.delete( + `/customers/${encodeURIComponent(identifier)}/devices/${encodeURIComponent(deviceToken)}` + ) + ); + } + + async addToManualSegment(segmentId: number, customerIds: string[]) { + return await this.request('add people to manual segment', () => + this.axios.post(`/segments/${segmentId}/add_customers`, { + ids: customerIds + }) + ); + } + + async removeFromManualSegment(segmentId: number, customerIds: string[]) { + return await this.request('remove people from manual segment', () => + this.axios.post(`/segments/${segmentId}/remove_customers`, { + ids: customerIds + }) ); - return response.data; } async mergeCustomers( primary: { idType: string; id: string }, secondary: { idType: string; id: string } ) { - let response = await this.axios.post('/merge_customers', { - primary: { [primary.idType]: primary.id }, - secondary: { [secondary.idType]: secondary.id } - }); - return response.data; + return await this.request('merge people', () => + this.axios.post('/merge_customers', { + primary: { [primary.idType]: primary.id }, + secondary: { [secondary.idType]: secondary.id } + }) + ); } } @@ -136,41 +161,46 @@ export class AppClient { }); } + private async request(operation: string, run: () => Promise>) { + try { + return (await run()).data; + } catch (error) { + throw customerIoApiError(error, operation); + } + } + // ---- People / Customers ---- async getCustomerByEmail(email: string) { - let response = await this.axios.get('/customers', { params: { email } }); - return response.data; + return await this.request('get customer by email', () => + this.axios.get('/customers', { params: { email } }) + ); } async getCustomerAttributes(customerId: string, idType?: string) { let params: Record = {}; if (idType) params.id_type = idType; - let response = await this.axios.get( - `/customers/${encodeURIComponent(customerId)}/attributes`, - { params } + return await this.request('get customer attributes', () => + this.axios.get(`/customers/${encodeURIComponent(customerId)}/attributes`, { + params + }) ); - return response.data; } async getCustomerSegments(customerId: string, idType?: string) { let params: Record = {}; if (idType) params.id_type = idType; - let response = await this.axios.get( - `/customers/${encodeURIComponent(customerId)}/segments`, - { params } + return await this.request('get customer segments', () => + this.axios.get(`/customers/${encodeURIComponent(customerId)}/segments`, { params }) ); - return response.data; } async getCustomerMessages(customerId: string, idType?: string) { let params: Record = {}; if (idType) params.id_type = idType; - let response = await this.axios.get( - `/customers/${encodeURIComponent(customerId)}/messages`, - { params } + return await this.request('get customer messages', () => + this.axios.get(`/customers/${encodeURIComponent(customerId)}/messages`, { params }) ); - return response.data; } async getCustomerActivities( @@ -185,132 +215,187 @@ export class AppClient { if (type) params.type = type; if (start) params.start = start; if (limit) params.limit = limit; - let response = await this.axios.get( - `/customers/${encodeURIComponent(customerId)}/activities`, - { params } + return await this.request('get customer activities', () => + this.axios.get(`/customers/${encodeURIComponent(customerId)}/activities`, { + params + }) ); - return response.data; } async searchPeople(filter: Record) { - let response = await this.axios.post('/customers', { filter }); - return response.data; + return await this.request('search people', () => + this.axios.post('/customers', { filter }) + ); } // ---- Segments ---- async listSegments() { - let response = await this.axios.get('/segments'); - return response.data; + return await this.request('list segments', () => this.axios.get('/segments')); } async getSegment(segmentId: number) { - let response = await this.axios.get(`/segments/${segmentId}`); - return response.data; + return await this.request('get segment', () => this.axios.get(`/segments/${segmentId}`)); } async getSegmentMembership(segmentId: number, start?: string, limit?: number) { let params: Record = {}; if (start) params.start = start; if (limit) params.limit = limit; - let response = await this.axios.get(`/segments/${segmentId}/membership`, { params }); - return response.data; + return await this.request('get segment membership', () => + this.axios.get(`/segments/${segmentId}/membership`, { params }) + ); } - async addToManualSegment(segmentId: number, customerIds: string[]) { - let response = await this.axios.post(`/segments/${segmentId}/add_customers`, { - ids: customerIds - }); - return response.data; + async createManualSegment(name: string, description?: string) { + return await this.request('create manual segment', () => + this.axios.post('/segments', withoutUndefined({ name, description })) + ); } - async removeFromManualSegment(segmentId: number, customerIds: string[]) { - let response = await this.axios.post(`/segments/${segmentId}/remove_customers`, { - ids: customerIds - }); - return response.data; + async deleteManualSegment(segmentId: number) { + return await this.request('delete manual segment', () => + this.axios.delete(`/segments/${segmentId}`) + ); } // ---- Campaigns ---- async listCampaigns() { - let response = await this.axios.get('/campaigns'); - return response.data; + return await this.request('list campaigns', () => this.axios.get('/campaigns')); } async getCampaign(campaignId: number) { - let response = await this.axios.get(`/campaigns/${campaignId}`); - return response.data; + return await this.request('get campaign', () => + this.axios.get(`/campaigns/${campaignId}`) + ); } async getCampaignMetrics( campaignId: number, params?: { period?: string; steps?: number; type?: string } ) { - let response = await this.axios.get(`/campaigns/${campaignId}/metrics`, { params }); - return response.data; + return await this.request('get campaign metrics', () => + this.axios.get(`/campaigns/${campaignId}/metrics`, { params }) + ); } async getCampaignActions(campaignId: number) { - let response = await this.axios.get(`/campaigns/${campaignId}/actions`); - return response.data; + return await this.request('get campaign actions', () => + this.axios.get(`/campaigns/${campaignId}/actions`) + ); } // ---- Broadcasts ---- async listBroadcasts() { - let response = await this.axios.get('/campaigns', { - params: { type: 'api_triggered_broadcast' } - }); - return response.data; + return await this.request('list broadcasts', () => this.axios.get('/broadcasts')); + } + + async getBroadcast(broadcastId: number) { + return await this.request('get broadcast', () => + this.axios.get(`/broadcasts/${broadcastId}`) + ); + } + + async getBroadcastActions(broadcastId: number) { + return await this.request('get broadcast actions', () => + this.axios.get(`/broadcasts/${broadcastId}/actions`) + ); + } + + async getBroadcastMetrics(broadcastId: number) { + return await this.request('get broadcast metrics', () => + this.axios.get(`/broadcasts/${broadcastId}/metrics`) + ); } async triggerBroadcast(broadcastId: number, payload: Record) { - let response = await this.axios.post(`/campaigns/${broadcastId}/triggers`, payload); - return response.data; + return await this.request('trigger broadcast', () => + this.axios.post(`/campaigns/${broadcastId}/triggers`, payload) + ); } async getBroadcastTriggers(broadcastId: number) { - let response = await this.axios.get(`/campaigns/${broadcastId}/triggers`); - return response.data; + return await this.request('get broadcast triggers', () => + this.axios.get(`/broadcasts/${broadcastId}/triggers`) + ); + } + + async getBroadcastTrigger(broadcastId: number, triggerId: number) { + return await this.request('get broadcast trigger', () => + this.axios.get(`/campaigns/${broadcastId}/triggers/${triggerId}`) + ); + } + + async getBroadcastTriggerErrors(broadcastId: number, triggerId: number, start?: string) { + return await this.request('get broadcast trigger errors', () => + this.axios.get(`/campaigns/${broadcastId}/triggers/${triggerId}/errors`, { + params: start ? { start } : undefined + }) + ); } // ---- Transactional Messages ---- + async listTransactionalMessages() { + return await this.request('list transactional messages', () => + this.axios.get('/transactional') + ); + } + + async getTransactionalMessage(transactionalId: number | string) { + return await this.request('get transactional message', () => + this.axios.get(`/transactional/${encodeURIComponent(String(transactionalId))}`) + ); + } + async sendTransactionalEmail(message: Record) { - let response = await this.axios.post('/send/email', message); - return response.data; + return await this.request('send transactional email', () => + this.axios.post('/send/email', message) + ); } async sendTransactionalPush(message: Record) { - let response = await this.axios.post('/send/push', message); - return response.data; + return await this.request('send transactional push', () => + this.axios.post('/send/push', message) + ); } async sendTransactionalSms(message: Record) { - let response = await this.axios.post('/send/sms', message); - return response.data; + return await this.request('send transactional sms', () => + this.axios.post('/send/sms', message) + ); + } + + async sendTransactionalInApp(message: Record) { + return await this.request('send transactional in-app message', () => + this.axios.post('/send/in_app', message) + ); + } + + async sendTransactionalInboxMessage(message: Record) { + return await this.request('send transactional inbox message', () => + this.axios.post('/send/inbox_message', message) + ); } // ---- Collections ---- async listCollections() { - let response = await this.axios.get('/api/collections'); - return response.data; + return await this.request('list collections', () => this.axios.get('/collections')); } async getCollection(collectionId: string) { - let response = await this.axios.get( - `/api/collections/${encodeURIComponent(collectionId)}` + return await this.request('get collection', () => + this.axios.get(`/collections/${encodeURIComponent(collectionId)}`) ); - return response.data; } async getCollectionContents(collectionId: string) { - let response = await this.axios.get( - `/api/collections/${encodeURIComponent(collectionId)}/content` + return await this.request('get collection contents', () => + this.axios.get(`/collections/${encodeURIComponent(collectionId)}/content`) ); - return response.data; } async createCollection(name: string, data: unknown[] | string) { @@ -320,8 +405,9 @@ export class AppClient { } else { body.data = data; } - let response = await this.axios.post('/api/collections', body); - return response.data; + return await this.request('create collection', () => + this.axios.post('/collections', body) + ); } async updateCollection( @@ -337,68 +423,105 @@ export class AppClient { body.data = update.data; } } - let response = await this.axios.put( - `/api/collections/${encodeURIComponent(collectionId)}`, - body + return await this.request('update collection', () => + this.axios.put(`/collections/${encodeURIComponent(collectionId)}`, body) ); - return response.data; } async deleteCollection(collectionId: string) { - let response = await this.axios.delete( - `/api/collections/${encodeURIComponent(collectionId)}` + return await this.request('delete collection', () => + this.axios.delete(`/collections/${encodeURIComponent(collectionId)}`) ); - return response.data; } // ---- Exports ---- - async createCustomerExport(filters?: Record) { - let response = await this.axios.post('/exports/customers', { filters }); - return response.data; + async createCustomerExport(filters: Record) { + return await this.request('create customer export', () => + this.axios.post('/exports/customers', { filters }) + ); } async getExport(exportId: number) { - let response = await this.axios.get(`/exports/${exportId}`); - return response.data; + return await this.request('get export', () => this.axios.get(`/exports/${exportId}`)); } async listExports() { - let response = await this.axios.get('/exports'); - return response.data; + return await this.request('list exports', () => this.axios.get('/exports')); + } + + async getExportDownloadUrl(exportId: number) { + return await this.request<{ url?: string }>('get export download URL', () => + this.axios.get(`/exports/${exportId}/download`) + ); + } + + async downloadExport(exportId: number) { + let result = await this.getExportDownloadUrl(exportId); + if (!result.url) { + throw customerIoServiceError('Customer.io did not return an export download URL.'); + } + + try { + let response = await fetch(result.url); + if (!response.ok) { + throw customerIoServiceError( + `Customer.io export file download failed: HTTP ${response.status} ${response.statusText}` + ); + } + + let mimeType = response.headers.get('content-type') ?? 'application/octet-stream'; + let bytes = Buffer.from(await response.arrayBuffer()); + + return { + exportId, + mimeType, + byteLength: bytes.byteLength, + contentBase64: bytes.toString('base64') + }; + } catch (error) { + throw customerIoApiError(error, 'download export file'); + } } // ---- Activities ---- async listActivities(params?: { start?: string; limit?: number; type?: string }) { - let response = await this.axios.get('/activities', { params }); - return response.data; + return await this.request('list activities', () => + this.axios.get('/activities', { params }) + ); } // ---- Newsletters ---- async listNewsletters() { - let response = await this.axios.get('/newsletters'); - return response.data; + return await this.request('list newsletters', () => this.axios.get('/newsletters')); } async getNewsletter(newsletterId: number) { - let response = await this.axios.get(`/newsletters/${newsletterId}`); - return response.data; + return await this.request('get newsletter', () => + this.axios.get(`/newsletters/${newsletterId}`) + ); } async getNewsletterMetrics( newsletterId: number, params?: { period?: string; steps?: number; type?: string } ) { - let response = await this.axios.get(`/newsletters/${newsletterId}/metrics`, { params }); - return response.data; + return await this.request('get newsletter metrics', () => + this.axios.get(`/newsletters/${newsletterId}/metrics`, { params }) + ); } // ---- Messages ---- + async listMessages() { + return await this.request('list messages', () => this.axios.get('/messages')); + } + async getMessage(messageId: string) { - let response = await this.axios.get(`/messages/${encodeURIComponent(messageId)}`); - return response.data; + return await this.request('get message', () => + this.axios.get(`/messages/${encodeURIComponent(messageId)}`) + ); } } diff --git a/integrations/customer-io/src/lib/errors.ts b/integrations/customer-io/src/lib/errors.ts new file mode 100644 index 0000000000..2b7863ba48 --- /dev/null +++ b/integrations/customer-io/src/lib/errors.ts @@ -0,0 +1,92 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.error); + addDetail(details, value.error_description); + addDetail(details, value.detail); + addDetail(details, value.code); + collectDetails(value.errors, details); +}; + +let extractCustomerIoMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + if (isRecord(response?.data)) { + collectDetails(response.data.error, details); + collectDetails(response.data.errors, details); + collectDetails(response.data.message, details); + } else { + collectDetails(response?.data, details); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let customerIoServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let customerIoApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = customerIoServiceError( + `Customer.io API ${operation} failed: ${statusLabelFor(response)}${extractCustomerIoMessage(error)}` + ); + serviceError.data.reason = 'customer_io_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/customer-io/src/tools.schema.test.ts b/integrations/customer-io/src/tools.schema.test.ts new file mode 100644 index 0000000000..6eedfe5080 --- /dev/null +++ b/integrations/customer-io/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Customer.io tool input schemas', provider.actions); diff --git a/integrations/customer-io/src/tools/get-broadcast.ts b/integrations/customer-io/src/tools/get-broadcast.ts new file mode 100644 index 0000000000..e74b8758f7 --- /dev/null +++ b/integrations/customer-io/src/tools/get-broadcast.ts @@ -0,0 +1,105 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AppClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getBroadcast = SlateTool.create(spec, { + name: 'Get Broadcast', + key: 'get_broadcast', + description: + 'Retrieve a Customer.io broadcast and optionally include its actions, metrics, trigger history, trigger status, and trigger errors.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + broadcastId: z.number().describe('The ID of the broadcast to retrieve'), + includeActions: z.boolean().optional().describe('Also fetch broadcast actions'), + includeMetrics: z.boolean().optional().describe('Also fetch broadcast metrics'), + includeTriggers: z.boolean().optional().describe('Also fetch broadcast trigger history'), + triggerId: z.number().optional().describe('Specific trigger ID to fetch status for'), + includeTriggerErrors: z.boolean().optional().describe('Also fetch errors for triggerId'), + triggerErrorsCursor: z + .string() + .optional() + .describe('Pagination cursor for trigger errors') + }) + ) + .output( + z.object({ + broadcast: z.record(z.string(), z.unknown()).describe('The broadcast object'), + actions: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe('Broadcast actions'), + metrics: z.record(z.string(), z.unknown()).optional().describe('Broadcast metrics'), + triggers: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe('Broadcast trigger history'), + trigger: z + .record(z.string(), z.unknown()) + .optional() + .describe('Specific trigger status'), + triggerErrors: z.array(z.string()).optional().describe('Trigger errors'), + nextTriggerErrors: z.string().optional().describe('Next trigger-error cursor') + }) + ) + .handleInvocation(async ctx => { + let appClient = new AppClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let broadcastResult = await appClient.getBroadcast(ctx.input.broadcastId); + let broadcast = broadcastResult?.broadcast ?? broadcastResult; + let actions: Record[] | undefined; + let metrics: Record | undefined; + let triggers: Record[] | undefined; + let trigger: Record | undefined; + let triggerErrors: string[] | undefined; + let nextTriggerErrors: string | undefined; + + if (ctx.input.includeActions) { + let actionsResult = await appClient.getBroadcastActions(ctx.input.broadcastId); + actions = actionsResult?.actions ?? []; + } + if (ctx.input.includeMetrics) { + metrics = await appClient.getBroadcastMetrics(ctx.input.broadcastId); + } + if (ctx.input.includeTriggers) { + let triggersResult = await appClient.getBroadcastTriggers(ctx.input.broadcastId); + triggers = triggersResult?.triggers ?? []; + } + if (ctx.input.triggerId) { + trigger = await appClient.getBroadcastTrigger( + ctx.input.broadcastId, + ctx.input.triggerId + ); + if (ctx.input.includeTriggerErrors) { + let errorsResult = await appClient.getBroadcastTriggerErrors( + ctx.input.broadcastId, + ctx.input.triggerId, + ctx.input.triggerErrorsCursor + ); + triggerErrors = errorsResult?.errors ?? []; + nextTriggerErrors = errorsResult?.next; + } + } + + return { + output: { + broadcast, + actions, + metrics, + triggers, + trigger, + triggerErrors, + nextTriggerErrors + }, + message: `Retrieved broadcast **${broadcast?.name ?? ctx.input.broadcastId}**.` + }; + }) + .build(); diff --git a/integrations/customer-io/src/tools/get-message.ts b/integrations/customer-io/src/tools/get-message.ts new file mode 100644 index 0000000000..b99273b019 --- /dev/null +++ b/integrations/customer-io/src/tools/get-message.ts @@ -0,0 +1,40 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AppClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getMessage = SlateTool.create(spec, { + name: 'Get Message', + key: 'get_message', + description: + 'Retrieve a specific Customer.io delivery record by message ID, including recipient, parent campaign/broadcast/transactional IDs, metrics, and failure information.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + messageId: z.string().describe('The Customer.io delivery/message ID') + }) + ) + .output( + z.object({ + message: z.record(z.string(), z.unknown()).describe('The message delivery record') + }) + ) + .handleInvocation(async ctx => { + let appClient = new AppClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await appClient.getMessage(ctx.input.messageId); + let message = result?.message ?? result; + + return { + output: { message }, + message: `Retrieved message **${ctx.input.messageId}**.` + }; + }) + .build(); diff --git a/integrations/customer-io/src/tools/get-transactional-message.ts b/integrations/customer-io/src/tools/get-transactional-message.ts new file mode 100644 index 0000000000..3e5a621fd0 --- /dev/null +++ b/integrations/customer-io/src/tools/get-transactional-message.ts @@ -0,0 +1,44 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AppClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getTransactionalMessage = SlateTool.create(spec, { + name: 'Get Transactional Message', + key: 'get_transactional_message', + description: + 'Retrieve a Customer.io transactional message definition by ID or trigger name.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + transactionalMessageId: z + .union([z.string(), z.number()]) + .describe('The transactional message ID or trigger name') + }) + ) + .output( + z.object({ + message: z + .record(z.string(), z.unknown()) + .describe('The transactional message definition') + }) + ) + .handleInvocation(async ctx => { + let appClient = new AppClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await appClient.getTransactionalMessage(ctx.input.transactionalMessageId); + let message = result?.message ?? result; + + return { + output: { message }, + message: `Retrieved transactional message **${message?.name ?? ctx.input.transactionalMessageId}**.` + }; + }) + .build(); diff --git a/integrations/customer-io/src/tools/index.ts b/integrations/customer-io/src/tools/index.ts index ef195065d3..2c430268fb 100644 --- a/integrations/customer-io/src/tools/index.ts +++ b/integrations/customer-io/src/tools/index.ts @@ -1,12 +1,19 @@ export * from './delete-person'; +export * from './get-broadcast'; export * from './get-campaign'; +export * from './get-message'; export * from './get-person'; export * from './get-segment-membership'; +export * from './get-transactional-message'; +export * from './list-broadcasts'; export * from './list-campaigns'; export * from './list-collections'; +export * from './list-messages'; export * from './list-segments'; +export * from './list-transactional-messages'; export * from './manage-collection'; export * from './manage-device'; +export * from './manage-export'; export * from './manage-manual-segment'; export * from './merge-people'; export * from './search-people'; diff --git a/integrations/customer-io/src/tools/list-broadcasts.ts b/integrations/customer-io/src/tools/list-broadcasts.ts new file mode 100644 index 0000000000..d11b6b538a --- /dev/null +++ b/integrations/customer-io/src/tools/list-broadcasts.ts @@ -0,0 +1,58 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AppClient } from '../lib/client'; +import { spec } from '../spec'; + +export let listBroadcasts = SlateTool.create(spec, { + name: 'List Broadcasts', + key: 'list_broadcasts', + description: + 'Retrieve API-triggered broadcasts from your Customer.io workspace, including IDs, state, active status, tags, and message action references.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + broadcasts: z + .array( + z.object({ + broadcastId: z.number().describe('The broadcast ID'), + name: z.string().optional().describe('The broadcast name'), + type: z.string().optional().describe('The broadcast type'), + state: z.string().optional().describe('The broadcast state'), + active: z.boolean().optional().describe('Whether the broadcast is active'), + createdAt: z.number().optional().describe('Unix timestamp when created'), + updatedAt: z.number().optional().describe('Unix timestamp when updated'), + tags: z.array(z.string()).optional().describe('Tags applied to the broadcast') + }) + ) + .describe('Array of broadcasts') + }) + ) + .handleInvocation(async ctx => { + let appClient = new AppClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await appClient.listBroadcasts(); + let broadcasts = (result?.broadcasts ?? []).map((broadcast: any) => ({ + broadcastId: broadcast.id, + name: broadcast.name, + type: broadcast.type, + state: broadcast.state, + active: broadcast.active, + createdAt: broadcast.created, + updatedAt: broadcast.updated, + tags: broadcast.tags + })); + + return { + output: { broadcasts }, + message: `Found **${broadcasts.length}** broadcasts.` + }; + }) + .build(); diff --git a/integrations/customer-io/src/tools/list-messages.ts b/integrations/customer-io/src/tools/list-messages.ts new file mode 100644 index 0000000000..31584280af --- /dev/null +++ b/integrations/customer-io/src/tools/list-messages.ts @@ -0,0 +1,38 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AppClient } from '../lib/client'; +import { spec } from '../spec'; + +export let listMessages = SlateTool.create(spec, { + name: 'List Messages', + key: 'list_messages', + description: + 'List recent Customer.io delivery records for troubleshooting sends, broadcasts, campaigns, and transactional messages.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + messages: z + .array(z.record(z.string(), z.unknown())) + .describe('Customer.io message delivery records') + }) + ) + .handleInvocation(async ctx => { + let appClient = new AppClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await appClient.listMessages(); + let messages = result?.messages ?? []; + + return { + output: { messages }, + message: `Found **${messages.length}** messages.` + }; + }) + .build(); diff --git a/integrations/customer-io/src/tools/list-transactional-messages.ts b/integrations/customer-io/src/tools/list-transactional-messages.ts new file mode 100644 index 0000000000..668c2154c1 --- /dev/null +++ b/integrations/customer-io/src/tools/list-transactional-messages.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { AppClient } from '../lib/client'; +import { spec } from '../spec'; + +export let listTransactionalMessages = SlateTool.create(spec, { + name: 'List Transactional Messages', + key: 'list_transactional_messages', + description: + 'List transactional message definitions in Customer.io so you can discover message IDs and trigger names before sending transactional messages.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + messages: z + .array( + z.object({ + transactionalMessageId: z.number().describe('The transactional message ID'), + name: z.string().optional().describe('The transactional message name'), + description: z.string().optional(), + sendToUnsubscribed: z.boolean().optional(), + queueDrafts: z.boolean().optional(), + createdAt: z.number().optional(), + updatedAt: z.number().optional() + }) + ) + .describe('Transactional message definitions') + }) + ) + .handleInvocation(async ctx => { + let appClient = new AppClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let result = await appClient.listTransactionalMessages(); + let messages = (result?.messages ?? []).map((message: any) => ({ + transactionalMessageId: message.id, + name: message.name, + description: message.description, + sendToUnsubscribed: message.send_to_unsubscribed, + queueDrafts: message.queue_drafts, + createdAt: message.created_at, + updatedAt: message.updated_at + })); + + return { + output: { messages }, + message: `Found **${messages.length}** transactional messages.` + }; + }) + .build(); diff --git a/integrations/customer-io/src/tools/manage-collection.ts b/integrations/customer-io/src/tools/manage-collection.ts index c2179798f7..85197ab288 100644 --- a/integrations/customer-io/src/tools/manage-collection.ts +++ b/integrations/customer-io/src/tools/manage-collection.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AppClient } from '../lib/client'; +import { customerIoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCollection = SlateTool.create(spec, { @@ -56,15 +57,43 @@ export let manageCollection = SlateTool.create(spec, { let result: any; if (ctx.input.action === 'create') { - let data: unknown[] | string = ctx.input.jsonData ?? ctx.input.dataUrl ?? []; - result = await appClient.createCollection(ctx.input.name!, data); + if (!ctx.input.name) { + throw customerIoServiceError('name is required to create a collection.'); + } + if (ctx.input.jsonData && ctx.input.dataUrl) { + throw customerIoServiceError('Provide only one of jsonData or dataUrl.'); + } + if (!ctx.input.jsonData && !ctx.input.dataUrl) { + throw customerIoServiceError( + 'jsonData or dataUrl is required to create a collection.' + ); + } + + let data = ctx.input.jsonData ?? ctx.input.dataUrl!; + result = await appClient.createCollection(ctx.input.name, data); } else if (ctx.input.action === 'update') { - result = await appClient.updateCollection(ctx.input.collectionId!, { + if (!ctx.input.collectionId) { + throw customerIoServiceError('collectionId is required to update a collection.'); + } + if (ctx.input.jsonData && ctx.input.dataUrl) { + throw customerIoServiceError('Provide only one of jsonData or dataUrl.'); + } + if (!ctx.input.name && !ctx.input.jsonData && !ctx.input.dataUrl) { + throw customerIoServiceError( + 'Provide name, jsonData, or dataUrl to update a collection.' + ); + } + + result = await appClient.updateCollection(ctx.input.collectionId, { name: ctx.input.name, data: ctx.input.jsonData ?? ctx.input.dataUrl }); } else { - await appClient.deleteCollection(ctx.input.collectionId!); + if (!ctx.input.collectionId) { + throw customerIoServiceError('collectionId is required to delete a collection.'); + } + + await appClient.deleteCollection(ctx.input.collectionId); return { output: { collectionId: ctx.input.collectionId, success: true }, message: `Deleted collection **${ctx.input.collectionId}**.` diff --git a/integrations/customer-io/src/tools/manage-device.ts b/integrations/customer-io/src/tools/manage-device.ts index 8b32ffa953..5149d26bb1 100644 --- a/integrations/customer-io/src/tools/manage-device.ts +++ b/integrations/customer-io/src/tools/manage-device.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { TrackClient } from '../lib/client'; +import { customerIoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageDevice = SlateTool.create(spec, { @@ -46,9 +47,13 @@ export let manageDevice = SlateTool.create(spec, { }); if (ctx.input.action === 'add') { + if (!ctx.input.platform) { + throw customerIoServiceError('platform is required when adding a device.'); + } + await trackClient.addDevice(ctx.input.personIdentifier, { id: ctx.input.deviceToken, - platform: ctx.input.platform ?? 'ios', + platform: ctx.input.platform, last_used: ctx.input.lastUsed, attributes: ctx.input.deviceAttributes }); diff --git a/integrations/customer-io/src/tools/manage-export.ts b/integrations/customer-io/src/tools/manage-export.ts new file mode 100644 index 0000000000..4fa4f2ebb7 --- /dev/null +++ b/integrations/customer-io/src/tools/manage-export.ts @@ -0,0 +1,115 @@ +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { AppClient } from '../lib/client'; +import { customerIoServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let manageExport = SlateTool.create(spec, { + name: 'Manage Export', + key: 'manage_export', + description: + 'Create, list, inspect, or download Customer.io exports. Downloaded export file bytes are returned as Slate attachments with structured metadata only.', + instructions: [ + 'For "create_customer_export", provide a Customer.io filters object.', + 'For "get" and "download", provide exportId.', + 'Only exports with status "done" can be downloaded.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + action: z + .enum(['create_customer_export', 'list', 'get', 'download']) + .describe('Export operation to perform'), + exportId: z.number().optional().describe('Export ID. Required for get and download.'), + filters: z + .record(z.string(), z.unknown()) + .optional() + .describe('Customer.io audience filter object. Required for create_customer_export.') + }) + ) + .output( + z.object({ + export: z.record(z.string(), z.unknown()).optional().describe('Export metadata'), + exports: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe('Export metadata records'), + exportId: z.number().optional().describe('The affected export ID'), + status: z.string().optional().describe('Export status'), + mimeType: z.string().optional().describe('Downloaded attachment MIME type'), + byteLength: z.number().optional().describe('Downloaded byte length'), + attachmentCount: z.number().optional().describe('Number of attachments returned') + }) + ) + .handleInvocation(async ctx => { + let appClient = new AppClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + if (ctx.input.action === 'create_customer_export') { + if (!ctx.input.filters) { + throw customerIoServiceError( + 'filters is required to create a Customer.io customer export.' + ); + } + + let result = await appClient.createCustomerExport(ctx.input.filters); + let exportRecord = result?.export ?? result; + + return { + output: { + export: exportRecord, + exportId: exportRecord?.id, + status: exportRecord?.status + }, + message: `Created Customer.io export **${exportRecord?.id ?? 'unknown'}**.` + }; + } + + if (ctx.input.action === 'list') { + let result = await appClient.listExports(); + let exports = result?.exports ?? []; + + return { + output: { exports }, + message: `Found **${exports.length}** exports.` + }; + } + + if (!ctx.input.exportId) { + throw customerIoServiceError(`exportId is required for "${ctx.input.action}".`); + } + + if (ctx.input.action === 'get') { + let result = await appClient.getExport(ctx.input.exportId); + let exportRecord = result?.export ?? result; + + return { + output: { + export: exportRecord, + exportId: exportRecord?.id ?? ctx.input.exportId, + status: exportRecord?.status + }, + message: `Retrieved export **${ctx.input.exportId}**.` + }; + } + + let downloaded = await appClient.downloadExport(ctx.input.exportId); + + return { + output: { + exportId: downloaded.exportId, + mimeType: downloaded.mimeType, + byteLength: downloaded.byteLength, + attachmentCount: 1 + }, + attachments: [createBase64Attachment(downloaded.contentBase64, downloaded.mimeType)], + message: `Downloaded export **${ctx.input.exportId}** as an attachment.` + }; + }) + .build(); diff --git a/integrations/customer-io/src/tools/manage-manual-segment.ts b/integrations/customer-io/src/tools/manage-manual-segment.ts index dbe4d807dc..24797b6583 100644 --- a/integrations/customer-io/src/tools/manage-manual-segment.ts +++ b/integrations/customer-io/src/tools/manage-manual-segment.ts @@ -1,15 +1,18 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { AppClient } from '../lib/client'; +import { AppClient, TrackClient } from '../lib/client'; +import { customerIoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageManualSegment = SlateTool.create(spec, { name: 'Manage Manual Segment', key: 'manage_manual_segment', - description: `Add or remove people from a manual segment. Manual segments are static groups that you manage explicitly, unlike data-driven segments that update automatically based on criteria.`, + description: `Create or delete a manual segment, or add/remove people from one. Manual segments are static groups that you manage explicitly, unlike data-driven segments that update automatically based on criteria.`, instructions: [ - 'This only works with manual segments, not data-driven segments.', - 'Provide customer IDs (not emails) to add or remove.' + 'Use "create" to create an empty manual segment.', + 'Use "delete" to remove a manual segment.', + 'Use "add" or "remove" with customer IDs (not emails) to manage membership.', + 'Add/remove only works with manual segments, not data-driven segments.' ], tags: { destructive: false, @@ -18,15 +21,24 @@ export let manageManualSegment = SlateTool.create(spec, { }) .input( z.object({ - segmentId: z.number().describe('The ID of the manual segment'), action: z - .enum(['add', 'remove']) - .describe('Whether to add or remove people from the segment'), - customerIds: z.array(z.string()).describe('Array of customer IDs to add or remove') + .enum(['create', 'delete', 'add', 'remove']) + .describe('The manual-segment operation to perform'), + segmentId: z + .number() + .optional() + .describe('The ID of the manual segment. Required for delete, add, and remove.'), + name: z.string().optional().describe('Segment name. Required for create.'), + description: z.string().optional().describe('Optional description for create.'), + customerIds: z + .array(z.string()) + .optional() + .describe('Array of customer IDs to add or remove') }) ) .output( z.object({ + segmentId: z.number().optional().describe('The created or affected segment ID'), success: z.boolean().describe('Whether the operation succeeded') }) ) @@ -35,15 +47,52 @@ export let manageManualSegment = SlateTool.create(spec, { token: ctx.auth.token, region: ctx.config.region }); + let trackClient = new TrackClient({ + siteId: ctx.auth.siteId, + trackApiKey: ctx.auth.trackApiKey, + region: ctx.config.region + }); + + if (ctx.input.action === 'create') { + if (!ctx.input.name) { + throw customerIoServiceError('name is required to create a manual segment.'); + } + let result = await appClient.createManualSegment(ctx.input.name, ctx.input.description); + let segmentId = result?.segment?.id ?? result?.id; + + return { + output: { segmentId, success: true }, + message: `Created manual segment **${ctx.input.name}**.` + }; + } + + if (!ctx.input.segmentId) { + throw customerIoServiceError(`segmentId is required for "${ctx.input.action}".`); + } + + if (ctx.input.action === 'delete') { + await appClient.deleteManualSegment(ctx.input.segmentId); + + return { + output: { segmentId: ctx.input.segmentId, success: true }, + message: `Deleted manual segment **${ctx.input.segmentId}**.` + }; + } + + if (!ctx.input.customerIds || ctx.input.customerIds.length === 0) { + throw customerIoServiceError( + `customerIds must include at least one customer ID for "${ctx.input.action}".` + ); + } if (ctx.input.action === 'add') { - await appClient.addToManualSegment(ctx.input.segmentId, ctx.input.customerIds); + await trackClient.addToManualSegment(ctx.input.segmentId, ctx.input.customerIds); } else { - await appClient.removeFromManualSegment(ctx.input.segmentId, ctx.input.customerIds); + await trackClient.removeFromManualSegment(ctx.input.segmentId, ctx.input.customerIds); } return { - output: { success: true }, + output: { segmentId: ctx.input.segmentId, success: true }, message: `${ctx.input.action === 'add' ? 'Added' : 'Removed'} **${ctx.input.customerIds.length}** people ${ctx.input.action === 'add' ? 'to' : 'from'} segment **${ctx.input.segmentId}**.` }; }) diff --git a/integrations/customer-io/src/tools/send-transactional-message.ts b/integrations/customer-io/src/tools/send-transactional-message.ts index 2516ea4728..cbbec1d35c 100644 --- a/integrations/customer-io/src/tools/send-transactional-message.ts +++ b/integrations/customer-io/src/tools/send-transactional-message.ts @@ -1,17 +1,32 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AppClient } from '../lib/client'; +import { customerIoServiceError } from '../lib/errors'; import { spec } from '../spec'; +let validateIdentifiers = (identifiers: Record | undefined) => { + if (!identifiers) { + return; + } + + let provided = ['id', 'email', 'cio_id'].filter(key => identifiers[key]); + if (provided.length !== 1) { + throw customerIoServiceError( + 'identifiers must contain exactly one of id, email, or cio_id.' + ); + } +}; + export let sendTransactionalMessage = SlateTool.create(spec, { name: 'Send Transactional Message', key: 'send_transactional_message', - description: `Send a transactional message (email, push notification, or SMS) to a person. Transactional messages are for receipts, password resets, order confirmations, and other messages your audience implicitly expects to receive. + description: `Send a transactional message (email, push notification, SMS, in-app, or inbox message) to a person. Transactional messages are for receipts, password resets, order confirmations, and other messages your audience implicitly expects to receive. You can reference a pre-built template by its transactional message ID, or provide the full message content inline.`, instructions: [ 'For email, provide the "to" address and either a transactionalMessageId (to use a template) or subject/body.', - 'For push, provide the person identifier and transactionalMessageId.', - 'For SMS, provide the "to" phone number and transactionalMessageId.', + 'For push, provide identifiers and transactionalMessageId.', + 'For SMS, provide the "to" phone number, identifiers, and transactionalMessageId.', + 'For in-app and inbox messages, provide identifiers and transactionalMessageId.', 'Message data (key-value pairs) can be passed to populate Liquid template variables.' ], constraints: [ @@ -26,7 +41,7 @@ You can reference a pre-built template by its transactional message ID, or provi .input( z.object({ channel: z - .enum(['email', 'push', 'sms']) + .enum(['email', 'push', 'sms', 'in_app', 'inbox_message']) .describe('The channel to send the transactional message through'), transactionalMessageId: z .union([z.string(), z.number()]) @@ -41,26 +56,35 @@ You can reference a pre-built template by its transactional message ID, or provi .record(z.string(), z.string()) .optional() .describe( - 'Person identifiers (e.g. { "id": "user123" } or { "email": "test@example.com" })' + 'Person identifiers with exactly one of id, email, or cio_id (e.g. { "id": "user123" })' ), messageData: z .record(z.string(), z.unknown()) .optional() .describe('Key-value data to populate Liquid merge fields in the template'), + sendAt: z + .number() + .optional() + .describe('Unix timestamp when Customer.io should send the message'), subject: z .string() .optional() .describe('Email subject (overrides template subject, email only)'), - body: z - .string() - .optional() - .describe('Email HTML body (overrides template body, email only)'), + body: z.string().optional().describe('Email HTML body or channel body override'), from: z .string() .optional() - .describe('Sender email address (overrides template sender, email only)'), + .describe('Sender email address for email or verified phone number for SMS'), replyTo: z.string().optional().describe('Reply-to email address (email only)'), bcc: z.string().optional().describe('BCC email address (email only)'), + title: z.string().optional().describe('Push notification title override (push only)'), + message: z + .string() + .optional() + .describe('Push notification message override (push only)'), + imageUrl: z.string().optional().describe('Push notification image URL (push only)'), + link: z.string().optional().describe('Deep link opened from a push notification'), + language: z.string().optional().describe('Override the recipient language'), disableMessageRetention: z .boolean() .optional() @@ -68,7 +92,17 @@ You can reference a pre-built template by its transactional message ID, or provi sendToUnsubscribed: z .boolean() .optional() - .describe('If true, sends even if the recipient has unsubscribed') + .describe('If true, sends even if the recipient has unsubscribed'), + queueDraft: z + .boolean() + .optional() + .describe('If true, queues the message as a draft instead of sending immediately'), + autoCreate: z + .boolean() + .optional() + .describe( + 'If true, Customer.io can create an empty transactional record for a string trigger name' + ) }) ) .output( @@ -86,12 +120,38 @@ You can reference a pre-built template by its transactional message ID, or provi region: ctx.config.region }); + validateIdentifiers(ctx.input.identifiers); + + if (ctx.input.channel === 'email' && !ctx.input.to) { + throw customerIoServiceError('to is required for transactional email messages.'); + } + if (ctx.input.channel === 'sms' && !ctx.input.to) { + throw customerIoServiceError('to is required for transactional SMS messages.'); + } + if ( + ['push', 'sms', 'in_app', 'inbox_message'].includes(ctx.input.channel) && + !ctx.input.identifiers + ) { + throw customerIoServiceError( + `identifiers is required for transactional ${ctx.input.channel} messages.` + ); + } + if (ctx.input.autoCreate && typeof ctx.input.transactionalMessageId !== 'string') { + throw customerIoServiceError( + 'transactionalMessageId must be a string when autoCreate is true.' + ); + } + let message: Record = { transactional_message_id: ctx.input.transactionalMessageId, message_data: ctx.input.messageData, identifiers: ctx.input.identifiers, + send_at: ctx.input.sendAt, disable_message_retention: ctx.input.disableMessageRetention, - send_to_unsubscribed: ctx.input.sendToUnsubscribed + send_to_unsubscribed: ctx.input.sendToUnsubscribed, + queue_draft: ctx.input.queueDraft, + auto_create: ctx.input.autoCreate, + language: ctx.input.language }; if (ctx.input.to) message.to = ctx.input.to; @@ -100,14 +160,22 @@ You can reference a pre-built template by its transactional message ID, or provi if (ctx.input.from) message.from = ctx.input.from; if (ctx.input.replyTo) message.reply_to = ctx.input.replyTo; if (ctx.input.bcc) message.bcc = ctx.input.bcc; + if (ctx.input.title) message.title = ctx.input.title; + if (ctx.input.message) message.message = ctx.input.message; + if (ctx.input.imageUrl) message.image_url = ctx.input.imageUrl; + if (ctx.input.link) message.link = ctx.input.link; let result: any; if (ctx.input.channel === 'email') { result = await appClient.sendTransactionalEmail(message); } else if (ctx.input.channel === 'push') { result = await appClient.sendTransactionalPush(message); - } else { + } else if (ctx.input.channel === 'sms') { result = await appClient.sendTransactionalSms(message); + } else if (ctx.input.channel === 'in_app') { + result = await appClient.sendTransactionalInApp(message); + } else { + result = await appClient.sendTransactionalInboxMessage(message); } return { diff --git a/integrations/customer-io/src/tools/track-event.ts b/integrations/customer-io/src/tools/track-event.ts index e45c8e1c62..46d55886ab 100644 --- a/integrations/customer-io/src/tools/track-event.ts +++ b/integrations/customer-io/src/tools/track-event.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { TrackClient } from '../lib/client'; +import { customerIoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let trackEvent = SlateTool.create(spec, { @@ -30,6 +31,10 @@ Supports tracking events attributed to a specific person, anonymous events (asso eventName: z .string() .describe('The name of the event (e.g. "purchase", "button_click", "/home")'), + eventId: z + .string() + .optional() + .describe('Optional ULID used by Customer.io to deduplicate events'), eventType: z .enum(['event', 'page', 'screen']) .default('event') @@ -61,21 +66,24 @@ Supports tracking events attributed to a specific person, anonymous events (asso }); if (ctx.input.personIdentifier) { - if (ctx.input.eventType === 'page') { - await trackClient.trackPageView(ctx.input.personIdentifier, { - name: ctx.input.eventName, - data: ctx.input.properties, - timestamp: ctx.input.timestamp - }); - } else { - await trackClient.trackEvent(ctx.input.personIdentifier, { - name: ctx.input.eventName, - data: ctx.input.properties, - timestamp: ctx.input.timestamp - }); + if (ctx.input.eventType === 'screen' && !ctx.input.anonymousId) { + throw customerIoServiceError( + 'anonymousId is required for screen events attributed to a person.' + ); } + + await trackClient.trackEvent(ctx.input.personIdentifier, { + id: ctx.input.eventId, + type: ctx.input.eventType, + name: ctx.input.eventName, + data: ctx.input.properties, + anonymous_id: ctx.input.anonymousId, + timestamp: ctx.input.timestamp + }); } else { await trackClient.trackAnonymousEvent({ + id: ctx.input.eventId, + type: ctx.input.eventType, name: ctx.input.eventName, data: ctx.input.properties, anonymous_id: ctx.input.anonymousId, diff --git a/integrations/customer-io/src/tools/trigger-broadcast.ts b/integrations/customer-io/src/tools/trigger-broadcast.ts index 521f27043a..df9a4cabf3 100644 --- a/integrations/customer-io/src/tools/trigger-broadcast.ts +++ b/integrations/customer-io/src/tools/trigger-broadcast.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AppClient } from '../lib/client'; +import { customerIoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let triggerBroadcast = SlateTool.create(spec, { @@ -10,7 +11,8 @@ export let triggerBroadcast = SlateTool.create(spec, { You can target a segment, a list of customer IDs, or a list of email addresses.`, instructions: [ 'The broadcast must be set up as an API-triggered broadcast in the Customer.io UI first.', - 'You can provide data that populates Liquid merge fields in the broadcast message.' + 'You can provide data that populates Liquid merge fields in the broadcast message.', + 'Provide at most one audience selector: segmentIds, recipientsFilter, customerIds, emails, or recipients. Omit all audience selectors to use the broadcast default audience.' ], constraints: [ 'Rate limit: 1 request per 10 seconds.', @@ -28,17 +30,37 @@ You can target a segment, a list of customer IDs, or a list of email addresses.` .record(z.string(), z.unknown()) .optional() .describe('Key-value data to populate Liquid merge fields in the broadcast message'), - segmentIds: z.array(z.number()).optional().describe('Target specific segment IDs'), + segmentIds: z + .array(z.number()) + .optional() + .describe('Target people matching one or more segment IDs'), + recipientsFilter: z + .record(z.string(), z.unknown()) + .optional() + .describe('Customer.io audience filter object for custom broadcast recipients'), customerIds: z.array(z.string()).optional().describe('Target specific customer IDs'), emails: z.array(z.string()).optional().describe('Target specific email addresses'), recipients: z .record(z.string(), z.record(z.string(), z.unknown())) .optional() - .describe('Map of customer IDs to per-recipient data') + .describe('Map of customer IDs to per-recipient Liquid data'), + emailAddDuplicates: z + .boolean() + .optional() + .describe('If true, allow email addresses associated with multiple profiles'), + emailIgnoreMissing: z + .boolean() + .optional() + .describe('If true, skip recipients missing email addresses'), + idIgnoreMissing: z + .boolean() + .optional() + .describe('If true, skip missing customer IDs instead of failing the broadcast') }) ) .output( z.object({ + triggerId: z.number().optional().describe('The broadcast trigger ID'), success: z.boolean().describe('Whether the broadcast was triggered successfully') }) ) @@ -50,8 +72,37 @@ You can target a segment, a list of customer IDs, or a list of email addresses.` let payload: Record = {}; if (ctx.input.broadcastData) payload.data = ctx.input.broadcastData; - if (ctx.input.segmentIds) payload.ids = ctx.input.segmentIds; - if (ctx.input.customerIds) payload.recipients = { ids: ctx.input.customerIds }; + if (ctx.input.emailAddDuplicates !== undefined) + payload.email_add_duplicates = ctx.input.emailAddDuplicates; + if (ctx.input.emailIgnoreMissing !== undefined) + payload.email_ignore_missing = ctx.input.emailIgnoreMissing; + if (ctx.input.idIgnoreMissing !== undefined) + payload.id_ignore_missing = ctx.input.idIgnoreMissing; + + let audienceSelectors = [ + ctx.input.segmentIds?.length, + ctx.input.recipientsFilter ? 1 : 0, + ctx.input.customerIds?.length, + ctx.input.emails?.length, + ctx.input.recipients ? 1 : 0 + ].filter(Boolean).length; + + if (audienceSelectors > 1) { + throw customerIoServiceError( + 'Provide at most one broadcast audience selector: segmentIds, recipientsFilter, customerIds, emails, or recipients.' + ); + } + + if (ctx.input.segmentIds?.length) { + payload.recipients = + ctx.input.segmentIds.length === 1 + ? { segment: { id: ctx.input.segmentIds[0] } } + : { + or: ctx.input.segmentIds.map(segmentId => ({ segment: { id: segmentId } })) + }; + } + if (ctx.input.recipientsFilter) payload.recipients = ctx.input.recipientsFilter; + if (ctx.input.customerIds) payload.ids = ctx.input.customerIds; if (ctx.input.emails) payload.emails = ctx.input.emails; if (ctx.input.recipients) payload.per_user_data = Object.entries(ctx.input.recipients).map(([id, data]) => ({ @@ -59,10 +110,10 @@ You can target a segment, a list of customer IDs, or a list of email addresses.` data })); - await appClient.triggerBroadcast(ctx.input.broadcastId, payload); + let result = await appClient.triggerBroadcast(ctx.input.broadcastId, payload); return { - output: { success: true }, + output: { triggerId: result?.id, success: true }, message: `Triggered broadcast **${ctx.input.broadcastId}** successfully.` }; }) diff --git a/integrations/customer-io/vitest.config.ts b/integrations/customer-io/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/customer-io/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/databricks/package.json b/integrations/databricks/package.json index 3f60ac5ba1..e1cb3a4384 100644 --- a/integrations/databricks/package.json +++ b/integrations/databricks/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/databricks/src/auth.ts b/integrations/databricks/src/auth.ts index 2aa535934c..53c35bd99a 100644 --- a/integrations/databricks/src/auth.ts +++ b/integrations/databricks/src/auth.ts @@ -1,11 +1,18 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { databricksApiError, databricksServiceError } from './lib/errors'; + +let normalizeWorkspaceUrl = (workspaceUrl: string) => workspaceUrl.replace(/\/+$/, ''); + +let expiresAtFrom = (expiresIn: unknown) => + typeof expiresIn === 'number' ? Date.now() + expiresIn * 1000 : undefined; export let auth = SlateAuth.create() .output( z.object({ token: z.string(), - refreshToken: z.string().optional() + refreshToken: z.string().optional(), + expiresAt: z.number().optional().describe('Access token expiry time in epoch ms') }) ) .addOauth({ @@ -50,7 +57,7 @@ export let auth = SlateAuth.create() }), getAuthorizationUrl: async ctx => { - let host = ctx.input.workspaceUrl.replace(/\/+$/, ''); + let host = normalizeWorkspaceUrl(ctx.input.workspaceUrl); let params = new URLSearchParams({ client_id: ctx.clientId, redirect_uri: ctx.redirectUri, @@ -68,80 +75,98 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let host = ctx.input.workspaceUrl.replace(/\/+$/, ''); + let host = normalizeWorkspaceUrl(ctx.input.workspaceUrl); let http = createAxios({ baseURL: host }); - let response = await http.post( - '/oidc/v1/token', - new URLSearchParams({ - grant_type: 'authorization_code', - code: ctx.code, - redirect_uri: ctx.redirectUri, - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - code_verifier: ctx.state - }).toString(), - { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - } - ); - - let data = response.data as any; - - return { - output: { - token: data.access_token, - refreshToken: data.refresh_token - }, - input: ctx.input - }; + try { + let response = await http.post( + '/oidc/v1/token', + new URLSearchParams({ + grant_type: 'authorization_code', + code: ctx.code, + redirect_uri: ctx.redirectUri, + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + code_verifier: ctx.state + }).toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + } + ); + + let data = response.data as any; + + return { + output: { + token: data.access_token, + refreshToken: data.refresh_token, + expiresAt: expiresAtFrom(data.expires_in) + }, + input: ctx.input + }; + } catch (error) { + throw databricksApiError(error, 'OAuth callback'); + } }, handleTokenRefresh: async (ctx: any) => { - let host = ctx.input.workspaceUrl.replace(/\/+$/, ''); - let http = createAxios({ baseURL: host }); - - let response = await http.post( - '/oidc/v1/token', - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: ctx.output.refreshToken ?? '', - client_id: ctx.clientId, - client_secret: ctx.clientSecret - }).toString(), - { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - } - ); + if (!ctx.output.refreshToken) { + throw databricksServiceError('No Databricks refresh token is available.'); + } - let data = response.data as any; + let host = normalizeWorkspaceUrl(ctx.input.workspaceUrl); + let http = createAxios({ baseURL: host }); - return { - output: { - token: data.access_token, - refreshToken: data.refresh_token ?? ctx.output.refreshToken - }, - input: ctx.input - }; + try { + let response = await http.post( + '/oidc/v1/token', + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }).toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + } + ); + + let data = response.data as any; + + return { + output: { + token: data.access_token, + refreshToken: data.refresh_token ?? ctx.output.refreshToken, + expiresAt: expiresAtFrom(data.expires_in) + }, + input: ctx.input + }; + } catch (error) { + throw databricksApiError(error, 'OAuth token refresh'); + } }, getProfile: async (ctx: any) => { - let host = ctx.input.workspaceUrl.replace(/\/+$/, ''); + let host = normalizeWorkspaceUrl(ctx.input.workspaceUrl); let http = createAxios({ baseURL: host, headers: { Authorization: `Bearer ${ctx.output.token}` } }); - let response = await http.get('/api/2.0/preview/scim/v2/Me'); - let user = response.data as any; - - return { - profile: { - id: user.id, - email: user.emails?.[0]?.value ?? user.userName, - name: user.displayName ?? user.userName - } - }; + try { + let response = await http.get('/api/2.0/preview/scim/v2/Me'); + let user = response.data as any; + + return { + profile: { + id: user.id, + email: user.emails?.[0]?.value ?? user.userName, + name: user.displayName ?? user.userName + } + }; + } catch (error) { + throw databricksApiError(error, 'profile lookup'); + } } }) .addTokenAuth({ @@ -177,28 +202,33 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { - let host = ctx.input.workspaceUrl.replace(/\/+$/, ''); + let host = normalizeWorkspaceUrl(ctx.input.workspaceUrl); let http = createAxios({ baseURL: host }); - let response = await http.post( - '/oidc/v1/token', - new URLSearchParams({ - grant_type: 'client_credentials', - client_id: ctx.input.clientId, - client_secret: ctx.input.clientSecret, - scope: 'all-apis' - }).toString(), - { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - } - ); - - let data = response.data as any; - - return { - output: { - token: data.access_token - } - }; + try { + let response = await http.post( + '/oidc/v1/token', + new URLSearchParams({ + grant_type: 'client_credentials', + client_id: ctx.input.clientId, + client_secret: ctx.input.clientSecret, + scope: 'all-apis' + }).toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + } + ); + + let data = response.data as any; + + return { + output: { + token: data.access_token, + expiresAt: expiresAtFrom(data.expires_in) + } + }; + } catch (error) { + throw databricksApiError(error, 'machine-to-machine token exchange'); + } } }); diff --git a/integrations/databricks/src/index.ts b/integrations/databricks/src/index.ts index dd66f79c34..641666a465 100644 --- a/integrations/databricks/src/index.ts +++ b/integrations/databricks/src/index.ts @@ -13,10 +13,12 @@ import { listWarehouses, manageCluster, manageDbfs, + manageFiles, manageJob, manageNotebook, managePipeline, manageSecrets, + manageVectorSearch, manageWarehouse, queryServingEndpoint, runJob, @@ -46,7 +48,9 @@ export let provider = Slate.create({ queryServingEndpoint, managePipeline, listPipelines, - manageDbfs + manageDbfs, + manageFiles, + manageVectorSearch ], triggers: [modelRegistryTrigger, jobRunsTrigger] }); diff --git a/integrations/databricks/src/lib/client.ts b/integrations/databricks/src/lib/client.ts index 00a090b4d9..7aeb52ab47 100644 --- a/integrations/databricks/src/lib/client.ts +++ b/integrations/databricks/src/lib/client.ts @@ -1,4 +1,29 @@ import { createAxios } from 'slates'; +import { databricksApiError } from './errors'; + +let encodeMultiSegmentPath = (path: string) => { + let normalized = path.startsWith('/') ? path : `/${path}`; + return normalized + .split('/') + .map((part, index) => (index === 0 ? '' : encodeURIComponent(part))) + .join('/'); +}; + +let toBase64 = (data: unknown) => { + if (Buffer.isBuffer(data)) { + return data.toString('base64'); + } + + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString('base64'); + } + + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('base64'); + } + + return Buffer.from(String(data)).toString('base64'); +}; export class DatabricksClient { private http: ReturnType; @@ -12,17 +37,21 @@ export class DatabricksClient { 'Content-Type': 'application/json' } }); + this.http.interceptors.response.use( + response => response, + error => Promise.reject(databricksApiError(error)) + ); } // ─── Clusters ──────────────────────────────────────────────────────── async listClusters() { - let response = await this.http.get('/api/2.0/clusters/list'); + let response = await this.http.get('/api/2.1/clusters/list'); return (response.data as any).clusters ?? []; } async getCluster(clusterId: string) { - let response = await this.http.get('/api/2.0/clusters/get', { + let response = await this.http.get('/api/2.1/clusters/get', { params: { cluster_id: clusterId } }); return response.data as any; @@ -56,7 +85,7 @@ export class DatabricksClient { if (params.sparkConf) body.spark_conf = params.sparkConf; if (params.customTags) body.custom_tags = params.customTags; - let response = await this.http.post('/api/2.0/clusters/create', body); + let response = await this.http.post('/api/2.1/clusters/create', body); return response.data as any; } @@ -93,43 +122,43 @@ export class DatabricksClient { if (params.autoterminationMinutes !== undefined) body.autotermination_minutes = params.autoterminationMinutes; - let response = await this.http.post('/api/2.0/clusters/edit', body); + let response = await this.http.post('/api/2.1/clusters/edit', body); return response.data as any; } async startCluster(clusterId: string) { - await this.http.post('/api/2.0/clusters/start', { cluster_id: clusterId }); + await this.http.post('/api/2.1/clusters/start', { cluster_id: clusterId }); } async restartCluster(clusterId: string) { - await this.http.post('/api/2.0/clusters/restart', { cluster_id: clusterId }); + await this.http.post('/api/2.1/clusters/restart', { cluster_id: clusterId }); } async terminateCluster(clusterId: string) { - await this.http.post('/api/2.0/clusters/delete', { cluster_id: clusterId }); + await this.http.post('/api/2.1/clusters/delete', { cluster_id: clusterId }); } async permanentDeleteCluster(clusterId: string) { - await this.http.post('/api/2.0/clusters/permanent-delete', { cluster_id: clusterId }); + await this.http.post('/api/2.1/clusters/permanent-delete', { cluster_id: clusterId }); } // ─── Jobs ──────────────────────────────────────────────────────────── async listJobs( - params: { limit?: number; offset?: number; name?: string; expandTasks?: boolean } = {} + params: { limit?: number; pageToken?: string; name?: string; expandTasks?: boolean } = {} ) { let query: Record = {}; if (params.limit !== undefined) query.limit = params.limit; - if (params.offset !== undefined) query.offset = params.offset; + if (params.pageToken) query.page_token = params.pageToken; if (params.name) query.name = params.name; if (params.expandTasks) query.expand_tasks = true; - let response = await this.http.get('/api/2.1/jobs/list', { params: query }); + let response = await this.http.get('/api/2.2/jobs/list', { params: query }); return response.data as any; } async getJob(jobId: string) { - let response = await this.http.get('/api/2.1/jobs/get', { + let response = await this.http.get('/api/2.2/jobs/get', { params: { job_id: jobId } }); return response.data as any; @@ -163,12 +192,45 @@ export class DatabricksClient { if (params.webhookNotifications) body.webhook_notifications = params.webhookNotifications; if (params.tags) body.tags = params.tags; - let response = await this.http.post('/api/2.1/jobs/create', body); + let response = await this.http.post('/api/2.2/jobs/create', body); return response.data as any; } + async updateJob( + jobId: string, + params: { + name?: string; + tasks?: any[]; + schedule?: { quartzCronExpression: string; timezoneId: string; pauseStatus?: string }; + maxConcurrentRuns?: number; + timeoutSeconds?: number; + tags?: Record; + } + ) { + let newSettings: Record = {}; + if (params.name) newSettings.name = params.name; + if (params.tasks) newSettings.tasks = params.tasks.map((t: any) => this.mapTaskToApi(t)); + if (params.schedule) { + newSettings.schedule = { + quartz_cron_expression: params.schedule.quartzCronExpression, + timezone_id: params.schedule.timezoneId, + pause_status: params.schedule.pauseStatus ?? 'UNPAUSED' + }; + } + if (params.maxConcurrentRuns !== undefined) + newSettings.max_concurrent_runs = params.maxConcurrentRuns; + if (params.timeoutSeconds !== undefined) + newSettings.timeout_seconds = params.timeoutSeconds; + if (params.tags) newSettings.tags = params.tags; + + await this.http.post('/api/2.2/jobs/update', { + job_id: Number(jobId), + new_settings: newSettings + }); + } + async deleteJob(jobId: string) { - await this.http.post('/api/2.1/jobs/delete', { job_id: jobId }); + await this.http.post('/api/2.2/jobs/delete', { job_id: jobId }); } async runJobNow( @@ -186,16 +248,16 @@ export class DatabricksClient { if (params.jarParams) body.jar_params = params.jarParams; if (params.sparkSubmitParams) body.spark_submit_params = params.sparkSubmitParams; - let response = await this.http.post('/api/2.1/jobs/run-now', body); + let response = await this.http.post('/api/2.2/jobs/run-now', body); return response.data as any; } async cancelJobRun(runId: string) { - await this.http.post('/api/2.1/jobs/runs/cancel', { run_id: runId }); + await this.http.post('/api/2.2/jobs/runs/cancel', { run_id: runId }); } async getJobRun(runId: string) { - let response = await this.http.get('/api/2.1/jobs/runs/get', { + let response = await this.http.get('/api/2.2/jobs/runs/get', { params: { run_id: runId } }); return response.data as any; @@ -207,7 +269,7 @@ export class DatabricksClient { activeOnly?: boolean; completedOnly?: boolean; limit?: number; - offset?: number; + pageToken?: string; } = {} ) { let query: Record = {}; @@ -215,14 +277,14 @@ export class DatabricksClient { if (params.activeOnly) query.active_only = true; if (params.completedOnly) query.completed_only = true; if (params.limit !== undefined) query.limit = params.limit; - if (params.offset !== undefined) query.offset = params.offset; + if (params.pageToken) query.page_token = params.pageToken; - let response = await this.http.get('/api/2.1/jobs/runs/list', { params: query }); + let response = await this.http.get('/api/2.2/jobs/runs/list', { params: query }); return response.data as any; } async getJobRunOutput(runId: string) { - let response = await this.http.get('/api/2.1/jobs/runs/get-output', { + let response = await this.http.get('/api/2.2/jobs/runs/get-output', { params: { run_id: runId } }); return response.data as any; @@ -675,7 +737,9 @@ export class DatabricksClient { } async getVectorSearchEndpoint(endpointName: string) { - let response = await this.http.get(`/api/2.0/vector-search/endpoints/${endpointName}`); + let response = await this.http.get( + `/api/2.0/vector-search/endpoints/${encodeURIComponent(endpointName)}` + ); return response.data as any; } @@ -688,16 +752,29 @@ export class DatabricksClient { } async deleteVectorSearchEndpoint(endpointName: string) { - await this.http.delete(`/api/2.0/vector-search/endpoints/${endpointName}`); + await this.http.delete( + `/api/2.0/vector-search/endpoints/${encodeURIComponent(endpointName)}` + ); } async listVectorSearchIndexes(endpointName: string) { - let response = await this.http.get(`/api/2.0/vector-search/indexes`, { + let response = await this.http.get('/api/2.0/vector-search/indexes', { params: { endpoint_name: endpointName } }); return (response.data as any).vector_indexes ?? []; } + async getVectorSearchIndex(indexName: string) { + let response = await this.http.get( + `/api/2.0/vector-search/indexes/${encodeURIComponent(indexName)}` + ); + return response.data as any; + } + + async deleteVectorSearchIndex(indexName: string) { + await this.http.delete(`/api/2.0/vector-search/indexes/${encodeURIComponent(indexName)}`); + } + async queryVectorSearchIndex( indexName: string, params: { @@ -717,12 +794,69 @@ export class DatabricksClient { if (params.filtersJson) body.filters_json = params.filtersJson; let response = await this.http.post( - `/api/2.0/vector-search/indexes/${indexName}/query`, + `/api/2.0/vector-search/indexes/${encodeURIComponent(indexName)}/query`, body ); return response.data as any; } + // ─── Files API (Workspace files and Unity Catalog Volumes) ─────────── + + async listFilesDirectory( + path: string, + params: { pageSize?: number; pageToken?: string } = {} + ) { + let response = await this.http.get( + `/api/2.0/fs/directories${encodeMultiSegmentPath(path)}`, + { + params: { + page_size: params.pageSize, + page_token: params.pageToken + } + } + ); + return response.data as any; + } + + async createFilesDirectory(path: string) { + await this.http.put(`/api/2.0/fs/directories${encodeMultiSegmentPath(path)}`); + } + + async deleteFilesDirectory(path: string) { + await this.http.delete(`/api/2.0/fs/directories${encodeMultiSegmentPath(path)}`); + } + + async downloadFile(path: string, range?: string) { + let response = await this.http.get(`/api/2.0/fs/files${encodeMultiSegmentPath(path)}`, { + responseType: 'arraybuffer', + headers: range ? { Range: range } : undefined + }); + + return { + contentBase64: toBase64(response.data), + contentType: String(response.headers?.['content-type'] ?? 'application/octet-stream'), + contentLength: Number(response.headers?.['content-length'] ?? 0) || undefined, + lastModified: response.headers?.['last-modified'] + ? String(response.headers['last-modified']) + : undefined + }; + } + + async uploadFile(path: string, contentBase64: string, overwrite?: boolean) { + await this.http.put( + `/api/2.0/fs/files${encodeMultiSegmentPath(path)}`, + Buffer.from(contentBase64, 'base64'), + { + params: overwrite === undefined ? undefined : { overwrite }, + headers: { 'Content-Type': 'application/octet-stream' } + } + ); + } + + async deleteFile(path: string) { + await this.http.delete(`/api/2.0/fs/files${encodeMultiSegmentPath(path)}`); + } + // ─── DBFS ──────────────────────────────────────────────────────────── async dbfsList(path: string) { diff --git a/integrations/databricks/src/lib/errors.ts b/integrations/databricks/src/lib/errors.ts new file mode 100644 index 0000000000..0e78eb52cb --- /dev/null +++ b/integrations/databricks/src/lib/errors.ts @@ -0,0 +1,79 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + for (let key of ['message', 'error', 'error_description', 'error_code', 'code', 'details']) { + collectDetails(value[key], details); + } +}; + +let extractDatabricksMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (error instanceof Error && error.message) { + pushDetail(details, error.message); + } + + return details.length > 0 ? details.join(' - ') : 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let databricksServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let databricksApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = databricksServiceError( + `Databricks API ${operation} failed: ${statusLabelFor(response)}${extractDatabricksMessage(error)}` + ); + serviceError.data.reason = 'databricks_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/databricks/src/tools.schema.test.ts b/integrations/databricks/src/tools.schema.test.ts new file mode 100644 index 0000000000..e26b44b48f --- /dev/null +++ b/integrations/databricks/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Databricks tool input schemas', provider.actions); diff --git a/integrations/databricks/src/tools/browse-catalog.ts b/integrations/databricks/src/tools/browse-catalog.ts index 5154ea3c85..c0effc40d2 100644 --- a/integrations/databricks/src/tools/browse-catalog.ts +++ b/integrations/databricks/src/tools/browse-catalog.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; export let browseCatalog = SlateTool.create(spec, { @@ -114,7 +115,7 @@ export let browseCatalog = SlateTool.create(spec, { } case 'schemas': { if (!ctx.input.catalogName) - throw new Error('catalogName is required for listing schemas'); + throw databricksServiceError('catalogName is required for listing schemas'); let schemas = await client.listSchemas(ctx.input.catalogName); let mapped = schemas.map((s: any) => ({ schemaName: s.full_name ?? `${ctx.input.catalogName}.${s.name}`, @@ -128,7 +129,9 @@ export let browseCatalog = SlateTool.create(spec, { } case 'tables': { if (!ctx.input.catalogName || !ctx.input.schemaName) - throw new Error('catalogName and schemaName are required for listing tables'); + throw databricksServiceError( + 'catalogName and schemaName are required for listing tables' + ); let tables = await client.listTables(ctx.input.catalogName, ctx.input.schemaName); let mapped = tables.map((t: any) => ({ tableName: @@ -144,7 +147,8 @@ export let browseCatalog = SlateTool.create(spec, { }; } case 'table_detail': { - if (!ctx.input.tableName) throw new Error('tableName is required for table detail'); + if (!ctx.input.tableName) + throw databricksServiceError('tableName is required for table detail'); let table = await client.getTable(ctx.input.tableName); let detail = { tableName: table.full_name ?? ctx.input.tableName, diff --git a/integrations/databricks/src/tools/index.ts b/integrations/databricks/src/tools/index.ts index 7c5ead706f..e0d7a667da 100644 --- a/integrations/databricks/src/tools/index.ts +++ b/integrations/databricks/src/tools/index.ts @@ -10,10 +10,12 @@ export * from './list-serving-endpoints'; export * from './list-warehouses'; export * from './manage-cluster'; export * from './manage-dbfs'; +export * from './manage-files'; export * from './manage-job'; export * from './manage-notebook'; export * from './manage-pipeline'; export * from './manage-secrets'; +export * from './manage-vector-search'; export * from './manage-warehouse'; export * from './query-serving-endpoint'; export * from './run-job'; diff --git a/integrations/databricks/src/tools/list-jobs.ts b/integrations/databricks/src/tools/list-jobs.ts index b7ff2cb777..c9e51a5467 100644 --- a/integrations/databricks/src/tools/list-jobs.ts +++ b/integrations/databricks/src/tools/list-jobs.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listJobs = SlateTool.create(spec, { @@ -15,7 +16,14 @@ export let listJobs = SlateTool.create(spec, { z.object({ name: z.string().optional().describe('Filter by job name (substring match)'), limit: z.number().optional().describe('Maximum number of jobs to return (default 20)'), - offset: z.number().optional().describe('Offset for pagination'), + pageToken: z + .string() + .optional() + .describe('Pagination token from nextPageToken or prevPageToken'), + offset: z + .number() + .optional() + .describe('Deprecated: Jobs API 2.2 uses pageToken instead of offset'), expandTasks: z .boolean() .optional() @@ -37,10 +45,16 @@ export let listJobs = SlateTool.create(spec, { }) ) .describe('List of jobs'), - hasMore: z.boolean().describe('Whether more results are available') + hasMore: z.boolean().describe('Whether more results are available'), + nextPageToken: z.string().optional().describe('Token for the next page of jobs'), + prevPageToken: z.string().optional().describe('Token for the previous page of jobs') }) ) .handleInvocation(async ctx => { + if (ctx.input.offset !== undefined) { + throw databricksServiceError('offset is not supported by Jobs API 2.2; use pageToken'); + } + let client = new DatabricksClient({ workspaceUrl: ctx.config.workspaceUrl, token: ctx.auth.token @@ -49,7 +63,7 @@ export let listJobs = SlateTool.create(spec, { let result = await client.listJobs({ name: ctx.input.name, limit: ctx.input.limit, - offset: ctx.input.offset, + pageToken: ctx.input.pageToken, expandTasks: ctx.input.expandTasks }); @@ -66,9 +80,11 @@ export let listJobs = SlateTool.create(spec, { return { output: { jobs, - hasMore: result.has_more ?? false + hasMore: Boolean(result.next_page_token), + nextPageToken: result.next_page_token, + prevPageToken: result.prev_page_token }, - message: `Found **${jobs.length}** job(s).${result.has_more ? ' More results available.' : ''}` + message: `Found **${jobs.length}** job(s).${result.next_page_token ? ' More results available.' : ''}` }; }) .build(); diff --git a/integrations/databricks/src/tools/manage-cluster.ts b/integrations/databricks/src/tools/manage-cluster.ts index 1599f95efe..a52f985950 100644 --- a/integrations/databricks/src/tools/manage-cluster.ts +++ b/integrations/databricks/src/tools/manage-cluster.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCluster = SlateTool.create(spec, { @@ -73,7 +74,9 @@ To **start/restart/stop/delete**, provide \`clusterId\` and the corresponding \` if (action === 'create') { if (!ctx.input.clusterName || !ctx.input.sparkVersion || !ctx.input.nodeTypeId) { - throw new Error('clusterName, sparkVersion, and nodeTypeId are required for create'); + throw databricksServiceError( + 'clusterName, sparkVersion, and nodeTypeId are required for create' + ); } let result = await client.createCluster({ clusterName: ctx.input.clusterName, @@ -92,7 +95,7 @@ To **start/restart/stop/delete**, provide \`clusterId\` and the corresponding \` } if (!clusterId) { - throw new Error('clusterId is required for this action'); + throw databricksServiceError('clusterId is required for this action'); } switch (action) { diff --git a/integrations/databricks/src/tools/manage-dbfs.ts b/integrations/databricks/src/tools/manage-dbfs.ts index 6fdb24feea..a4d84f4bff 100644 --- a/integrations/databricks/src/tools/manage-dbfs.ts +++ b/integrations/databricks/src/tools/manage-dbfs.ts @@ -1,6 +1,7 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageDbfs = SlateTool.create(spec, { @@ -9,7 +10,7 @@ export let manageDbfs = SlateTool.create(spec, { description: `Interact with the Databricks File System (DBFS). List, read, upload, create directories, and delete files or folders.`, instructions: [ 'File content for upload must be base64-encoded.', - 'Read returns base64-encoded file content.', + 'Read returns file bytes as a Slate attachment, not an inline base64 output field.', 'Use Unity Catalog Volumes for governed file management where possible.' ] }) @@ -18,6 +19,10 @@ export let manageDbfs = SlateTool.create(spec, { action: z.enum(['list', 'read', 'put', 'mkdirs', 'delete']).describe('DBFS operation'), path: z.string().describe('DBFS path (e.g., "/mnt/data/file.csv")'), content: z.string().optional().describe('Base64-encoded file content (for put)'), + mimeType: z + .string() + .optional() + .describe('MIME type to use for read attachments (default application/octet-stream)'), overwrite: z.boolean().optional().describe('Overwrite existing file (for put)'), recursive: z.boolean().optional().describe('Delete recursively (for delete)'), offset: z.number().optional().describe('Byte offset for read'), @@ -36,8 +41,9 @@ export let manageDbfs = SlateTool.create(spec, { ) .optional() .describe('Files listed at the path'), - content: z.string().optional().describe('Base64-encoded file content (for read)'), bytesRead: z.number().optional().describe('Number of bytes read'), + mimeType: z.string().optional().describe('MIME type of the returned attachment'), + attachmentCount: z.number().optional().describe('Number of returned Slate attachments'), success: z.boolean().describe('Whether the operation succeeded') }) ) @@ -62,17 +68,22 @@ export let manageDbfs = SlateTool.create(spec, { } case 'read': { let result = await client.dbfsRead(ctx.input.path, ctx.input.offset, ctx.input.length); + let mimeType = ctx.input.mimeType ?? 'application/octet-stream'; return { output: { - content: result.data, bytesRead: result.bytes_read, + mimeType, + attachmentCount: result.data ? 1 : 0, success: true }, + attachments: result.data + ? [createBase64Attachment(result.data, mimeType)] + : undefined, message: `Read **${result.bytes_read ?? 0}** bytes from \`${ctx.input.path}\`.` }; } case 'put': { - if (!ctx.input.content) throw new Error('content is required for put'); + if (!ctx.input.content) throw databricksServiceError('content is required for put'); await client.dbfsPut(ctx.input.path, ctx.input.content, ctx.input.overwrite); return { output: { success: true }, diff --git a/integrations/databricks/src/tools/manage-files.ts b/integrations/databricks/src/tools/manage-files.ts new file mode 100644 index 0000000000..0424386e5d --- /dev/null +++ b/integrations/databricks/src/tools/manage-files.ts @@ -0,0 +1,170 @@ +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let isLikelyBase64 = (value: string) => { + let normalized = value.replace(/\s+/g, ''); + return ( + normalized.length > 0 && + normalized.length % 4 === 0 && + /^[A-Za-z0-9+/]+={0,2}$/.test(normalized) + ); +}; + +export let manageFiles = SlateTool.create(spec, { + name: 'Manage Files', + key: 'manage_files', + description: `Manage files and directories with the current Databricks Files API for workspace files and Unity Catalog Volumes. Supports listing directories, creating and deleting directories, uploading files, downloading files as Slate attachments, and deleting files.`, + instructions: [ + 'Use absolute paths such as /Volumes/catalog/schema/volume/path/file.txt or workspace file paths supported by the Files API.', + 'Downloaded bytes are returned in response attachments, not inline output fields.', + 'Uploads expect base64-encoded file content.' + ], + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z + .enum([ + 'list_directory', + 'create_directory', + 'delete_directory', + 'download_file', + 'upload_file', + 'delete_file' + ]) + .describe('Files API operation to perform'), + path: z + .string() + .describe( + 'Absolute file or directory path, for example /Volumes/catalog/schema/volume/file.txt' + ), + contentBase64: z + .string() + .optional() + .describe('Base64-encoded file bytes, required for upload_file'), + mimeType: z + .string() + .optional() + .describe('MIME type to use for download attachments when Databricks omits one'), + overwrite: z + .boolean() + .optional() + .describe('Whether upload_file should overwrite an existing file'), + pageSize: z.number().optional().describe('Maximum directory entries to return'), + pageToken: z.string().optional().describe('Pagination token for list_directory'), + range: z + .string() + .optional() + .describe('HTTP byte range for download_file, for example bytes=0-499') + }) + ) + .output( + z.object({ + path: z.string().describe('Path acted on by the operation'), + entries: z + .array( + z.object({ + path: z.string().describe('Absolute file or directory path'), + name: z.string().optional().describe('Last path component'), + isDirectory: z.boolean().optional().describe('Whether the item is a directory'), + fileSize: z.number().optional().describe('File size in bytes'), + lastModified: z.string().optional().describe('Last modification time in epoch ms') + }) + ) + .optional() + .describe('Directory entries returned by list_directory'), + nextPageToken: z.string().optional().describe('Token for the next directory page'), + byteLength: z.number().optional().describe('Downloaded file byte length'), + mimeType: z.string().optional().describe('MIME type of the returned attachment'), + lastModified: z.string().optional().describe('Downloaded file Last-Modified header'), + attachmentCount: z.number().optional().describe('Number of returned Slate attachments'), + success: z.boolean().describe('Whether the operation succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new DatabricksClient({ + workspaceUrl: ctx.config.workspaceUrl, + token: ctx.auth.token + }); + + switch (ctx.input.action) { + case 'list_directory': { + let result = await client.listFilesDirectory(ctx.input.path, { + pageSize: ctx.input.pageSize, + pageToken: ctx.input.pageToken + }); + let entries = (result.contents ?? []).map((entry: any) => ({ + path: entry.path ?? '', + name: entry.name, + isDirectory: entry.is_directory, + fileSize: entry.file_size, + lastModified: entry.last_modified ? String(entry.last_modified) : undefined + })); + + return { + output: { + path: ctx.input.path, + entries, + nextPageToken: result.next_page_token, + success: true + }, + message: `Found **${entries.length}** item(s) in \`${ctx.input.path}\`.` + }; + } + case 'create_directory': { + await client.createFilesDirectory(ctx.input.path); + return { + output: { path: ctx.input.path, success: true }, + message: `Created directory \`${ctx.input.path}\`.` + }; + } + case 'delete_directory': { + await client.deleteFilesDirectory(ctx.input.path); + return { + output: { path: ctx.input.path, success: true }, + message: `Deleted directory \`${ctx.input.path}\`.` + }; + } + case 'download_file': { + let result = await client.downloadFile(ctx.input.path, ctx.input.range); + let mimeType = result.contentType || ctx.input.mimeType || 'application/octet-stream'; + return { + output: { + path: ctx.input.path, + byteLength: result.contentLength, + mimeType, + lastModified: result.lastModified, + attachmentCount: 1, + success: true + }, + attachments: [createBase64Attachment(result.contentBase64, mimeType)], + message: `Downloaded file \`${ctx.input.path}\`.` + }; + } + case 'upload_file': { + if (!ctx.input.contentBase64 || !isLikelyBase64(ctx.input.contentBase64)) { + throw databricksServiceError( + 'contentBase64 is required and must be valid base64 for upload_file' + ); + } + await client.uploadFile(ctx.input.path, ctx.input.contentBase64, ctx.input.overwrite); + return { + output: { path: ctx.input.path, success: true }, + message: `Uploaded file to \`${ctx.input.path}\`.` + }; + } + case 'delete_file': { + await client.deleteFile(ctx.input.path); + return { + output: { path: ctx.input.path, success: true }, + message: `Deleted file \`${ctx.input.path}\`.` + }; + } + } + }) + .build(); diff --git a/integrations/databricks/src/tools/manage-job.ts b/integrations/databricks/src/tools/manage-job.ts index c84f5df5c9..fd1dec254f 100644 --- a/integrations/databricks/src/tools/manage-job.ts +++ b/integrations/databricks/src/tools/manage-job.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; let taskSchema = z.object({ @@ -33,10 +34,11 @@ let taskSchema = z.object({ export let manageJob = SlateTool.create(spec, { name: 'Manage Job', key: 'manage_job', - description: `Create or delete a multi-task workflow job. Supports notebook, Python, and SQL task types with dependencies, scheduling, and notification settings.`, + description: `Create, update, or delete a multi-task workflow job. Supports notebook, Python, and SQL task types with dependencies, scheduling, and notification settings.`, instructions: [ 'To create a job, provide a name and at least one task.', 'Tasks can depend on each other using the dependsOn field referencing other task keys.', + 'To update a job, provide jobId and at least one setting such as name, tasks, schedule, timeoutSeconds, or tags.', 'To delete a job, provide only the jobId and set action to "delete".' ], tags: { @@ -45,8 +47,8 @@ export let manageJob = SlateTool.create(spec, { }) .input( z.object({ - action: z.enum(['create', 'delete']).describe('Action to perform'), - jobId: z.string().optional().describe('Job ID (required for delete)'), + action: z.enum(['create', 'update', 'delete']).describe('Action to perform'), + jobId: z.string().optional().describe('Job ID (required for update and delete)'), name: z.string().optional().describe('Job name (required for create)'), tasks: z.array(taskSchema).optional().describe('List of tasks for the job'), schedule: z @@ -77,7 +79,7 @@ export let manageJob = SlateTool.create(spec, { }); if (ctx.input.action === 'delete') { - if (!ctx.input.jobId) throw new Error('jobId is required for delete'); + if (!ctx.input.jobId) throw databricksServiceError('jobId is required for delete'); await client.deleteJob(ctx.input.jobId); return { output: { jobId: ctx.input.jobId }, @@ -85,8 +87,36 @@ export let manageJob = SlateTool.create(spec, { }; } + if (ctx.input.action === 'update') { + if (!ctx.input.jobId) throw databricksServiceError('jobId is required for update'); + if ( + !ctx.input.name && + !ctx.input.tasks && + !ctx.input.schedule && + ctx.input.maxConcurrentRuns === undefined && + ctx.input.timeoutSeconds === undefined && + !ctx.input.tags + ) { + throw databricksServiceError('At least one job setting is required for update'); + } + + await client.updateJob(ctx.input.jobId, { + name: ctx.input.name, + tasks: ctx.input.tasks, + schedule: ctx.input.schedule, + maxConcurrentRuns: ctx.input.maxConcurrentRuns, + timeoutSeconds: ctx.input.timeoutSeconds, + tags: ctx.input.tags as Record | undefined + }); + + return { + output: { jobId: ctx.input.jobId }, + message: `Updated job **${ctx.input.jobId}**.` + }; + } + if (!ctx.input.name || !ctx.input.tasks || ctx.input.tasks.length === 0) { - throw new Error('name and at least one task are required for create'); + throw databricksServiceError('name and at least one task are required for create'); } let result = await client.createJob({ diff --git a/integrations/databricks/src/tools/manage-notebook.ts b/integrations/databricks/src/tools/manage-notebook.ts index 7d669919c0..d2f7cc6a95 100644 --- a/integrations/databricks/src/tools/manage-notebook.ts +++ b/integrations/databricks/src/tools/manage-notebook.ts @@ -1,15 +1,32 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; +let mimeTypeForNotebookFormat = (format?: string) => { + switch (format) { + case 'HTML': + return 'text/html'; + case 'JUPYTER': + return 'application/x-ipynb+json'; + case 'DBC': + return 'application/octet-stream'; + default: + return 'text/plain'; + } +}; + +let base64ByteLength = (content: string) => Buffer.from(content, 'base64').byteLength; + export let manageNotebook = SlateTool.create(spec, { name: 'Manage Notebook', key: 'manage_notebook', description: `Import, export, or delete notebooks and create workspace directories. -Use **import** to upload notebook content (base64-encoded). Use **export** to download a notebook. Use **delete** to remove a notebook or folder.`, +Use **import** to upload notebook content (base64-encoded). Use **export** to download a notebook as a Slate attachment. Use **delete** to remove a notebook or folder.`, instructions: [ 'Content must be base64-encoded when importing.', + 'Exported notebook bytes are returned in response attachments, not inline output fields.', 'Supported formats: SOURCE, HTML, JUPYTER, DBC.', 'Supported languages: PYTHON, SCALA, SQL, R.' ], @@ -37,8 +54,10 @@ Use **import** to upload notebook content (base64-encoded). Use **export** to do .output( z.object({ path: z.string().describe('The workspace path acted upon'), - content: z.string().optional().describe('Base64-encoded content (for export)'), - fileType: z.string().optional().describe('File type of the exported object') + fileType: z.string().optional().describe('File type of the exported object'), + mimeType: z.string().optional().describe('MIME type of the exported attachment'), + byteLength: z.number().optional().describe('Byte length of the exported attachment'), + attachmentCount: z.number().optional().describe('Number of returned Slate attachments') }) ) .handleInvocation(async ctx => { @@ -49,7 +68,7 @@ Use **import** to upload notebook content (base64-encoded). Use **export** to do switch (ctx.input.action) { case 'import': { - if (!ctx.input.content) throw new Error('content is required for import'); + if (!ctx.input.content) throw databricksServiceError('content is required for import'); await client.importNotebook({ path: ctx.input.path, content: ctx.input.content, @@ -64,12 +83,18 @@ Use **import** to upload notebook content (base64-encoded). Use **export** to do } case 'export': { let result = await client.exportNotebook(ctx.input.path, ctx.input.format); + let mimeType = mimeTypeForNotebookFormat(ctx.input.format); return { output: { path: ctx.input.path, - content: result.content, - fileType: result.file_type + fileType: result.file_type, + mimeType, + byteLength: result.content ? base64ByteLength(result.content) : 0, + attachmentCount: result.content ? 1 : 0 }, + attachments: result.content + ? [createBase64Attachment(result.content, mimeType)] + : undefined, message: `Exported notebook from \`${ctx.input.path}\`.` }; } diff --git a/integrations/databricks/src/tools/manage-pipeline.ts b/integrations/databricks/src/tools/manage-pipeline.ts index f5c87ad25c..e1c00a93dd 100644 --- a/integrations/databricks/src/tools/manage-pipeline.ts +++ b/integrations/databricks/src/tools/manage-pipeline.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; export let managePipeline = SlateTool.create(spec, { @@ -61,7 +62,7 @@ export let managePipeline = SlateTool.create(spec, { if (action === 'create') { if (!ctx.input.name || !ctx.input.libraries) - throw new Error('name and libraries are required for create'); + throw databricksServiceError('name and libraries are required for create'); let result = await client.createPipeline({ name: ctx.input.name, libraries: ctx.input.libraries, @@ -76,7 +77,7 @@ export let managePipeline = SlateTool.create(spec, { }; } - if (!pipelineId) throw new Error('pipelineId is required for this action'); + if (!pipelineId) throw databricksServiceError('pipelineId is required for this action'); switch (action) { case 'start': { diff --git a/integrations/databricks/src/tools/manage-secrets.ts b/integrations/databricks/src/tools/manage-secrets.ts index 15d4b34113..730ecaabd6 100644 --- a/integrations/databricks/src/tools/manage-secrets.ts +++ b/integrations/databricks/src/tools/manage-secrets.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageSecrets = SlateTool.create(spec, { @@ -84,7 +85,7 @@ export let manageSecrets = SlateTool.create(spec, { }; } case 'create_scope': { - if (!ctx.input.scope) throw new Error('scope is required'); + if (!ctx.input.scope) throw databricksServiceError('scope is required'); await client.createSecretScope(ctx.input.scope, ctx.input.initialManagePrincipal); return { output: { success: true }, @@ -92,7 +93,7 @@ export let manageSecrets = SlateTool.create(spec, { }; } case 'delete_scope': { - if (!ctx.input.scope) throw new Error('scope is required'); + if (!ctx.input.scope) throw databricksServiceError('scope is required'); await client.deleteSecretScope(ctx.input.scope); return { output: { success: true }, @@ -100,7 +101,7 @@ export let manageSecrets = SlateTool.create(spec, { }; } case 'list_secrets': { - if (!ctx.input.scope) throw new Error('scope is required'); + if (!ctx.input.scope) throw databricksServiceError('scope is required'); let secrets = await client.listSecrets(ctx.input.scope); let mapped = secrets.map((s: any) => ({ secretKey: s.key, @@ -115,7 +116,7 @@ export let manageSecrets = SlateTool.create(spec, { } case 'put_secret': { if (!ctx.input.scope || !ctx.input.secretKey || !ctx.input.secretValue) { - throw new Error('scope, secretKey, and secretValue are required'); + throw databricksServiceError('scope, secretKey, and secretValue are required'); } await client.putSecret(ctx.input.scope, ctx.input.secretKey, ctx.input.secretValue); return { @@ -125,7 +126,7 @@ export let manageSecrets = SlateTool.create(spec, { } case 'delete_secret': { if (!ctx.input.scope || !ctx.input.secretKey) { - throw new Error('scope and secretKey are required'); + throw databricksServiceError('scope and secretKey are required'); } await client.deleteSecret(ctx.input.scope, ctx.input.secretKey); return { diff --git a/integrations/databricks/src/tools/manage-vector-search.ts b/integrations/databricks/src/tools/manage-vector-search.ts new file mode 100644 index 0000000000..4c4b1f235c --- /dev/null +++ b/integrations/databricks/src/tools/manage-vector-search.ts @@ -0,0 +1,165 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let manageVectorSearch = SlateTool.create(spec, { + name: 'Manage Vector Search', + key: 'manage_vector_search', + description: `Manage and query Databricks Vector Search endpoints and indexes. Supports listing, getting, creating, and deleting endpoints; listing and getting indexes; deleting indexes; and querying an index with text or vector input.`, + instructions: [ + 'Creating endpoints can provision compute; use create_endpoint only when the workspace is intended to own that endpoint.', + 'For query_index, provide indexName, columns, and either queryText or queryVector.' + ], + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z + .enum([ + 'list_endpoints', + 'get_endpoint', + 'create_endpoint', + 'delete_endpoint', + 'list_indexes', + 'get_index', + 'delete_index', + 'query_index' + ]) + .describe('Vector Search operation to perform'), + endpointName: z + .string() + .optional() + .describe('Vector Search endpoint name for endpoint actions and list_indexes'), + endpointType: z + .enum(['STANDARD', 'STORAGE_OPTIMIZED']) + .optional() + .describe('Endpoint type for create_endpoint'), + indexName: z + .string() + .optional() + .describe('Full Vector Search index name, usually catalog.schema.index'), + columns: z + .array(z.string()) + .optional() + .describe('Columns to return from query_index results'), + queryText: z.string().optional().describe('Text query for query_index'), + queryVector: z.array(z.number()).optional().describe('Embedding vector for query_index'), + numResults: z.number().optional().describe('Maximum query_index results to return'), + filtersJson: z + .string() + .optional() + .describe('JSON filter expression for query_index, passed through to Databricks') + }) + ) + .output( + z.object({ + endpoints: z.array(z.any()).optional().describe('Vector Search endpoints'), + endpoint: z.any().optional().describe('Vector Search endpoint details'), + indexes: z.array(z.any()).optional().describe('Vector Search indexes'), + index: z.any().optional().describe('Vector Search index details'), + queryResult: z.any().optional().describe('Vector Search query response'), + endpointName: z.string().optional().describe('Affected endpoint name'), + indexName: z.string().optional().describe('Affected index name'), + success: z.boolean().describe('Whether the operation succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new DatabricksClient({ + workspaceUrl: ctx.config.workspaceUrl, + token: ctx.auth.token + }); + + switch (ctx.input.action) { + case 'list_endpoints': { + let endpoints = await client.listVectorSearchEndpoints(); + return { + output: { endpoints, success: true }, + message: `Found **${endpoints.length}** vector search endpoint(s).` + }; + } + case 'get_endpoint': { + if (!ctx.input.endpointName) + throw databricksServiceError('endpointName is required for get_endpoint'); + let endpoint = await client.getVectorSearchEndpoint(ctx.input.endpointName); + return { + output: { endpoint, endpointName: ctx.input.endpointName, success: true }, + message: `Retrieved vector search endpoint **${ctx.input.endpointName}**.` + }; + } + case 'create_endpoint': { + if (!ctx.input.endpointName || !ctx.input.endpointType) { + throw databricksServiceError( + 'endpointName and endpointType are required for create_endpoint' + ); + } + let endpoint = await client.createVectorSearchEndpoint( + ctx.input.endpointName, + ctx.input.endpointType + ); + return { + output: { endpoint, endpointName: ctx.input.endpointName, success: true }, + message: `Created vector search endpoint **${ctx.input.endpointName}**.` + }; + } + case 'delete_endpoint': { + if (!ctx.input.endpointName) + throw databricksServiceError('endpointName is required for delete_endpoint'); + await client.deleteVectorSearchEndpoint(ctx.input.endpointName); + return { + output: { endpointName: ctx.input.endpointName, success: true }, + message: `Deleted vector search endpoint **${ctx.input.endpointName}**.` + }; + } + case 'list_indexes': { + if (!ctx.input.endpointName) + throw databricksServiceError('endpointName is required for list_indexes'); + let indexes = await client.listVectorSearchIndexes(ctx.input.endpointName); + return { + output: { indexes, endpointName: ctx.input.endpointName, success: true }, + message: `Found **${indexes.length}** vector search index(es).` + }; + } + case 'get_index': { + if (!ctx.input.indexName) + throw databricksServiceError('indexName is required for get_index'); + let index = await client.getVectorSearchIndex(ctx.input.indexName); + return { + output: { index, indexName: ctx.input.indexName, success: true }, + message: `Retrieved vector search index **${ctx.input.indexName}**.` + }; + } + case 'delete_index': { + if (!ctx.input.indexName) + throw databricksServiceError('indexName is required for delete_index'); + await client.deleteVectorSearchIndex(ctx.input.indexName); + return { + output: { indexName: ctx.input.indexName, success: true }, + message: `Deleted vector search index **${ctx.input.indexName}**.` + }; + } + case 'query_index': { + if (!ctx.input.indexName) + throw databricksServiceError('indexName is required for query_index'); + if (!ctx.input.columns || ctx.input.columns.length === 0) + throw databricksServiceError('columns is required for query_index'); + if (!ctx.input.queryText && !ctx.input.queryVector) + throw databricksServiceError('queryText or queryVector is required for query_index'); + let queryResult = await client.queryVectorSearchIndex(ctx.input.indexName, { + queryText: ctx.input.queryText, + queryVector: ctx.input.queryVector, + columns: ctx.input.columns, + numResults: ctx.input.numResults, + filtersJson: ctx.input.filtersJson + }); + return { + output: { queryResult, indexName: ctx.input.indexName, success: true }, + message: `Queried vector search index **${ctx.input.indexName}**.` + }; + } + } + }) + .build(); diff --git a/integrations/databricks/src/tools/manage-warehouse.ts b/integrations/databricks/src/tools/manage-warehouse.ts index bb765425b4..4233699779 100644 --- a/integrations/databricks/src/tools/manage-warehouse.ts +++ b/integrations/databricks/src/tools/manage-warehouse.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageWarehouse = SlateTool.create(spec, { @@ -47,7 +48,7 @@ export let manageWarehouse = SlateTool.create(spec, { if (action === 'create') { if (!ctx.input.name || !ctx.input.clusterSize) { - throw new Error('name and clusterSize are required for create'); + throw databricksServiceError('name and clusterSize are required for create'); } let result = await client.createWarehouse({ name: ctx.input.name, @@ -64,7 +65,7 @@ export let manageWarehouse = SlateTool.create(spec, { }; } - if (!warehouseId) throw new Error('warehouseId is required for this action'); + if (!warehouseId) throw databricksServiceError('warehouseId is required for this action'); switch (action) { case 'start': diff --git a/integrations/databricks/src/tools/run-job.ts b/integrations/databricks/src/tools/run-job.ts index 08ab07611a..d06df1f86a 100644 --- a/integrations/databricks/src/tools/run-job.ts +++ b/integrations/databricks/src/tools/run-job.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DatabricksClient } from '../lib/client'; +import { databricksServiceError } from '../lib/errors'; import { spec } from '../spec'; export let runJob = SlateTool.create(spec, { @@ -37,7 +38,7 @@ export let runJob = SlateTool.create(spec, { }); if (ctx.input.action === 'cancel') { - if (!ctx.input.runId) throw new Error('runId is required for cancel'); + if (!ctx.input.runId) throw databricksServiceError('runId is required for cancel'); await client.cancelJobRun(ctx.input.runId); return { output: { runId: ctx.input.runId }, @@ -45,7 +46,7 @@ export let runJob = SlateTool.create(spec, { }; } - if (!ctx.input.jobId) throw new Error('jobId is required for run_now'); + if (!ctx.input.jobId) throw databricksServiceError('jobId is required for run_now'); let result = await client.runJobNow(ctx.input.jobId, { notebookParams: ctx.input.notebookParams as Record | undefined, diff --git a/integrations/databricks/vitest.config.ts b/integrations/databricks/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/databricks/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/datadog/README.md b/integrations/datadog/README.md index 340cb00225..d380aca8b5 100644 --- a/integrations/datadog/README.md +++ b/integrations/datadog/README.md @@ -1,21 +1,69 @@ # Datadog -Monitor infrastructure, applications, and services across cloud environments. Submit and query metrics, create and manage monitors and alerts, build dashboards, search and analyze logs, manage incidents, track SLOs, run synthetic tests, and configure integrations with cloud providers. Create on-call schedules, automate workflows, manage users and roles, handle security monitoring and detection rules, submit DORA metrics, and retrieve usage and cost data. Supports outbound webhooks for alert notifications. +Monitor infrastructure, applications, and services across cloud environments. Submit, discover, and query metrics; create and manage monitors and alerts; build and clean up dashboards; search and analyze logs; manage incidents; track SLOs; run synthetic tests; inspect users and hosts; and schedule or cancel monitor downtimes. ## Tools +### Cancel Downtime + +Cancel a Datadog downtime by ID to resume normal monitor notifications for its target scope. + +### Delete Dashboard + +Delete a Datadog dashboard by ID. Use this to clean up dashboards that were created for temporary investigations or automation runs. + +### Delete Incident + +Delete a Datadog incident by ID. This is destructive and should be used only for incidents created by automation or explicit cleanup. + ### Delete Monitor Delete a Datadog monitor by its ID. Optionally force-delete monitors that are referenced by other resources. +### Delete SLO + +Delete a Datadog Service Level Objective by ID. Use this to clean up temporary or obsolete SLOs. + ### Get Dashboard Get full details of a specific Datadog dashboard by ID, including all widget definitions and template variables. +### Get Downtime + +Get details for a Datadog downtime by ID, including scope, status, monitor target, schedule, and notification settings. + +### Get Event + +Get a specific Datadog event by ID, including its title, text, tags, host, priority, and alert type. + +### Get Incident + +Get details for a Datadog incident by ID, including title, severity, state, customer impact, timestamps, and fields. + +### Get Metric Metadata + +Get Datadog metadata for a metric, including type, unit, per-unit, description, integration, and StatsD interval when available. + +### Get Monitor + +Get full details for a Datadog monitor by ID, including its query, options, tags, and current state. + +### Get SLO + +Get details for a Datadog Service Level Objective by ID, including thresholds, tags, monitors, and metric queries. + +### List Active Metrics + +List metric names actively reporting to Datadog since a Unix timestamp. Use this to discover metrics before querying or building monitors. + ### List Dashboards List all Datadog dashboards in the organization. Returns a summary of each dashboard including title, layout type, and author. +### List Downtimes + +List Datadog monitor downtimes. Use this to inspect active or scheduled notification suppression windows. + ### List Events List events from the Datadog event stream within a time range. Filter events by priority, source, or tags. @@ -74,7 +122,7 @@ Post an event to the Datadog event stream. Events represent deployments, alerts, ### Query Metrics -Query timeseries metric data from Datadog. Retrieve metric values over a specified time range using Datadog's query language. Use metric queries like \ +Query timeseries metric data from Datadog. Retrieve metric values over a specified time range using Datadog's query language. ### Schedule Downtime @@ -82,7 +130,7 @@ Schedule a downtime to temporarily mute monitoring notifications. Target specifi ### Search Logs -Search and retrieve logs from Datadog using query syntax. Filter logs by time range, service, status, and custom attributes. Uses the Datadog log search query language, e.g. \ +Search and retrieve logs from Datadog using query syntax. Filter logs by time range, service, status, and custom attributes. ### Submit Logs diff --git a/integrations/datadog/package.json b/integrations/datadog/package.json index cbbb16398a..9ce0d242c4 100644 --- a/integrations/datadog/package.json +++ b/integrations/datadog/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/datadog/slate.json b/integrations/datadog/slate.json index 3f54f5eb67..ae60d7983f 100644 --- a/integrations/datadog/slate.json +++ b/integrations/datadog/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/datadog", - "description": "Monitor infrastructure, applications, and services across cloud environments. Submit and query metrics, create and manage monitors and alerts, build dashboards, search and analyze logs, manage incidents, track SLOs, run synthetic tests, and configure integrations with cloud providers. Create on-call schedules, automate workflows, manage users and roles, handle security monitoring and detection rules, submit DORA metrics, and retrieve usage and cost data. Supports outbound webhooks for alert notifications.", + "description": "Monitor infrastructure, applications, and services across cloud environments. Submit, discover, and query metrics; create and manage monitors and alerts; build and clean up dashboards; search and analyze logs; manage incidents; track SLOs; run synthetic tests; inspect users and hosts; and schedule or cancel monitor downtimes.", "categories": ["apis-and-http-requests", "security"], "skills": [ "submit and query metrics", @@ -10,9 +10,8 @@ "manage incidents", "run synthetic tests", "track service level objectives", - "configure cloud integrations", - "manage on-call schedules", - "automate workflows" + "inspect users and hosts", + "schedule and cancel downtimes" ], "logoUrl": "https://provider-logos.metorial-cdn.com/datadog-logo.png" } diff --git a/integrations/datadog/src/auth.ts b/integrations/datadog/src/auth.ts index 91ce631bb8..a7a6f60601 100644 --- a/integrations/datadog/src/auth.ts +++ b/integrations/datadog/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { datadogApiError, datadogServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -144,6 +145,7 @@ export let auth = SlateAuth.create() 'us5.datadoghq.com', 'datadoghq.eu', 'ap1.datadoghq.com', + 'ap2.datadoghq.com', 'ddog-gov.com' ]) .default('datadoghq.com') @@ -170,19 +172,24 @@ export let auth = SlateAuth.create() let site = ctx.input.site || 'datadoghq.com'; let http = createAxios({ baseURL: `https://api.${site}` }); - let response = await http.post( - '/oauth2/v1/token', - new URLSearchParams({ - grant_type: 'authorization_code', - code: ctx.code, - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - redirect_uri: ctx.redirectUri - }).toString(), - { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - } - ); + let response: any; + try { + response = await http.post( + '/oauth2/v1/token', + new URLSearchParams({ + grant_type: 'authorization_code', + code: ctx.code, + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + redirect_uri: ctx.redirectUri + }).toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + } + ); + } catch (error) { + throw datadogApiError(error, 'OAuth token exchange'); + } let data = response.data; let expiresAt = data.expires_in @@ -204,18 +211,29 @@ export let auth = SlateAuth.create() let site = ctx.input.site || 'datadoghq.com'; let http = createAxios({ baseURL: `https://api.${site}` }); - let response = await http.post( - '/oauth2/v1/token', - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: ctx.output.refreshToken || '', - client_id: ctx.clientId, - client_secret: ctx.clientSecret - }).toString(), - { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - } - ); + if (!ctx.output.refreshToken) { + throw datadogServiceError( + 'Datadog OAuth token refresh requires a saved refresh token. Re-authorize the Datadog connection.' + ); + } + + let response: any; + try { + response = await http.post( + '/oauth2/v1/token', + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }).toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + } + ); + } catch (error) { + throw datadogApiError(error, 'OAuth token refresh'); + } let data = response.data; let expiresAt = data.expires_in @@ -240,7 +258,19 @@ export let auth = SlateAuth.create() inputSchema: z.object({ apiKey: z.string().describe('Datadog API key (DD-API-KEY)'), - appKey: z.string().describe('Datadog Application key (DD-APPLICATION-KEY)') + appKey: z.string().describe('Datadog Application key (DD-APPLICATION-KEY)'), + site: z + .enum([ + 'datadoghq.com', + 'us3.datadoghq.com', + 'us5.datadoghq.com', + 'datadoghq.eu', + 'ap1.datadoghq.com', + 'ap2.datadoghq.com', + 'ddog-gov.com' + ]) + .default('datadoghq.com') + .describe('Datadog site/region for validating the keys') }), getOutput: async ctx => { @@ -261,16 +291,22 @@ export let auth = SlateAuth.create() appKey?: string; authMethod: 'oauth' | 'apikey'; }; - input: { apiKey: string; appKey: string }; + input: { apiKey: string; appKey: string; site?: string }; }) => { - let http = createAxios({ baseURL: 'https://api.datadoghq.com' }); + let site = ctx.input.site || 'datadoghq.com'; + let http = createAxios({ baseURL: `https://api.${site}` }); - let response = await http.get('/api/v1/validate', { - headers: { - 'DD-API-KEY': ctx.output.apiKey || '', - 'DD-APPLICATION-KEY': ctx.output.appKey || '' - } - }); + let response: any; + try { + response = await http.get('/api/v1/validate', { + headers: { + 'DD-API-KEY': ctx.output.apiKey || '', + 'DD-APPLICATION-KEY': ctx.output.appKey || '' + } + }); + } catch (error) { + throw datadogApiError(error, 'key validation'); + } return { profile: { diff --git a/integrations/datadog/src/config.ts b/integrations/datadog/src/config.ts index e2e648e585..512c7d1053 100644 --- a/integrations/datadog/src/config.ts +++ b/integrations/datadog/src/config.ts @@ -10,6 +10,7 @@ export let config = SlateConfig.create( 'us5.datadoghq.com', 'datadoghq.eu', 'ap1.datadoghq.com', + 'ap2.datadoghq.com', 'ddog-gov.com' ]) .default('datadoghq.com') diff --git a/integrations/datadog/src/index.ts b/integrations/datadog/src/index.ts index 16d34c4453..5b87f61f3e 100644 --- a/integrations/datadog/src/index.ts +++ b/integrations/datadog/src/index.ts @@ -1,9 +1,21 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + cancelDowntime, + deleteDashboard, + deleteIncident, deleteMonitor, + deleteSlo, getDashboard, + getDowntime, + getEvent, + getIncident, + getMetricMetadata, + getMonitor, + getSlo, + listActiveMetrics, listDashboards, + listDowntimes, listEvents, listHosts, listIncidents, @@ -37,27 +49,39 @@ export let provider = Slate.create({ tools: [ queryMetrics, submitMetrics, + listActiveMetrics, + getMetricMetadata, manageMonitor, listMonitors, + getMonitor, deleteMonitor, muteMonitor, manageDashboard, listDashboards, getDashboard, + deleteDashboard, postEvent, listEvents, + getEvent, manageIncident, listIncidents, + getIncident, + deleteIncident, searchLogs, submitLogs, manageSlo, listSlos, + getSlo, + deleteSlo, manageSynthetics, listSyntheticsTests, triggerSynthetics, listUsers, listHosts, - scheduleDowntime + scheduleDowntime, + listDowntimes, + getDowntime, + cancelDowntime ], triggers: [inboundWebhook, monitorAlertTrigger, newEventTrigger, incidentUpdateTrigger] }); diff --git a/integrations/datadog/src/lib/client.ts b/integrations/datadog/src/lib/client.ts index 2135446375..7cb4e2be73 100644 --- a/integrations/datadog/src/lib/client.ts +++ b/integrations/datadog/src/lib/client.ts @@ -1,21 +1,34 @@ import { createAxios } from 'slates'; +import { datadogApiError, datadogServiceError } from './errors'; import type { Dashboard, DatadogAuthConfig, LogSearchParams, + MetricSeriesInput, MetricsQueryParams, Monitor, MonitorOptions } from './types'; export class DatadogClient { - private http; + private http: ReturnType; + private logIntakeHttp: ReturnType; private authConfig: DatadogAuthConfig; constructor(authConfig: DatadogAuthConfig) { this.authConfig = authConfig; let baseURL = `https://api.${authConfig.site}`; this.http = createAxios({ baseURL }); + this.logIntakeHttp = createAxios({ + baseURL: `https://http-intake.logs.${authConfig.site}` + }); + + for (let http of [this.http, this.logIntakeHttp]) { + http.interceptors.response.use( + response => response, + error => Promise.reject(datadogApiError(error)) + ); + } } private getHeaders(): Record { @@ -32,6 +45,23 @@ export class DatadogClient { }; } + private getApiKeyOnlyHeaders(): Record { + let apiKey = + this.authConfig.apiKey ?? + (this.authConfig.authMethod === 'apikey' ? this.authConfig.token : undefined); + + if (!apiKey) { + throw datadogServiceError( + 'This Datadog endpoint requires API key authentication. Reconnect Datadog with API Key + Application Key credentials.' + ); + } + + return { + 'DD-API-KEY': apiKey, + 'Content-Type': 'application/json' + }; + } + // ─── Metrics ──────────────────────────────────────────── async queryMetrics(params: MetricsQueryParams): Promise { @@ -65,22 +95,21 @@ export class DatadogClient { return response.data; } - async submitMetrics( - series: Array<{ - metric: string; - type?: number; - points: [number, number][]; - host?: string; - tags?: string[]; - }> - ): Promise { - let response = await this.http.post( - '/api/v1/series', - { series }, - { - headers: this.getHeaders() - } - ); + async submitMetrics(series: MetricSeriesInput[]): Promise { + let body = { + series: series.map(s => ({ + metric: s.metric, + type: s.type, + points: s.points.map(([timestamp, value]) => ({ timestamp, value })), + tags: s.tags, + resources: s.resources ?? (s.host ? [{ name: s.host, type: 'host' }] : undefined), + unit: s.unit + })) + }; + + let response = await this.http.post('/api/v2/series', body, { + headers: this.getApiKeyOnlyHeaders() + }); return response.data; } @@ -468,8 +497,8 @@ export class DatadogClient { service?: string; }> ): Promise { - let response = await this.http.post('/api/v2/logs', logs, { - headers: this.getHeaders() + let response = await this.logIntakeHttp.post('/api/v2/logs', logs, { + headers: this.getApiKeyOnlyHeaders() }); return response.data; } @@ -657,8 +686,20 @@ export class DatadogClient { // ─── Downtime ────────────────────────────────────────── - async listDowntimes(): Promise { + async listDowntimes(params?: { pageLimit?: number; pageOffset?: number }): Promise { + let queryParams: Record = {}; + if (params?.pageLimit !== undefined) queryParams['page[limit]'] = params.pageLimit; + if (params?.pageOffset !== undefined) queryParams['page[offset]'] = params.pageOffset; + let response = await this.http.get('/api/v2/downtime', { + headers: this.getHeaders(), + params: queryParams + }); + return response.data; + } + + async getDowntime(downtimeId: string): Promise { + let response = await this.http.get(`/api/v2/downtime/${encodeURIComponent(downtimeId)}`, { headers: this.getHeaders() }); return response.data; @@ -709,6 +750,12 @@ export class DatadogClient { return response.data; } + async cancelDowntime(downtimeId: string): Promise { + await this.http.delete(`/api/v2/downtime/${encodeURIComponent(downtimeId)}`, { + headers: this.getHeaders() + }); + } + // ─── Webhooks Integration ────────────────────────────── async createWebhook(webhook: { diff --git a/integrations/datadog/src/lib/errors.ts b/integrations/datadog/src/lib/errors.ts new file mode 100644 index 0000000000..b1347477a7 --- /dev/null +++ b/integrations/datadog/src/lib/errors.ts @@ -0,0 +1,94 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let collectDatadogMessages = (value: unknown, messages: string[]) => { + if (!isRecord(value)) { + addMessage(messages, value); + return; + } + + for (let key of ['message', 'error', 'detail', 'title']) { + addMessage(messages, value[key]); + } + + if (Array.isArray(value.errors)) { + for (let error of value.errors) { + collectDatadogMessages(error, messages); + } + } else if (isRecord(value.errors)) { + collectDatadogMessages(value.errors, messages); + } +}; + +let extractDatadogMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectDatadogMessages(response?.data, messages); + + if (isRecord(error)) { + collectDatadogMessages(error.data, messages); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getDatadogErrorStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let datadogServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let datadogApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getDatadogErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = datadogServiceError( + `Datadog API ${operation} failed: ${statusLabel}${extractDatadogMessage(error)}` + ); + serviceError.data.reason = 'datadog_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/datadog/src/lib/types.ts b/integrations/datadog/src/lib/types.ts index 386d3243aa..0d4985ee7d 100644 --- a/integrations/datadog/src/lib/types.ts +++ b/integrations/datadog/src/lib/types.ts @@ -6,6 +6,21 @@ export interface DatadogAuthConfig { site: string; } +export interface MetricResource { + name: string; + type: string; +} + +export interface MetricSeriesInput { + metric: string; + type?: number; + points: [number, number][]; + host?: string; + tags?: string[]; + resources?: MetricResource[]; + unit?: string; +} + export interface MonitorOptions { thresholds?: { critical?: number; diff --git a/integrations/datadog/src/tools.schema.test.ts b/integrations/datadog/src/tools.schema.test.ts new file mode 100644 index 0000000000..5205d497c0 --- /dev/null +++ b/integrations/datadog/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Datadog tool input schemas', provider.actions); diff --git a/integrations/datadog/src/tools/cancel-downtime.ts b/integrations/datadog/src/tools/cancel-downtime.ts new file mode 100644 index 0000000000..0cd15628d2 --- /dev/null +++ b/integrations/datadog/src/tools/cancel-downtime.ts @@ -0,0 +1,34 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let cancelDowntime = SlateTool.create(spec, { + name: 'Cancel Downtime', + key: 'cancel_downtime', + description: `Cancel a Datadog downtime by ID to resume normal monitor notifications for its target scope.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + downtimeId: z.string().describe('Downtime ID to cancel') + }) + ) + .output( + z.object({ + canceledDowntimeId: z.string().describe('ID of the canceled downtime') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + await client.cancelDowntime(ctx.input.downtimeId); + + return { + output: { canceledDowntimeId: ctx.input.downtimeId }, + message: `Canceled downtime **${ctx.input.downtimeId}**` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/delete-dashboard.ts b/integrations/datadog/src/tools/delete-dashboard.ts new file mode 100644 index 0000000000..6179e25984 --- /dev/null +++ b/integrations/datadog/src/tools/delete-dashboard.ts @@ -0,0 +1,34 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let deleteDashboard = SlateTool.create(spec, { + name: 'Delete Dashboard', + key: 'delete_dashboard', + description: `Delete a Datadog dashboard by ID. Use this to clean up dashboards that were created for temporary investigations or automation runs.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + dashboardId: z.string().describe('Dashboard ID to delete') + }) + ) + .output( + z.object({ + deletedDashboardId: z.string().describe('ID of the deleted dashboard') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + await client.deleteDashboard(ctx.input.dashboardId); + + return { + output: { deletedDashboardId: ctx.input.dashboardId }, + message: `Deleted dashboard **${ctx.input.dashboardId}**` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/delete-incident.ts b/integrations/datadog/src/tools/delete-incident.ts new file mode 100644 index 0000000000..2922136b27 --- /dev/null +++ b/integrations/datadog/src/tools/delete-incident.ts @@ -0,0 +1,34 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let deleteIncident = SlateTool.create(spec, { + name: 'Delete Incident', + key: 'delete_incident', + description: `Delete a Datadog incident by ID. This is destructive and should be used only for incidents created by automation or explicit cleanup.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + incidentId: z.string().describe('Incident ID to delete') + }) + ) + .output( + z.object({ + deletedIncidentId: z.string().describe('ID of the deleted incident') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + await client.deleteIncident(ctx.input.incidentId); + + return { + output: { deletedIncidentId: ctx.input.incidentId }, + message: `Deleted incident **${ctx.input.incidentId}**` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/delete-slo.ts b/integrations/datadog/src/tools/delete-slo.ts new file mode 100644 index 0000000000..d8d7cfee47 --- /dev/null +++ b/integrations/datadog/src/tools/delete-slo.ts @@ -0,0 +1,34 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let deleteSlo = SlateTool.create(spec, { + name: 'Delete SLO', + key: 'delete_slo', + description: `Delete a Datadog Service Level Objective by ID. Use this to clean up temporary or obsolete SLOs.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + sloId: z.string().describe('SLO ID to delete') + }) + ) + .output( + z.object({ + deletedSloId: z.string().describe('ID of the deleted SLO') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + await client.deleteSLO(ctx.input.sloId); + + return { + output: { deletedSloId: ctx.input.sloId }, + message: `Deleted SLO **${ctx.input.sloId}**` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/get-downtime.ts b/integrations/datadog/src/tools/get-downtime.ts new file mode 100644 index 0000000000..177855cd30 --- /dev/null +++ b/integrations/datadog/src/tools/get-downtime.ts @@ -0,0 +1,52 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getDowntime = SlateTool.create(spec, { + name: 'Get Downtime', + key: 'get_downtime', + description: `Get details for a Datadog downtime by ID, including scope, status, monitor target, schedule, and notification settings.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + downtimeId: z.string().describe('Downtime ID to retrieve') + }) + ) + .output( + z.object({ + downtimeId: z.string().describe('Downtime ID'), + status: z.string().optional().describe('Downtime status'), + scope: z.string().optional().describe('Downtime scope'), + message: z.string().optional().describe('Downtime message'), + monitorId: z.number().optional().describe('Target monitor ID'), + monitorTags: z.array(z.string()).optional().describe('Target monitor tags'), + schedule: z.any().optional().describe('Raw downtime schedule'), + notifyEndStates: z.array(z.string()).optional().describe('States that notify at end'), + notifyEndTypes: z.array(z.string()).optional().describe('End notification types') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + let result = await client.getDowntime(ctx.input.downtimeId); + let downtime = result.data || result; + + return { + output: { + downtimeId: downtime.id || ctx.input.downtimeId, + status: downtime.attributes?.status, + scope: downtime.attributes?.scope, + message: downtime.attributes?.message, + monitorId: downtime.attributes?.monitor_identifier?.monitor_id, + monitorTags: downtime.attributes?.monitor_identifier?.monitor_tags, + schedule: downtime.attributes?.schedule, + notifyEndStates: downtime.attributes?.notify_end_states, + notifyEndTypes: downtime.attributes?.notify_end_types + }, + message: `Retrieved downtime **${downtime.id || ctx.input.downtimeId}**` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/get-event.ts b/integrations/datadog/src/tools/get-event.ts new file mode 100644 index 0000000000..e68b4aecf6 --- /dev/null +++ b/integrations/datadog/src/tools/get-event.ts @@ -0,0 +1,52 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getEvent = SlateTool.create(spec, { + name: 'Get Event', + key: 'get_event', + description: `Get a specific Datadog event by ID, including its title, text, tags, host, priority, and alert type.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + eventId: z.number().describe('Event ID to retrieve') + }) + ) + .output( + z.object({ + eventId: z.number().describe('Event ID'), + title: z.string().optional().describe('Event title'), + text: z.string().optional().describe('Event body text'), + dateHappened: z.number().optional().describe('Unix timestamp when the event happened'), + priority: z.string().optional().describe('Event priority'), + host: z.string().optional().describe('Associated host'), + tags: z.array(z.string()).optional().describe('Event tags'), + alertType: z.string().optional().describe('Event alert type'), + sourceTypeName: z.string().optional().describe('Event source type') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + let result = await client.getEvent(ctx.input.eventId); + let event = result.event || result; + + return { + output: { + eventId: event.id || ctx.input.eventId, + title: event.title, + text: event.text, + dateHappened: event.date_happened, + priority: event.priority, + host: event.host, + tags: event.tags, + alertType: event.alert_type, + sourceTypeName: event.source_type_name + }, + message: `Retrieved event **${event.title || ctx.input.eventId}**` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/get-incident.ts b/integrations/datadog/src/tools/get-incident.ts new file mode 100644 index 0000000000..fbc3898bdd --- /dev/null +++ b/integrations/datadog/src/tools/get-incident.ts @@ -0,0 +1,52 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getIncident = SlateTool.create(spec, { + name: 'Get Incident', + key: 'get_incident', + description: `Get details for a Datadog incident by ID, including title, severity, state, customer impact, timestamps, and fields.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + incidentId: z.string().describe('Incident ID to retrieve') + }) + ) + .output( + z.object({ + incidentId: z.string().describe('Incident ID'), + title: z.string().optional().describe('Incident title'), + customerImpacted: z.boolean().optional().describe('Whether customers are impacted'), + severity: z.string().optional().describe('Incident severity'), + state: z.string().optional().describe('Incident state'), + created: z.string().optional().describe('Creation timestamp'), + modified: z.string().optional().describe('Last modification timestamp'), + resolved: z.string().optional().describe('Resolution timestamp'), + fields: z.record(z.string(), z.any()).optional().describe('Incident custom fields') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + let result = await client.getIncident(ctx.input.incidentId); + let incident = result.data || result; + + return { + output: { + incidentId: incident.id || ctx.input.incidentId, + title: incident.attributes?.title, + customerImpacted: incident.attributes?.customer_impacted, + severity: incident.attributes?.severity, + state: incident.attributes?.state, + created: incident.attributes?.created, + modified: incident.attributes?.modified, + resolved: incident.attributes?.resolved, + fields: incident.attributes?.fields + }, + message: `Retrieved incident **${incident.attributes?.title || ctx.input.incidentId}**` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/get-metric-metadata.ts b/integrations/datadog/src/tools/get-metric-metadata.ts new file mode 100644 index 0000000000..7f7698790c --- /dev/null +++ b/integrations/datadog/src/tools/get-metric-metadata.ts @@ -0,0 +1,49 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getMetricMetadata = SlateTool.create(spec, { + name: 'Get Metric Metadata', + key: 'get_metric_metadata', + description: `Get Datadog metadata for a metric, including type, unit, per-unit, description, integration, and StatsD interval when available.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + metricName: z.string().describe('Metric name to retrieve metadata for') + }) + ) + .output( + z.object({ + metricName: z.string().describe('Metric name'), + type: z.string().optional().describe('Metric type'), + unit: z.string().optional().describe('Metric unit'), + perUnit: z.string().optional().describe('Metric per-unit'), + description: z.string().optional().describe('Metric description'), + shortName: z.string().optional().describe('Metric short name'), + integration: z.string().optional().describe('Integration that owns the metric'), + statsdInterval: z.number().optional().describe('StatsD flush interval') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + let metadata = await client.getMetricMetadata(ctx.input.metricName); + + return { + output: { + metricName: ctx.input.metricName, + type: metadata.type, + unit: metadata.unit, + perUnit: metadata.per_unit, + description: metadata.description, + shortName: metadata.short_name, + integration: metadata.integration, + statsdInterval: metadata.statsd_interval + }, + message: `Retrieved metadata for metric **${ctx.input.metricName}**` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/get-monitor.ts b/integrations/datadog/src/tools/get-monitor.ts new file mode 100644 index 0000000000..30dd1f0290 --- /dev/null +++ b/integrations/datadog/src/tools/get-monitor.ts @@ -0,0 +1,57 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getMonitor = SlateTool.create(spec, { + name: 'Get Monitor', + key: 'get_monitor', + description: `Get full details for a Datadog monitor by ID, including its query, options, tags, and current state.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + monitorId: z.number().describe('ID of the monitor to retrieve') + }) + ) + .output( + z.object({ + monitorId: z.number().describe('ID of the monitor'), + name: z.string().describe('Monitor name'), + type: z.string().describe('Monitor type'), + query: z.string().describe('Monitor query'), + overallState: z.string().optional().describe('Current state of the monitor'), + message: z.string().optional().describe('Notification message'), + tags: z.array(z.string()).optional().describe('Monitor tags'), + priority: z.number().optional().describe('Monitor priority'), + options: z.any().optional().describe('Raw Datadog monitor options'), + created: z.string().optional().describe('Creation timestamp'), + modified: z.string().optional().describe('Last modification timestamp'), + creatorName: z.string().optional().describe('Monitor creator name') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + let monitor = await client.getMonitor(ctx.input.monitorId); + + return { + output: { + monitorId: monitor.id || ctx.input.monitorId, + name: monitor.name, + type: monitor.type, + query: monitor.query, + overallState: monitor.overall_state, + message: monitor.message, + tags: monitor.tags, + priority: monitor.priority, + options: monitor.options, + created: monitor.created, + modified: monitor.modified, + creatorName: monitor.creator?.name + }, + message: `Retrieved monitor **${monitor.name}** (ID: ${monitor.id || ctx.input.monitorId})` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/get-slo.ts b/integrations/datadog/src/tools/get-slo.ts new file mode 100644 index 0000000000..8f24fc31c3 --- /dev/null +++ b/integrations/datadog/src/tools/get-slo.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getSlo = SlateTool.create(spec, { + name: 'Get SLO', + key: 'get_slo', + description: `Get details for a Datadog Service Level Objective by ID, including thresholds, tags, monitors, and metric queries.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + sloId: z.string().describe('SLO ID to retrieve') + }) + ) + .output( + z.object({ + sloId: z.string().describe('SLO ID'), + name: z.string().optional().describe('SLO name'), + type: z.string().optional().describe('SLO type'), + description: z.string().optional().describe('SLO description'), + tags: z.array(z.string()).optional().describe('SLO tags'), + thresholds: z.array(z.any()).optional().describe('SLO thresholds'), + monitorIds: z.array(z.number()).optional().describe('Monitor IDs for monitor SLOs'), + query: z.any().optional().describe('Metric SLO query definition'), + groups: z.array(z.string()).optional().describe('SLO groups'), + createdAt: z.number().optional().describe('Creation timestamp'), + modifiedAt: z.number().optional().describe('Last modification timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + let result = await client.getSLO(ctx.input.sloId); + let slo = result.data || result; + + return { + output: { + sloId: slo.id || ctx.input.sloId, + name: slo.name, + type: slo.type, + description: slo.description, + tags: slo.tags, + thresholds: slo.thresholds, + monitorIds: slo.monitor_ids, + query: slo.query, + groups: slo.groups, + createdAt: slo.created_at, + modifiedAt: slo.modified_at + }, + message: `Retrieved SLO **${slo.name || ctx.input.sloId}**` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/index.ts b/integrations/datadog/src/tools/index.ts index a79479f16d..a4ad5b9767 100644 --- a/integrations/datadog/src/tools/index.ts +++ b/integrations/datadog/src/tools/index.ts @@ -1,6 +1,18 @@ +export * from './cancel-downtime'; +export * from './delete-dashboard'; +export * from './delete-incident'; export * from './delete-monitor'; +export * from './delete-slo'; export * from './get-dashboard'; +export * from './get-downtime'; +export * from './get-event'; +export * from './get-incident'; +export * from './get-metric-metadata'; +export * from './get-monitor'; +export * from './get-slo'; +export * from './list-active-metrics'; export * from './list-dashboards'; +export * from './list-downtimes'; export * from './list-events'; export * from './list-hosts'; export * from './list-incidents'; diff --git a/integrations/datadog/src/tools/list-active-metrics.ts b/integrations/datadog/src/tools/list-active-metrics.ts new file mode 100644 index 0000000000..3594811cf5 --- /dev/null +++ b/integrations/datadog/src/tools/list-active-metrics.ts @@ -0,0 +1,47 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listActiveMetrics = SlateTool.create(spec, { + name: 'List Active Metrics', + key: 'list_active_metrics', + description: `List metric names actively reporting to Datadog since a Unix timestamp. Use this to discover metrics before querying or building monitors.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + from: z.number().describe('Unix timestamp in seconds to list active metrics from'), + host: z.string().optional().describe('Filter metrics by host'), + tagFilter: z + .string() + .optional() + .describe('Filter metrics by tag query, such as "env:production"') + }) + ) + .output( + z.object({ + metrics: z.array(z.string()).describe('Active metric names'), + count: z.number().describe('Number of metric names returned') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + let result = await client.listActiveMetrics( + ctx.input.from, + ctx.input.host, + ctx.input.tagFilter + ); + let metrics = result.metrics || []; + + return { + output: { + metrics, + count: metrics.length + }, + message: `Found **${metrics.length}** active metrics` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/list-downtimes.ts b/integrations/datadog/src/tools/list-downtimes.ts new file mode 100644 index 0000000000..1bf4220425 --- /dev/null +++ b/integrations/datadog/src/tools/list-downtimes.ts @@ -0,0 +1,62 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listDowntimes = SlateTool.create(spec, { + name: 'List Downtimes', + key: 'list_downtimes', + description: `List Datadog monitor downtimes. Use this to inspect active or scheduled notification suppression windows.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + pageLimit: z.number().optional().describe('Maximum number of downtimes to return'), + pageOffset: z.number().optional().describe('Pagination offset') + }) + ) + .output( + z.object({ + downtimes: z + .array( + z.object({ + downtimeId: z.string(), + status: z.string().optional(), + scope: z.string().optional(), + message: z.string().optional(), + monitorId: z.number().optional(), + monitorTags: z.array(z.string()).optional(), + created: z.string().optional(), + modified: z.string().optional() + }) + ) + .describe('Datadog downtimes'), + nextOffset: z.number().optional().describe('Next pagination offset') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + let result = await client.listDowntimes(ctx.input); + + let downtimes = (result.data || []).map((downtime: any) => ({ + downtimeId: downtime.id, + status: downtime.attributes?.status, + scope: downtime.attributes?.scope, + message: downtime.attributes?.message, + monitorId: downtime.attributes?.monitor_identifier?.monitor_id, + monitorTags: downtime.attributes?.monitor_identifier?.monitor_tags, + created: downtime.attributes?.created, + modified: downtime.attributes?.modified + })); + + return { + output: { + downtimes, + nextOffset: result.meta?.pagination?.next_offset + }, + message: `Found **${downtimes.length}** downtimes` + }; + }) + .build(); diff --git a/integrations/datadog/src/tools/manage-dashboard.ts b/integrations/datadog/src/tools/manage-dashboard.ts index 0464e4d344..d734dfd333 100644 --- a/integrations/datadog/src/tools/manage-dashboard.ts +++ b/integrations/datadog/src/tools/manage-dashboard.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { datadogServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -68,7 +69,7 @@ export let manageDashboard = SlateTool.create(spec, { dashboard = await client.updateDashboard(dashboardId, data); } else { if (!data.title || !data.layoutType || !data.widgets) { - throw new Error( + throw datadogServiceError( 'title, layoutType, and widgets are required when creating a new dashboard.' ); } diff --git a/integrations/datadog/src/tools/manage-incident.ts b/integrations/datadog/src/tools/manage-incident.ts index fefdf99cc2..5f50e5b579 100644 --- a/integrations/datadog/src/tools/manage-incident.ts +++ b/integrations/datadog/src/tools/manage-incident.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { datadogServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -64,7 +65,7 @@ export let manageIncident = SlateTool.create(spec, { result = await client.updateIncident(incidentId, data); } else { if (!data.title) { - throw new Error('title is required when creating a new incident.'); + throw datadogServiceError('title is required when creating a new incident.'); } result = await client.createIncident({ title: data.title, diff --git a/integrations/datadog/src/tools/manage-monitor.ts b/integrations/datadog/src/tools/manage-monitor.ts index ed1288b92c..38dd324b1f 100644 --- a/integrations/datadog/src/tools/manage-monitor.ts +++ b/integrations/datadog/src/tools/manage-monitor.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { datadogServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -126,7 +127,9 @@ Supports metric alerts, log alerts, anomaly detection, forecast, outlier, APM, a }); } else { if (!data.name || !data.type || !data.query) { - throw new Error('name, type, and query are required when creating a new monitor.'); + throw datadogServiceError( + 'name, type, and query are required when creating a new monitor.' + ); } monitor = await client.createMonitor({ name: data.name, diff --git a/integrations/datadog/src/tools/manage-slo.ts b/integrations/datadog/src/tools/manage-slo.ts index 72f766fcc3..c65d4ae99a 100644 --- a/integrations/datadog/src/tools/manage-slo.ts +++ b/integrations/datadog/src/tools/manage-slo.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { datadogServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -95,7 +96,9 @@ export let manageSlo = SlateTool.create(spec, { }); } else { if (!data.name || !data.type || !data.thresholds) { - throw new Error('name, type, and thresholds are required when creating a new SLO.'); + throw datadogServiceError( + 'name, type, and thresholds are required when creating a new SLO.' + ); } result = await client.createSLO({ name: data.name, diff --git a/integrations/datadog/src/tools/schedule-downtime.ts b/integrations/datadog/src/tools/schedule-downtime.ts index 79b21b99ab..6db9204ce5 100644 --- a/integrations/datadog/src/tools/schedule-downtime.ts +++ b/integrations/datadog/src/tools/schedule-downtime.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { datadogServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -56,6 +57,12 @@ export let scheduleDowntime = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = createClient(ctx.auth, ctx.config); + if (!ctx.input.monitorId && !ctx.input.monitorTags?.length) { + throw datadogServiceError( + 'Provide either monitorId or monitorTags when scheduling Datadog downtime.' + ); + } + let schedule: any; if (ctx.input.start || ctx.input.end) { schedule = {}; diff --git a/integrations/datadog/src/tools/submit-metrics.ts b/integrations/datadog/src/tools/submit-metrics.ts index e5feb4fe63..f60cb61922 100644 --- a/integrations/datadog/src/tools/submit-metrics.ts +++ b/integrations/datadog/src/tools/submit-metrics.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import type { MetricSeriesInput } from '../lib/types'; import { spec } from '../spec'; export let submitMetrics = SlateTool.create(spec, { @@ -34,7 +35,17 @@ export let submitMetrics = SlateTool.create(spec, { tags: z .array(z.string()) .optional() - .describe('Tags to associate, e.g. ["env:production", "service:web"]') + .describe('Tags to associate, e.g. ["env:production", "service:web"]'), + resources: z + .array( + z.object({ + name: z.string().describe('Resource name, such as a host name'), + type: z.string().describe('Resource type, such as "host"') + }) + ) + .optional() + .describe('Datadog v2 metric resources to associate with the metric'), + unit: z.string().optional().describe('Metric unit, such as "request" or "second"') }) ) .describe('One or more metric series to submit') @@ -42,24 +53,18 @@ export let submitMetrics = SlateTool.create(spec, { ) .output( z.object({ - status: z.string().describe('Submission status from Datadog') + status: z.string().describe('Submission status from Datadog'), + errors: z.array(z.string()).optional().describe('Datadog intake errors, if any') }) ) .handleInvocation(async ctx => { let client = createClient(ctx.auth, ctx.config); - let result = await client.submitMetrics( - ctx.input.series as Array<{ - metric: string; - type?: number; - points: [number, number][]; - host?: string; - tags?: string[]; - }> - ); + let result = await client.submitMetrics(ctx.input.series as MetricSeriesInput[]); return { output: { - status: result.status || 'ok' + status: 'accepted', + errors: result.errors }, message: `Submitted **${ctx.input.series.length}** metric series to Datadog` }; diff --git a/integrations/datadog/vitest.config.ts b/integrations/datadog/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/datadog/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/dataforseo/package.json b/integrations/dataforseo/package.json index 35194d1a3d..3118262f56 100644 --- a/integrations/dataforseo/package.json +++ b/integrations/dataforseo/package.json @@ -10,7 +10,7 @@ "dependencies": { "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { diff --git a/integrations/dataforseo/src/tools.schema.test.ts b/integrations/dataforseo/src/tools.schema.test.ts index c25eebb6d0..3a6d2c6f47 100644 --- a/integrations/dataforseo/src/tools.schema.test.ts +++ b/integrations/dataforseo/src/tools.schema.test.ts @@ -1,18 +1,4 @@ -import { describe, expect, it } from 'vitest'; -import { z } from 'zod'; +import { describeMcpCompatibleToolSchemas } from '@slates/test'; import { provider } from './index'; -let tools = provider.actions.filter(action => action.type === 'tool'); - -describe('DataForSEO tool input schemas', () => { - it.each( - tools.map(tool => [tool.key, tool] as const) - )('%s uses an MCP-compatible top-level object schema', (_key, tool) => { - let jsonSchema = z.toJSONSchema(tool.inputSchema) as Record; - - expect(jsonSchema.type).toBe('object'); - expect(jsonSchema).not.toHaveProperty('oneOf'); - expect(jsonSchema).not.toHaveProperty('anyOf'); - expect(jsonSchema).not.toHaveProperty('allOf'); - }); -}); +describeMcpCompatibleToolSchemas('DataForSEO tool input schemas', provider.actions); diff --git a/integrations/deepgram/README.md b/integrations/deepgram/README.md index 3235160afa..3ef082b00f 100644 --- a/integrations/deepgram/README.md +++ b/integrations/deepgram/README.md @@ -1,25 +1,101 @@ # Deepgram -Transcribe pre-recorded and live streaming audio to text in 45+ languages with speaker diarization, smart formatting, and keyword boosting. Convert text to natural-sounding speech with 40+ voice options. Analyze transcripts for sentiment, topics, summaries, and intents. Build conversational voice agents with integrated STT, LLM reasoning, and TTS in a single session. Manage projects, API keys, members, billing, and usage. Discover available models and their metadata. Supports asynchronous processing via callbacks for both transcription and speech synthesis. +Use Deepgram REST APIs for pre-recorded speech-to-text, text-to-speech, text intelligence, model discovery, project administration, usage, request troubleshooting, and billing reporting. Supports asynchronous processing via callbacks for transcription, speech synthesis, and text analysis. ## Tools ### Analyze Text -Analyze text for intelligence insights including sentiment analysis, topic detection, intent detection, and summarization. Enable one or more analysis features to extract value from text content such as transcripts, articles, or conversations. +Analyze text for intelligence insights including sentiment analysis, topic detection, intent detection, custom topics or intents, and summarization. Enable one or more analysis features to extract value from text content such as transcripts, articles, or conversations. + +### Create API Key + +Create a new API key for a Deepgram project. The key value is only returned once at creation time. + +### Create Temporary Token + +Create a short-lived Deepgram JWT for client-side or temporary use with core voice APIs. + +### Delete API Key + +Permanently delete an API key from a Deepgram project. + +### Delete Project + +Permanently delete a Deepgram project. + +### Delete Project Invitation + +Delete a pending Deepgram project invitation by invitee email address. + +### Get Balance + +Get one Deepgram billing balance by ID. + +### Get Balances + +Get billing balance information for a Deepgram project. Returns available credits and balance details. + +### Get Billing Breakdown + +Get grouped billing metrics for a Deepgram project. + +### Get Billing Fields + +Get available billing filter fields for a Deepgram project. + +### Get Member Scopes + +Get the role/scopes for a specific Deepgram project member. + +### Get Project + +Get details of a specific Deepgram project including its name, company, and configuration. + +### Get Project Model + +Get metadata for a model available to a specific Deepgram project. + +### Get Project Request + +Get details for a single Deepgram request by request ID. ### Get Usage Get usage data for a Deepgram project. Filter by date range, API key, tag, method (sync/async/streaming), or model. Useful for monitoring API consumption and billing. -### List Models +### Get Usage Breakdown -Query available Deepgram models and their metadata. Useful for discovering which models are available for transcription or text-to-speech and what languages they support. +Get grouped usage metrics for a Deepgram project. + +### Get Usage Fields + +Get available usage breakdown fields for a Deepgram project and optional date range. + +### Get API Key + +Get metadata for a specific Deepgram API key. Deepgram does not return the secret key value after creation. ### List API Keys List all API keys for a Deepgram project. Returns key metadata including comments, scopes, tags, and expiration dates. Does not return the actual key values. +### List Project Invitations + +List pending invitations for a Deepgram project. + +### List Models + +Query available Deepgram models and their metadata. Useful for discovering which models are available for transcription or text-to-speech and what languages they support. + +### List Project Requests + +List individual Deepgram API requests for a project. Useful for request-level troubleshooting, audit trails, and correlating tagged calls with usage. + +### List Project Models + +List public and project-specific Deepgram models available to a project. + ### List Project Members List all members of a Deepgram project. Returns member details including name, email, and permission scopes. @@ -28,13 +104,33 @@ List all members of a Deepgram project. Returns member details including name, e List all Deepgram projects accessible with the current API key. Returns project IDs, names, and company information. +### List Purchases + +List purchase/order records for a Deepgram project. + +### Remove Project Member + +Remove a member from a Deepgram project. + +### Send Project Invitation + +Invite a user to join a Deepgram project by email. + ### Text to Speech -Convert text into natural-sounding speech audio. Returns base64-encoded audio data. Supports 40+ English voices with localized accents, configurable encoding formats, sample rates, and bit rates. +Convert text into natural-sounding speech audio. Returns generated audio as a Slate attachment with structured metadata for MIME type, byte length, request ID, and attachment count. ### Transcribe Audio -Transcribe pre-recorded audio to text. Supports audio from a URL or raw audio data (base64-encoded). Provides options for model selection, language detection, speaker diarization, smart formatting, keyword boosting, and text intelligence features (summarization, topic detection, sentiment analysis). Returns the full transcript with word-level timestamps and confidence scores. +Transcribe pre-recorded audio to text. Supports audio from a URL or raw audio data (base64-encoded). Provides options for model selection, language detection, speaker diarization, smart formatting, keyword/keyterm prompting, callbacks, and text intelligence features (summarization, topic detection, intent detection, sentiment analysis). Returns the full transcript with word-level timestamps and confidence scores. + +### Update Member Scopes + +Update a project member's role/scopes. + +### Update Project + +Update a Deepgram project's name. ## License diff --git a/integrations/deepgram/package.json b/integrations/deepgram/package.json index 62e664276c..fa48cf7c6d 100644 --- a/integrations/deepgram/package.json +++ b/integrations/deepgram/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/deepgram/slate.json b/integrations/deepgram/slate.json index ff05fee30c..1af47aa4f6 100644 --- a/integrations/deepgram/slate.json +++ b/integrations/deepgram/slate.json @@ -1,17 +1,17 @@ { "name": "@metorial/deepgram", - "description": "Transcribe pre-recorded and live streaming audio to text in 45+ languages with speaker diarization, smart formatting, and keyword boosting. Convert text to natural-sounding speech with 40+ voice options. Analyze transcripts for sentiment, topics, summaries, and intents. Build conversational voice agents with integrated STT, LLM reasoning, and TTS in a single session. Manage projects, API keys, members, billing, and usage. Discover available models and their metadata. Supports asynchronous processing via callbacks for both transcription and speech synthesis.", + "description": "Use Deepgram REST APIs for pre-recorded speech-to-text, text-to-speech, text intelligence, model discovery, project administration, usage, request troubleshooting, and billing reporting. Supports asynchronous processing via callbacks for transcription, speech synthesis, and text analysis.", "categories": ["apis-and-http-requests", "speech-recognition-and-synthesis"], "skills": [ "transcribe pre-recorded audio", - "transcribe live streaming audio", "convert text to speech", "detect speakers in audio", "analyze text sentiment", "detect topics and intents", "summarize transcripts", - "build conversational voice agents", "manage projects and keys", + "review requests and usage", + "analyze billing and purchases", "discover available models" ], "logoUrl": "https://provider-logos.metorial-cdn.com/deepgram-logo.svg" diff --git a/integrations/deepgram/src/index.ts b/integrations/deepgram/src/index.ts index 25036caca8..58373785f9 100644 --- a/integrations/deepgram/src/index.ts +++ b/integrations/deepgram/src/index.ts @@ -3,16 +3,31 @@ import { spec } from './spec'; import { analyzeTextTool, createKeyTool, + createTemporaryTokenTool, + deleteInvitationTool, deleteKeyTool, deleteProjectTool, getBalancesTool, + getBalanceTool, + getBillingBreakdownTool, + getBillingFieldsTool, + getKeyTool, + getMemberScopesTool, getModelTool, + getProjectModelTool, + getProjectRequestTool, getProjectTool, + getUsageBreakdownTool, + getUsageFieldsTool, getUsageTool, + listInvitationsTool, listKeysTool, listMembersTool, listModelsTool, + listProjectModelsTool, + listProjectRequestsTool, listProjectsTool, + listPurchasesTool, removeMemberTool, sendInvitationTool, textToSpeechTool, @@ -28,21 +43,36 @@ export let provider = Slate.create({ transcribeAudioTool, textToSpeechTool, analyzeTextTool, + createTemporaryTokenTool, listProjectsTool, getProjectTool, updateProjectTool, deleteProjectTool, listMembersTool, + getMemberScopesTool, removeMemberTool, updateMemberScopesTool, + listInvitationsTool, sendInvitationTool, + deleteInvitationTool, listKeysTool, + getKeyTool, createKeyTool, deleteKeyTool, listModelsTool, getModelTool, + listProjectModelsTool, + getProjectModelTool, getUsageTool, - getBalancesTool + getUsageFieldsTool, + getUsageBreakdownTool, + listProjectRequestsTool, + getProjectRequestTool, + getBalancesTool, + getBalanceTool, + getBillingBreakdownTool, + getBillingFieldsTool, + listPurchasesTool ], triggers: [transcriptionCallbackTrigger] }); diff --git a/integrations/deepgram/src/lib/client.ts b/integrations/deepgram/src/lib/client.ts index 39bfc5ef9c..66e9b58c9a 100644 --- a/integrations/deepgram/src/lib/client.ts +++ b/integrations/deepgram/src/lib/client.ts @@ -1,4 +1,129 @@ import { createAxios } from 'slates'; +import { deepgramApiError, deepgramServiceError } from './errors'; + +type CustomMode = 'strict' | 'extended'; + +type TranscriptionParams = { + model?: string; + version?: string; + language?: string; + detectLanguage?: boolean; + detectEntities?: boolean; + punctuate?: boolean; + smartFormat?: boolean; + diarize?: boolean; + diarizeModel?: 'latest' | 'v1' | 'v2'; + utterances?: boolean; + dictation?: boolean; + encoding?: string; + fillerWords?: boolean; + measurements?: boolean; + multichannel?: boolean; + numerals?: boolean; + keywords?: string[]; + keyterms?: string[]; + search?: string[]; + replace?: string[]; + summarize?: boolean; + topics?: boolean; + intents?: boolean; + sentiment?: boolean; + paragraphs?: boolean; + redact?: string[]; + customTopics?: string[]; + customTopicMode?: CustomMode; + customIntents?: string[]; + customIntentMode?: CustomMode; + profanityFilter?: boolean; + uttSplit?: number; + mipOptOut?: boolean; + tag?: string; + callback?: string; + callbackMethod?: string; +}; + +type TextToSpeechParams = { + text: string; + model?: string; + encoding?: string; + sampleRate?: number; + bitRate?: number; + container?: string; + speed?: number; + tag?: string; + mipOptOut?: boolean; + callback?: string; + callbackMethod?: string; +}; + +type TextToSpeechResult = { + contentType: string; + audioBase64?: string; + byteLength: number; + requestId?: string; + callbackSubmitted: boolean; +}; + +type AnalyzeTextParams = { + text?: string; + url?: string; + language?: string; + summarize?: boolean; + topics?: boolean; + intents?: boolean; + sentiment?: boolean; + customTopics?: string[]; + customTopicMode?: CustomMode; + customIntents?: string[]; + customIntentMode?: CustomMode; + tag?: string; + callback?: string; + callbackMethod?: string; +}; + +type UsageBreakdownParams = { + start?: string; + end?: string; + grouping?: string; + accessor?: string; + tag?: string; + method?: string; + model?: string; + endpoint?: string; + deployment?: string; + featuresUsed?: string[]; +}; + +type BillingBreakdownParams = { + start?: string; + end?: string; + accessor?: string; + deployment?: string; + tag?: string; + lineItem?: string; + grouping?: string[]; +}; + +let appendListParam = ( + searchParams: URLSearchParams, + key: string, + values: string[] | undefined +) => { + for (let value of values ?? []) { + if (value) { + searchParams.append(key, value); + } + } +}; + +let bufferFromBase64 = (value: string) => { + let buffer = Buffer.from(value, 'base64'); + if (buffer.length === 0) { + throw deepgramServiceError('audioData must contain non-empty base64-encoded audio.'); + } + + return buffer; +}; export class DeepgramClient { private axios; @@ -12,327 +137,319 @@ export class DeepgramClient { }); } - // ─── Speech-to-Text (Pre-recorded) ──────────────────────────────────── - - async transcribeUrl(params: { - url: string; - model?: string; - language?: string; - detectLanguage?: boolean; - punctuate?: boolean; - smartFormat?: boolean; - diarize?: boolean; - utterances?: boolean; - keywords?: string[]; - search?: string[]; - summarize?: boolean; - topics?: boolean; - intents?: boolean; - sentiment?: boolean; - paragraphs?: boolean; - redact?: string[]; - tag?: string; - callback?: string; - callbackMethod?: string; - }) { - let queryParams = this.buildTranscriptionParams(params); - - let response = await this.axios.post( - `/v1/listen${queryParams}`, - { - url: params.url - }, - { - headers: { 'Content-Type': 'application/json' } - } - ); - - return response.data; - } - - async transcribeAudio(params: { - audioData: string; // base64-encoded audio - mimetype: string; - model?: string; - language?: string; - detectLanguage?: boolean; - punctuate?: boolean; - smartFormat?: boolean; - diarize?: boolean; - utterances?: boolean; - keywords?: string[]; - search?: string[]; - summarize?: boolean; - topics?: boolean; - intents?: boolean; - sentiment?: boolean; - paragraphs?: boolean; - redact?: string[]; - tag?: string; - callback?: string; - callbackMethod?: string; - }) { - let queryParams = this.buildTranscriptionParams(params); - - let binaryString = atob(params.audioData); - let bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); + private async request(operation: string, run: () => Promise) { + try { + return await run(); + } catch (error) { + throw deepgramApiError(error, operation); } + } + + // Speech-to-Text (pre-recorded) + + async transcribeUrl(params: TranscriptionParams & { url: string }) { + return this.request('transcribe audio URL', async () => { + let queryParams = this.buildTranscriptionParams(params); + let response = await this.axios.post( + `/v1/listen${queryParams}`, + { + url: params.url + }, + { + headers: { 'Content-Type': 'application/json' } + } + ); - let response = await this.axios.post(`/v1/listen${queryParams}`, bytes, { - headers: { 'Content-Type': params.mimetype } - }); - - return response.data; - } - - private buildTranscriptionParams(params: { - model?: string; - language?: string; - detectLanguage?: boolean; - punctuate?: boolean; - smartFormat?: boolean; - diarize?: boolean; - utterances?: boolean; - keywords?: string[]; - search?: string[]; - summarize?: boolean; - topics?: boolean; - intents?: boolean; - sentiment?: boolean; - paragraphs?: boolean; - redact?: string[]; - tag?: string; - callback?: string; - callbackMethod?: string; - }): string { + return response.data; + }); + } + + async transcribeAudio( + params: TranscriptionParams & { audioData: string; mimetype: string } + ) { + return this.request('transcribe audio bytes', async () => { + let queryParams = this.buildTranscriptionParams(params); + let audio = bufferFromBase64(params.audioData); + + let response = await this.axios.post(`/v1/listen${queryParams}`, audio, { + headers: { 'Content-Type': params.mimetype } + }); + + return response.data; + }); + } + + private buildTranscriptionParams(params: TranscriptionParams): string { let searchParams = new URLSearchParams(); if (params.model) searchParams.set('model', params.model); + if (params.version) searchParams.set('version', params.version); if (params.language) searchParams.set('language', params.language); if (params.detectLanguage) searchParams.set('detect_language', 'true'); + if (params.detectEntities) searchParams.set('detect_entities', 'true'); if (params.punctuate) searchParams.set('punctuate', 'true'); if (params.smartFormat) searchParams.set('smart_format', 'true'); if (params.diarize) searchParams.set('diarize', 'true'); + if (params.diarizeModel) searchParams.set('diarize_model', params.diarizeModel); if (params.utterances) searchParams.set('utterances', 'true'); + if (params.dictation) searchParams.set('dictation', 'true'); + if (params.encoding) searchParams.set('encoding', params.encoding); + if (params.fillerWords) searchParams.set('filler_words', 'true'); + if (params.measurements) searchParams.set('measurements', 'true'); + if (params.multichannel) searchParams.set('multichannel', 'true'); + if (params.numerals) searchParams.set('numerals', 'true'); if (params.summarize) searchParams.set('summarize', 'v2'); if (params.topics) searchParams.set('topics', 'true'); if (params.intents) searchParams.set('intents', 'true'); if (params.sentiment) searchParams.set('sentiment', 'true'); if (params.paragraphs) searchParams.set('paragraphs', 'true'); + if (params.profanityFilter) searchParams.set('profanity_filter', 'true'); + if (params.uttSplit !== undefined) searchParams.set('utt_split', String(params.uttSplit)); + if (params.mipOptOut) searchParams.set('mip_opt_out', 'true'); + if (params.customTopicMode) searchParams.set('custom_topic_mode', params.customTopicMode); + if (params.customIntentMode) + searchParams.set('custom_intent_mode', params.customIntentMode); if (params.tag) searchParams.set('tag', params.tag); if (params.callback) searchParams.set('callback', params.callback); if (params.callbackMethod) searchParams.set('callback_method', params.callbackMethod); - if (params.keywords) { - for (let kw of params.keywords) { - searchParams.append('keywords', kw); - } - } - if (params.search) { - for (let s of params.search) { - searchParams.append('search', s); - } - } - if (params.redact) { - for (let r of params.redact) { - searchParams.append('redact', r); - } - } + appendListParam(searchParams, 'keywords', params.keywords); + appendListParam(searchParams, 'keyterm', params.keyterms); + appendListParam(searchParams, 'search', params.search); + appendListParam(searchParams, 'replace', params.replace); + appendListParam(searchParams, 'redact', params.redact); + appendListParam(searchParams, 'custom_topic', params.customTopics); + appendListParam(searchParams, 'custom_intent', params.customIntents); let qs = searchParams.toString(); return qs ? `?${qs}` : ''; } - // ─── Text-to-Speech ─────────────────────────────────────────────────── - - async textToSpeech(params: { - text: string; - model?: string; - encoding?: string; - sampleRate?: number; - bitRate?: number; - container?: string; - callback?: string; - callbackMethod?: string; - }): Promise<{ contentType: string; audioBase64: string; requestId?: string }> { - let searchParams = new URLSearchParams(); - - if (params.model) searchParams.set('model', params.model); - if (params.encoding) searchParams.set('encoding', params.encoding); - if (params.sampleRate) searchParams.set('sample_rate', String(params.sampleRate)); - if (params.bitRate) searchParams.set('bit_rate', String(params.bitRate)); - if (params.container) searchParams.set('container', params.container); - if (params.callback) searchParams.set('callback', params.callback); - if (params.callbackMethod) searchParams.set('callback_method', params.callbackMethod); - - let qs = searchParams.toString(); - let url = `/v1/speak${qs ? `?${qs}` : ''}`; + // Text-to-Speech + + async textToSpeech(params: TextToSpeechParams): Promise { + return this.request('text to speech', async () => { + let searchParams = new URLSearchParams(); + + if (params.model) searchParams.set('model', params.model); + if (params.encoding) searchParams.set('encoding', params.encoding); + if (params.sampleRate) searchParams.set('sample_rate', String(params.sampleRate)); + if (params.bitRate) searchParams.set('bit_rate', String(params.bitRate)); + if (params.container) searchParams.set('container', params.container); + if (params.speed !== undefined) searchParams.set('speed', String(params.speed)); + if (params.tag) searchParams.set('tag', params.tag); + if (params.mipOptOut) searchParams.set('mip_opt_out', 'true'); + if (params.callback) searchParams.set('callback', params.callback); + if (params.callbackMethod) searchParams.set('callback_method', params.callbackMethod); + + let qs = searchParams.toString(); + let url = `/v1/speak${qs ? `?${qs}` : ''}`; + + if (params.callback) { + let response = await this.axios.post( + url, + { text: params.text }, + { + headers: { 'Content-Type': 'application/json' } + } + ); + + return { + contentType: 'application/json', + byteLength: 0, + requestId: response.data?.request_id, + callbackSubmitted: true + }; + } - // If callback is set, Deepgram returns JSON with request_id - if (params.callback) { let response = await this.axios.post( url, { text: params.text }, { - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, + responseType: 'arraybuffer' } ); + let audioBuffer = Buffer.from(response.data); + return { - contentType: 'application/json', - audioBase64: '', - requestId: response.data.request_id + contentType: String(response.headers['content-type'] ?? 'audio/mpeg'), + audioBase64: audioBuffer.toString('base64'), + byteLength: audioBuffer.byteLength, + requestId: + typeof response.headers['dg-request-id'] === 'string' + ? response.headers['dg-request-id'] + : undefined, + callbackSubmitted: false }; - } + }); + } - let response = await this.axios.post( - url, - { text: params.text }, - { - headers: { 'Content-Type': 'application/json' }, - responseType: 'arraybuffer' + // Text Intelligence + + async analyzeText(params: AnalyzeTextParams) { + return this.request('analyze text', async () => { + let searchParams = new URLSearchParams(); + + if (params.language) searchParams.set('language', params.language); + if (params.summarize) searchParams.set('summarize', 'true'); + if (params.topics) searchParams.set('topics', 'true'); + if (params.intents) searchParams.set('intents', 'true'); + if (params.sentiment) searchParams.set('sentiment', 'true'); + if (params.customTopicMode) + searchParams.set('custom_topic_mode', params.customTopicMode); + if (params.customIntentMode) { + searchParams.set('custom_intent_mode', params.customIntentMode); } - ); + if (params.tag) searchParams.set('tag', params.tag); + if (params.callback) searchParams.set('callback', params.callback); + if (params.callbackMethod) searchParams.set('callback_method', params.callbackMethod); - let binary = ''; - let bytes = new Uint8Array(response.data); - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]!); - } - let audioBase64 = btoa(binary); - - return { - contentType: String(response.headers['content-type'] ?? 'audio/mp3'), - audioBase64, - requestId: - typeof response.headers['dg-request-id'] === 'string' - ? response.headers['dg-request-id'] - : undefined - }; - } - - // ─── Text Intelligence ──────────────────────────────────────────────── - - async analyzeText(params: { - text: string; - language?: string; - summarize?: boolean; - topics?: boolean; - intents?: boolean; - sentiment?: boolean; - }) { - let searchParams = new URLSearchParams(); + appendListParam(searchParams, 'custom_topic', params.customTopics); + appendListParam(searchParams, 'custom_intent', params.customIntents); - if (params.language) searchParams.set('language', params.language); - if (params.summarize) searchParams.set('summarize', 'true'); - if (params.topics) searchParams.set('topics', 'true'); - if (params.intents) searchParams.set('intents', 'true'); - if (params.sentiment) searchParams.set('sentiment', 'true'); + let qs = searchParams.toString(); + let url = `/v1/read${qs ? `?${qs}` : ''}`; - let qs = searchParams.toString(); - let url = `/v1/read${qs ? `?${qs}` : ''}`; + let response = await this.axios.post( + url, + params.url ? { url: params.url } : { text: params.text }, + { + headers: { 'Content-Type': 'application/json' } + } + ); - let response = await this.axios.post( - url, - { text: params.text }, - { - headers: { 'Content-Type': 'application/json' } - } - ); + return response.data; + }); + } + + // Token-based authentication - return response.data; + async createTemporaryToken(params?: { ttlSeconds?: number }) { + return this.request('create temporary token', async () => { + let body = params?.ttlSeconds ? { ttl_seconds: params.ttlSeconds } : {}; + let response = await this.axios.post('/v1/auth/grant', body, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data; + }); } - // ─── Project Management ─────────────────────────────────────────────── + // Project Management async listProjects() { - let response = await this.axios.get('/v1/projects'); - return response.data; + return this.request('list projects', async () => { + let response = await this.axios.get('/v1/projects'); + return response.data; + }); } async getProject(projectId: string) { - let response = await this.axios.get(`/v1/projects/${projectId}`); - return response.data; + return this.request('get project', async () => { + let response = await this.axios.get(`/v1/projects/${projectId}`); + return response.data; + }); } - async updateProject(projectId: string, params: { name?: string; company?: string }) { - let response = await this.axios.patch(`/v1/projects/${projectId}`, params, { - headers: { 'Content-Type': 'application/json' } + async updateProject(projectId: string, params: { name: string }) { + return this.request('update project', async () => { + let response = await this.axios.patch(`/v1/projects/${projectId}`, params, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data; }); - return response.data; } async deleteProject(projectId: string) { - await this.axios.delete(`/v1/projects/${projectId}`); + return this.request('delete project', async () => { + await this.axios.delete(`/v1/projects/${projectId}`); + }); } - // ─── Project Members ────────────────────────────────────────────────── + // Project Members async listMembers(projectId: string) { - let response = await this.axios.get(`/v1/projects/${projectId}/members`); - return response.data; + return this.request('list project members', async () => { + let response = await this.axios.get(`/v1/projects/${projectId}/members`); + return response.data; + }); } async removeMember(projectId: string, memberId: string) { - await this.axios.delete(`/v1/projects/${projectId}/members/${memberId}`); + return this.request('remove project member', async () => { + await this.axios.delete(`/v1/projects/${projectId}/members/${memberId}`); + }); } async getMemberScopes(projectId: string, memberId: string) { - let response = await this.axios.get( - `/v1/projects/${projectId}/members/${memberId}/scopes` - ); - return response.data; + return this.request('get member scopes', async () => { + let response = await this.axios.get( + `/v1/projects/${projectId}/members/${memberId}/scopes` + ); + return response.data; + }); } async updateMemberScopes(projectId: string, memberId: string, scope: string) { - let response = await this.axios.put( - `/v1/projects/${projectId}/members/${memberId}/scopes`, - { - scope - }, - { - headers: { 'Content-Type': 'application/json' } - } - ); - return response.data; + return this.request('update member scopes', async () => { + let response = await this.axios.put( + `/v1/projects/${projectId}/members/${memberId}/scopes`, + { + scope + }, + { + headers: { 'Content-Type': 'application/json' } + } + ); + return response.data; + }); } - // ─── Invitations ────────────────────────────────────────────────────── + // Invitations async listInvitations(projectId: string) { - let response = await this.axios.get(`/v1/projects/${projectId}/invites`); - return response.data; + return this.request('list project invitations', async () => { + let response = await this.axios.get(`/v1/projects/${projectId}/invites`); + return response.data; + }); } async sendInvitation(projectId: string, email: string, scope: string) { - let response = await this.axios.post( - `/v1/projects/${projectId}/invites`, - { - email, - scope - }, - { - headers: { 'Content-Type': 'application/json' } - } - ); - return response.data; + return this.request('send project invitation', async () => { + let response = await this.axios.post( + `/v1/projects/${projectId}/invites`, + { + email, + scope + }, + { + headers: { 'Content-Type': 'application/json' } + } + ); + return response.data; + }); } async deleteInvitation(projectId: string, email: string) { - await this.axios.delete(`/v1/projects/${projectId}/invites/${email}`); + return this.request('delete project invitation', async () => { + await this.axios.delete(`/v1/projects/${projectId}/invites/${email}`); + }); } - // ─── API Keys ───────────────────────────────────────────────────────── + // API Keys async listKeys(projectId: string) { - let response = await this.axios.get(`/v1/projects/${projectId}/keys`); - return response.data; + return this.request('list API keys', async () => { + let response = await this.axios.get(`/v1/projects/${projectId}/keys`); + return response.data; + }); } async getKey(projectId: string, keyId: string) { - let response = await this.axios.get(`/v1/projects/${projectId}/keys/${keyId}`); - return response.data; + return this.request('get API key', async () => { + let response = await this.axios.get(`/v1/projects/${projectId}/keys/${keyId}`); + return response.data; + }); } async createKey( @@ -342,20 +459,29 @@ export class DeepgramClient { scopes: string[]; tags?: string[]; expirationDate?: string; - timeToLiveInSeconds?: number; } ) { - let response = await this.axios.post(`/v1/projects/${projectId}/keys`, params, { - headers: { 'Content-Type': 'application/json' } + return this.request('create API key', async () => { + let body = { + comment: params.comment, + scopes: params.scopes, + tags: params.tags, + expiration_date: params.expirationDate + }; + let response = await this.axios.post(`/v1/projects/${projectId}/keys`, body, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data; }); - return response.data; } async deleteKey(projectId: string, keyId: string) { - await this.axios.delete(`/v1/projects/${projectId}/keys/${keyId}`); + return this.request('delete API key', async () => { + await this.axios.delete(`/v1/projects/${projectId}/keys/${keyId}`); + }); } - // ─── Usage ──────────────────────────────────────────────────────────── + // Usage async getUsage( projectId: string, @@ -368,58 +494,191 @@ export class DeepgramClient { model?: string; } ) { - let searchParams = new URLSearchParams(); - if (params?.start) searchParams.set('start', params.start); - if (params?.end) searchParams.set('end', params.end); - if (params?.accessor) searchParams.set('accessor', params.accessor); - if (params?.tag) searchParams.set('tag', params.tag); - if (params?.method) searchParams.set('method', params.method); - if (params?.model) searchParams.set('model', params.model); - - let qs = searchParams.toString(); - let response = await this.axios.get( - `/v1/projects/${projectId}/usage${qs ? `?${qs}` : ''}` - ); - return response.data; + return this.request('get usage', async () => { + let searchParams = new URLSearchParams(); + if (params?.start) searchParams.set('start', params.start); + if (params?.end) searchParams.set('end', params.end); + if (params?.accessor) searchParams.set('accessor', params.accessor); + if (params?.tag) searchParams.set('tag', params.tag); + if (params?.method) searchParams.set('method', params.method); + if (params?.model) searchParams.set('model', params.model); + + let qs = searchParams.toString(); + let response = await this.axios.get( + `/v1/projects/${projectId}/usage${qs ? `?${qs}` : ''}` + ); + return response.data; + }); } async getUsageFields(projectId: string, params?: { start?: string; end?: string }) { - let searchParams = new URLSearchParams(); - if (params?.start) searchParams.set('start', params.start); - if (params?.end) searchParams.set('end', params.end); + return this.request('get usage fields', async () => { + let searchParams = new URLSearchParams(); + if (params?.start) searchParams.set('start', params.start); + if (params?.end) searchParams.set('end', params.end); + + let qs = searchParams.toString(); + let response = await this.axios.get( + `/v1/projects/${projectId}/usage/fields${qs ? `?${qs}` : ''}` + ); + return response.data; + }); + } - let qs = searchParams.toString(); - let response = await this.axios.get( - `/v1/projects/${projectId}/usage/fields${qs ? `?${qs}` : ''}` - ); - return response.data; + async listProjectRequests( + projectId: string, + params?: { + start?: string; + end?: string; + limit?: number; + status?: 'succeeded' | 'failed'; + } + ) { + return this.request('list project requests', async () => { + let searchParams = new URLSearchParams(); + if (params?.start) searchParams.set('start', params.start); + if (params?.end) searchParams.set('end', params.end); + if (params?.limit) searchParams.set('limit', String(params.limit)); + if (params?.status) searchParams.set('status', params.status); + + let qs = searchParams.toString(); + let response = await this.axios.get( + `/v1/projects/${projectId}/requests${qs ? `?${qs}` : ''}` + ); + return response.data; + }); + } + + async getProjectRequest(projectId: string, requestId: string) { + return this.request('get project request', async () => { + let response = await this.axios.get(`/v1/projects/${projectId}/requests/${requestId}`); + return response.data; + }); + } + + async getUsageBreakdown(projectId: string, params?: UsageBreakdownParams) { + return this.request('get usage breakdown', async () => { + let searchParams = new URLSearchParams(); + if (params?.start) searchParams.set('start', params.start); + if (params?.end) searchParams.set('end', params.end); + if (params?.grouping) searchParams.set('grouping', params.grouping); + if (params?.accessor) searchParams.set('accessor', params.accessor); + if (params?.tag) searchParams.set('tag', params.tag); + if (params?.method) searchParams.set('method', params.method); + if (params?.model) searchParams.set('model', params.model); + if (params?.endpoint) searchParams.set('endpoint', params.endpoint); + if (params?.deployment) searchParams.set('deployment', params.deployment); + for (let feature of params?.featuresUsed ?? []) { + searchParams.set(feature, 'true'); + } + + let qs = searchParams.toString(); + let response = await this.axios.get( + `/v1/projects/${projectId}/usage/breakdown${qs ? `?${qs}` : ''}` + ); + return response.data; + }); } - // ─── Balances ───────────────────────────────────────────────────────── + // Balances async getBalances(projectId: string) { - let response = await this.axios.get(`/v1/projects/${projectId}/balances`); - return response.data; + return this.request('get balances', async () => { + let response = await this.axios.get(`/v1/projects/${projectId}/balances`); + return response.data; + }); } async getBalance(projectId: string, balanceId: string) { - let response = await this.axios.get(`/v1/projects/${projectId}/balances/${balanceId}`); - return response.data; + return this.request('get balance', async () => { + let response = await this.axios.get(`/v1/projects/${projectId}/balances/${balanceId}`); + return response.data; + }); + } + + async getBillingBreakdown(projectId: string, params?: BillingBreakdownParams) { + return this.request('get billing breakdown', async () => { + let searchParams = new URLSearchParams(); + if (params?.start) searchParams.set('start', params.start); + if (params?.end) searchParams.set('end', params.end); + if (params?.accessor) searchParams.set('accessor', params.accessor); + if (params?.deployment) searchParams.set('deployment', params.deployment); + if (params?.tag) searchParams.set('tag', params.tag); + if (params?.lineItem) searchParams.set('line_item', params.lineItem); + appendListParam(searchParams, 'grouping', params?.grouping); + + let qs = searchParams.toString(); + let response = await this.axios.get( + `/v1/projects/${projectId}/billing/breakdown${qs ? `?${qs}` : ''}` + ); + return response.data; + }); + } + + async getBillingFields(projectId: string, params?: { start?: string; end?: string }) { + return this.request('get billing fields', async () => { + let searchParams = new URLSearchParams(); + if (params?.start) searchParams.set('start', params.start); + if (params?.end) searchParams.set('end', params.end); + + let qs = searchParams.toString(); + let response = await this.axios.get( + `/v1/projects/${projectId}/billing/fields${qs ? `?${qs}` : ''}` + ); + return response.data; + }); + } + + async listPurchases(projectId: string, params?: { limit?: number }) { + return this.request('list purchases', async () => { + let searchParams = new URLSearchParams(); + if (params?.limit) searchParams.set('limit', String(params.limit)); + + let qs = searchParams.toString(); + let response = await this.axios.get( + `/v1/projects/${projectId}/purchases${qs ? `?${qs}` : ''}` + ); + return response.data; + }); } - // ─── Models ─────────────────────────────────────────────────────────── + // Models async listModels(params?: { includeOutdated?: boolean }) { - let searchParams = new URLSearchParams(); - if (params?.includeOutdated) searchParams.set('include_outdated', 'true'); + return this.request('list models', async () => { + let searchParams = new URLSearchParams(); + if (params?.includeOutdated) searchParams.set('include_outdated', 'true'); - let qs = searchParams.toString(); - let response = await this.axios.get(`/v1/models${qs ? `?${qs}` : ''}`); - return response.data; + let qs = searchParams.toString(); + let response = await this.axios.get(`/v1/models${qs ? `?${qs}` : ''}`); + return response.data; + }); } async getModel(modelId: string) { - let response = await this.axios.get(`/v1/models/${modelId}`); - return response.data; + return this.request('get model', async () => { + let response = await this.axios.get(`/v1/models/${modelId}`); + return response.data; + }); + } + + async listProjectModels(projectId: string, params?: { includeOutdated?: boolean }) { + return this.request('list project models', async () => { + let searchParams = new URLSearchParams(); + if (params?.includeOutdated) searchParams.set('include_outdated', 'true'); + + let qs = searchParams.toString(); + let response = await this.axios.get( + `/v1/projects/${projectId}/models${qs ? `?${qs}` : ''}` + ); + return response.data; + }); + } + + async getProjectModel(projectId: string, modelId: string) { + return this.request('get project model', async () => { + let response = await this.axios.get(`/v1/projects/${projectId}/models/${modelId}`); + return response.data; + }); } } diff --git a/integrations/deepgram/src/lib/errors.ts b/integrations/deepgram/src/lib/errors.ts new file mode 100644 index 0000000000..b6d65c29b0 --- /dev/null +++ b/integrations/deepgram/src/lib/errors.ts @@ -0,0 +1,84 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractDeepgramMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + for (let key of ['message', 'error', 'err_msg', 'detail', 'reason']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getDeepgramErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let deepgramServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let deepgramApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getDeepgramErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = deepgramServiceError( + `Deepgram API ${operation} failed: ${statusLabel}${extractDeepgramMessage(error)}` + ); + serviceError.data.reason = 'deepgram_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/deepgram/src/tools.schema.test.ts b/integrations/deepgram/src/tools.schema.test.ts new file mode 100644 index 0000000000..8dc18530a6 --- /dev/null +++ b/integrations/deepgram/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Deepgram tool input schemas', provider.actions); diff --git a/integrations/deepgram/src/tools/analyze-text.ts b/integrations/deepgram/src/tools/analyze-text.ts index 66724ebd93..23abe40703 100644 --- a/integrations/deepgram/src/tools/analyze-text.ts +++ b/integrations/deepgram/src/tools/analyze-text.ts @@ -1,15 +1,21 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DeepgramClient } from '../lib/client'; +import { deepgramServiceError } from '../lib/errors'; import { spec } from '../spec'; +let customModeSchema = z + .enum(['strict', 'extended']) + .describe('How Deepgram should match custom topics or intents.'); + export let analyzeTextTool = SlateTool.create(spec, { name: 'Analyze Text', key: 'analyze_text', - description: `Analyze text for intelligence insights including sentiment analysis, topic detection, intent detection, and summarization. Enable one or more analysis features to extract value from text content such as transcripts, articles, or conversations.`, + description: `Analyze text for intelligence insights including sentiment analysis, topic detection, intent detection, custom topics or intents, and summarization. Enable one or more analysis features to extract value from text content such as transcripts, articles, or conversations.`, instructions: [ - 'Enable at least one analysis feature (summarize, topics, intents, or sentiment).', - 'All features can be combined in a single request.' + 'Enable at least one analysis feature: summarize, topics, intents, or sentiment.', + 'Provide either text or textUrl, not both.', + 'Use customTopics only when topics=true, and customIntents only when intents=true.' ], tags: { readOnly: true @@ -17,12 +23,19 @@ export let analyzeTextTool = SlateTool.create(spec, { }) .input( z.object({ - text: z.string().describe('The text content to analyze.'), + text: z + .string() + .optional() + .describe('Plain text content to analyze. Use this or textUrl, not both.'), + textUrl: z + .string() + .optional() + .describe('URL pointing to text content to analyze. Use this or text, not both.'), language: z .string() .optional() .describe( - 'BCP-47 language code of the text (e.g., "en", "es"). Auto-detected if not provided.' + 'BCP-47 language code of the text, for example "en" or "es". Auto-detected if not provided.' ), summarize: z.boolean().optional().describe('Generate a concise summary of the text.'), topics: z.boolean().optional().describe('Detect topics discussed in the text.'), @@ -30,11 +43,35 @@ export let analyzeTextTool = SlateTool.create(spec, { sentiment: z .boolean() .optional() - .describe('Analyze overall and segment-level sentiment.') + .describe('Analyze overall and segment-level sentiment.'), + customTopics: z + .array(z.string()) + .optional() + .describe('Custom topics to look for when topics=true.'), + customTopicMode: customModeSchema.optional(), + customIntents: z + .array(z.string()) + .optional() + .describe('Custom intents to look for when intents=true.'), + customIntentMode: customModeSchema.optional(), + tag: z.string().optional().describe('Tag for tracking the request in usage reports.'), + callbackUrl: z + .string() + .optional() + .describe('Optional callback URL for asynchronous text intelligence results.'), + callbackMethod: z + .enum(['POST', 'PUT']) + .optional() + .describe('HTTP method Deepgram should use for callback delivery.') }) ) .output( z.object({ + requestId: z.string().optional().describe('Unique request identifier.'), + callbackSubmitted: z + .boolean() + .optional() + .describe('True when Deepgram accepted an asynchronous callback request.'), summary: z.any().optional().describe('Summary of the text content.'), topics: z.any().optional().describe('Detected topics with confidence scores.'), intents: z.any().optional().describe('Detected intents with confidence scores.'), @@ -46,17 +83,77 @@ export let analyzeTextTool = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + let hasText = Boolean(ctx.input.text); + let hasTextUrl = Boolean(ctx.input.textUrl); + if (hasText === hasTextUrl) { + throw deepgramServiceError('Provide exactly one of text or textUrl.'); + } + + let hasFeature = + ctx.input.summarize || ctx.input.topics || ctx.input.intents || ctx.input.sentiment; + if (!hasFeature) { + throw deepgramServiceError( + 'Enable at least one analysis feature: summarize, topics, intents, or sentiment.' + ); + } + + if ((ctx.input.customTopics?.length ?? 0) > 0 && !ctx.input.topics) { + throw deepgramServiceError('customTopics requires topics=true.'); + } + + if (ctx.input.customTopicMode && (ctx.input.customTopics?.length ?? 0) === 0) { + throw deepgramServiceError('customTopicMode requires at least one customTopics value.'); + } + + if ((ctx.input.customIntents?.length ?? 0) > 0 && !ctx.input.intents) { + throw deepgramServiceError('customIntents requires intents=true.'); + } + + if (ctx.input.customIntentMode && (ctx.input.customIntents?.length ?? 0) === 0) { + throw deepgramServiceError( + 'customIntentMode requires at least one customIntents value.' + ); + } + + if (ctx.input.callbackMethod && !ctx.input.callbackUrl) { + throw deepgramServiceError('callbackMethod requires callbackUrl.'); + } + let client = new DeepgramClient(ctx.auth.token); let result = await client.analyzeText({ text: ctx.input.text, + url: ctx.input.textUrl, language: ctx.input.language, summarize: ctx.input.summarize, topics: ctx.input.topics, intents: ctx.input.intents, - sentiment: ctx.input.sentiment + sentiment: ctx.input.sentiment, + customTopics: ctx.input.customTopics, + customTopicMode: ctx.input.customTopicMode, + customIntents: ctx.input.customIntents, + customIntentMode: ctx.input.customIntentMode, + tag: ctx.input.tag, + callback: ctx.input.callbackUrl, + callbackMethod: ctx.input.callbackMethod }); + let requestId = + result.metadata?.metadata?.request_id || + result.metadata?.request_id || + result.request_id; + + if (ctx.input.callbackUrl && !result.results) { + return { + output: { + requestId, + callbackSubmitted: true, + metadata: result.metadata ?? result + }, + message: `Submitted asynchronous Deepgram text analysis request${requestId ? ` **${requestId}**` : ''}.` + }; + } + let features: string[] = []; if (ctx.input.summarize) features.push('summary'); if (ctx.input.topics) features.push('topics'); @@ -65,13 +162,15 @@ export let analyzeTextTool = SlateTool.create(spec, { return { output: { + requestId, + callbackSubmitted: false, summary: result.results?.summary, topics: result.results?.topics, intents: result.results?.intents, sentiments: result.results?.sentiments, metadata: result.metadata }, - message: `Analyzed text (${ctx.input.text.length} chars) for: ${features.join(', ') || 'no features enabled'}` + message: `Analyzed ${ctx.input.text ? `text (${ctx.input.text.length} chars)` : 'text URL'} for: ${features.join(', ')}` }; }) .build(); diff --git a/integrations/deepgram/src/tools/get-usage.ts b/integrations/deepgram/src/tools/get-usage.ts index b1f775011b..943e0ee46a 100644 --- a/integrations/deepgram/src/tools/get-usage.ts +++ b/integrations/deepgram/src/tools/get-usage.ts @@ -3,10 +3,100 @@ import { z } from 'zod'; import { DeepgramClient } from '../lib/client'; import { spec } from '../spec'; +let balanceSchema = z.object({ + balanceId: z.string().describe('Unique balance identifier.'), + amount: z.number().optional().describe('Balance amount.'), + units: z.string().optional().describe('Balance units.'), + purchase: z.any().optional().describe('Purchase details.') +}); + +let projectRequestSchema = z.object({ + requestId: z.string().optional().describe('Unique request identifier.'), + projectId: z.string().optional().describe('Project identifier.'), + created: z.string().optional().describe('Request creation timestamp.'), + path: z.string().optional().describe('API path used by the request.'), + apiKeyId: z.string().optional().describe('API key identifier used by the request.'), + code: z.number().optional().describe('HTTP response code.'), + deployment: z.string().optional().describe('Deployment type.'), + callback: z.string().optional().describe('Callback URL if one was used.'), + response: z.any().optional().describe('Recorded response details.') +}); + +let deploymentSchema = z.enum(['hosted', 'beta', 'self-hosted']); +let endpointSchema = z.enum(['listen', 'read', 'speak', 'agent']); +let methodSchema = z.enum(['sync', 'async', 'streaming']); +let usageGroupingSchema = z.enum([ + 'accessor', + 'endpoint', + 'feature_set', + 'models', + 'method', + 'tags', + 'deployment' +]); +let usageFeatureSchema = z.enum([ + 'alternatives', + 'callback', + 'callback_method', + 'channels', + 'custom_intent', + 'custom_intent_mode', + 'custom_topic', + 'custom_topic_mode', + 'detect_entities', + 'detect_language', + 'diarize', + 'dictation', + 'encoding', + 'extra', + 'filler_words', + 'intents', + 'keyterm', + 'keywords', + 'language', + 'measurements', + 'multichannel', + 'numerals', + 'paragraphs', + 'profanity_filter', + 'punctuate', + 'redact', + 'replace', + 'sample_rate', + 'search', + 'sentiment', + 'smart_format', + 'summarize', + 'topics', + 'utt_split', + 'utterances', + 'version' +]); +let billingGroupingSchema = z.enum(['accessor', 'deployment', 'line_item', 'tags']); + +let toBalance = (b: any): z.infer => ({ + balanceId: b.balance_id || b.balanceId || b.id || '', + amount: b.amount, + units: b.units, + purchase: b.purchase +}); + +let toProjectRequest = (request: any): z.infer => ({ + requestId: request.request_id, + projectId: request.project_uuid || request.project_id, + created: request.created, + path: request.path, + apiKeyId: request.api_key_id, + code: request.code, + deployment: request.deployment, + callback: request.callback, + response: request.response +}); + export let getUsageTool = SlateTool.create(spec, { name: 'Get Usage', key: 'get_usage', - description: `Get usage data for a Deepgram project. Filter by date range, API key, tag, method (sync/async/streaming), or model. Useful for monitoring API consumption and billing.`, + description: `Get usage data for a Deepgram project. Filter by date range, API key, tag, method, or model. Useful for monitoring API consumption and billing.`, tags: { readOnly: true } @@ -14,17 +104,14 @@ export let getUsageTool = SlateTool.create(spec, { .input( z.object({ projectId: z.string().describe('ID of the project.'), - start: z - .string() - .optional() - .describe('Start date in ISO 8601 format (e.g., "2024-01-01T00:00:00Z").'), - end: z.string().optional().describe('End date in ISO 8601 format.'), + start: z.string().optional().describe('Start date in YYYY-MM-DD format.'), + end: z.string().optional().describe('End date in YYYY-MM-DD format.'), accessor: z.string().optional().describe('Filter by API key ID.'), tag: z.string().optional().describe('Filter by request tag.'), method: z .string() .optional() - .describe('Filter by method (e.g., "sync", "async", "streaming").'), + .describe('Filter by method, for example "sync", "async", or "streaming".'), model: z.string().optional().describe('Filter by model name.') }) ) @@ -51,6 +138,178 @@ export let getUsageTool = SlateTool.create(spec, { }) .build(); +export let getUsageFieldsTool = SlateTool.create(spec, { + name: 'Get Usage Fields', + key: 'get_usage_fields', + description: `Get available usage breakdown fields for a Deepgram project and optional date range. Use this before building detailed usage filters.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + start: z.string().optional().describe('Start date in YYYY-MM-DD format.'), + end: z.string().optional().describe('End date in YYYY-MM-DD format.') + }) + ) + .output( + z.object({ + fields: z.any().describe('Deepgram usage field metadata.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.getUsageFields(ctx.input.projectId, { + start: ctx.input.start, + end: ctx.input.end + }); + + return { + output: { fields: result }, + message: `Retrieved usage fields for project **${ctx.input.projectId}**.` + }; + }) + .build(); + +export let listProjectRequestsTool = SlateTool.create(spec, { + name: 'List Project Requests', + key: 'list_project_requests', + description: `List individual Deepgram API requests for a project. Useful for request-level troubleshooting, audit trails, and correlating tagged calls with usage.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + start: z.string().optional().describe('Start date in YYYY-MM-DD format.'), + end: z.string().optional().describe('End date in YYYY-MM-DD format.'), + limit: z + .number() + .min(1) + .max(1000) + .optional() + .describe('Maximum number of requests to return. Deepgram documents 1-1000.'), + status: z + .enum(['succeeded', 'failed']) + .optional() + .describe('Optional filter for successful or failed requests.') + }) + ) + .output( + z.object({ + requests: z.array(projectRequestSchema).describe('Project request records.'), + metadata: z.any().optional().describe('Additional pagination or response metadata.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.listProjectRequests(ctx.input.projectId, { + start: ctx.input.start, + end: ctx.input.end, + limit: ctx.input.limit, + status: ctx.input.status + }); + let requests = result.requests || result.project_requests || result.items || []; + let mappedRequests = requests.map(toProjectRequest); + + return { + output: { + requests: mappedRequests, + metadata: result + }, + message: `Retrieved **${mappedRequests.length}** request record(s) for project **${ctx.input.projectId}**.` + }; + }) + .build(); + +export let getProjectRequestTool = SlateTool.create(spec, { + name: 'Get Project Request', + key: 'get_project_request', + description: `Get details for a single Deepgram request by request ID. Useful for troubleshooting failed calls returned by list_project_requests.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + requestId: z.string().describe('ID of the request to retrieve.') + }) + ) + .output( + z.object({ + request: projectRequestSchema.describe('Deepgram request record.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.getProjectRequest(ctx.input.projectId, ctx.input.requestId); + let request = toProjectRequest(result.request || result); + + return { + output: { + request + }, + message: `Retrieved request **${request.requestId || ctx.input.requestId}**.` + }; + }) + .build(); + +export let getUsageBreakdownTool = SlateTool.create(spec, { + name: 'Get Usage Breakdown', + key: 'get_usage_breakdown', + description: `Get grouped usage metrics for a Deepgram project, optionally filtered by dates, endpoint, method, model, tag, deployment, accessor, or documented feature flags.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + start: z.string().optional().describe('Start date in YYYY-MM-DD format.'), + end: z.string().optional().describe('End date in YYYY-MM-DD format.'), + grouping: usageGroupingSchema.optional().describe('Dimension to group usage by.'), + accessor: z.string().optional().describe('Filter by API key/accessor ID.'), + tag: z.string().optional().describe('Filter by request tag.'), + method: methodSchema.optional().describe('Filter by request method.'), + model: z.string().optional().describe('Filter by model UUID.'), + endpoint: endpointSchema.optional().describe('Filter by Deepgram API endpoint.'), + deployment: deploymentSchema.optional().describe('Filter by deployment type.'), + featuresUsed: z + .array(usageFeatureSchema) + .optional() + .describe('Feature filters to set true, such as "diarize" or "smart_format".') + }) + ) + .output( + z.object({ + usageBreakdown: z.any().describe('Deepgram usage breakdown response.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.getUsageBreakdown(ctx.input.projectId, { + start: ctx.input.start, + end: ctx.input.end, + grouping: ctx.input.grouping, + accessor: ctx.input.accessor, + tag: ctx.input.tag, + method: ctx.input.method, + model: ctx.input.model, + endpoint: ctx.input.endpoint, + deployment: ctx.input.deployment, + featuresUsed: ctx.input.featuresUsed + }); + + return { + output: { usageBreakdown: result }, + message: `Retrieved usage breakdown for project **${ctx.input.projectId}**.` + }; + }) + .build(); + export let getBalancesTool = SlateTool.create(spec, { name: 'Get Balances', key: 'get_balances', @@ -66,28 +325,13 @@ export let getBalancesTool = SlateTool.create(spec, { ) .output( z.object({ - balances: z - .array( - z.object({ - balanceId: z.string().describe('Unique balance identifier.'), - amount: z.number().optional().describe('Balance amount.'), - units: z.string().optional().describe('Balance units.'), - purchase: z.any().optional().describe('Purchase details.') - }) - ) - .describe('List of balances.') + balances: z.array(balanceSchema).describe('List of balances.') }) ) .handleInvocation(async ctx => { let client = new DeepgramClient(ctx.auth.token); let result = await client.getBalances(ctx.input.projectId); - - let balances = (result.balances || []).map((b: any) => ({ - balanceId: b.balance_id, - amount: b.amount, - units: b.units, - purchase: b.purchase - })); + let balances = (result.balances || []).map(toBalance); return { output: { balances }, @@ -95,3 +339,156 @@ export let getBalancesTool = SlateTool.create(spec, { }; }) .build(); + +export let getBalanceTool = SlateTool.create(spec, { + name: 'Get Balance', + key: 'get_balance', + description: `Get one Deepgram billing balance by ID.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + balanceId: z.string().describe('ID of the balance.') + }) + ) + .output(balanceSchema) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.getBalance(ctx.input.projectId, ctx.input.balanceId); + let balance = toBalance(result.balance || result); + + return { + output: balance, + message: `Retrieved balance **${balance.balanceId || ctx.input.balanceId}**.` + }; + }) + .build(); + +export let getBillingBreakdownTool = SlateTool.create(spec, { + name: 'Get Billing Breakdown', + key: 'get_billing_breakdown', + description: `Get grouped billing metrics for a Deepgram project, optionally filtered by dates, accessor, deployment, tag, line item, or billing grouping dimensions.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + start: z.string().optional().describe('Start date in YYYY-MM-DD format.'), + end: z.string().optional().describe('End date in YYYY-MM-DD format.'), + accessor: z.string().optional().describe('Filter by API key/accessor ID.'), + deployment: deploymentSchema.optional().describe('Filter by deployment type.'), + tag: z.string().optional().describe('Filter by request tag.'), + lineItem: z + .string() + .optional() + .describe('Filter by Deepgram billing line item, for example "streaming::nova-3".'), + grouping: z + .array(billingGroupingSchema) + .optional() + .describe('Dimensions to group billing by.') + }) + ) + .output( + z.object({ + billingBreakdown: z.any().describe('Deepgram billing breakdown response.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.getBillingBreakdown(ctx.input.projectId, { + start: ctx.input.start, + end: ctx.input.end, + accessor: ctx.input.accessor, + deployment: ctx.input.deployment, + tag: ctx.input.tag, + lineItem: ctx.input.lineItem, + grouping: ctx.input.grouping + }); + + return { + output: { billingBreakdown: result }, + message: `Retrieved billing breakdown for project **${ctx.input.projectId}**.` + }; + }) + .build(); + +export let getBillingFieldsTool = SlateTool.create(spec, { + name: 'Get Billing Fields', + key: 'get_billing_fields', + description: `List billing filter fields available for a Deepgram project and optional date range, including accessors, deployments, tags, and line items.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + start: z.string().optional().describe('Start date in YYYY-MM-DD format.'), + end: z.string().optional().describe('End date in YYYY-MM-DD format.') + }) + ) + .output( + z.object({ + billingFields: z.any().describe('Deepgram billing field metadata.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.getBillingFields(ctx.input.projectId, { + start: ctx.input.start, + end: ctx.input.end + }); + + return { + output: { billingFields: result }, + message: `Retrieved billing fields for project **${ctx.input.projectId}**.` + }; + }) + .build(); + +export let listPurchasesTool = SlateTool.create(spec, { + name: 'List Purchases', + key: 'list_purchases', + description: `List purchase/order records for a Deepgram project. Useful for billing reconciliation and balance audit workflows.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + limit: z + .number() + .min(1) + .max(1000) + .optional() + .describe('Maximum number of purchases to return. Deepgram documents 1-1000.') + }) + ) + .output( + z.object({ + purchases: z.array(z.any()).describe('Project purchase/order records.'), + metadata: z.any().optional().describe('Raw Deepgram purchases response.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.listPurchases(ctx.input.projectId, { + limit: ctx.input.limit + }); + let purchases = result.orders || result.purchases || []; + + return { + output: { + purchases, + metadata: result + }, + message: `Retrieved **${purchases.length}** purchase record(s) for project **${ctx.input.projectId}**.` + }; + }) + .build(); diff --git a/integrations/deepgram/src/tools/index.ts b/integrations/deepgram/src/tools/index.ts index c1cae60ded..4bb5830642 100644 --- a/integrations/deepgram/src/tools/index.ts +++ b/integrations/deepgram/src/tools/index.ts @@ -1,6 +1,7 @@ export * from './analyze-text'; export * from './get-usage'; export * from './list-models'; +export * from './manage-auth'; export * from './manage-keys'; export * from './manage-members'; export * from './manage-projects'; diff --git a/integrations/deepgram/src/tools/list-models.ts b/integrations/deepgram/src/tools/list-models.ts index 069bf060f3..7d0ae608ea 100644 --- a/integrations/deepgram/src/tools/list-models.ts +++ b/integrations/deepgram/src/tools/list-models.ts @@ -6,11 +6,42 @@ import { spec } from '../spec'; let modelSchema = z.object({ modelId: z.string().describe('Unique model identifier.'), name: z.string().optional().describe('Model name.'), + canonicalName: z.string().optional().describe('Canonical model name.'), + architecture: z.string().optional().describe('Model architecture.'), version: z.string().optional().describe('Model version.'), languages: z.array(z.string()).optional().describe('Supported languages.'), + batch: z + .boolean() + .optional() + .describe('Whether the model supports batch/pre-recorded audio.'), + streaming: z.boolean().optional().describe('Whether the model supports streaming audio.'), + formattedOutput: z + .boolean() + .optional() + .describe('Whether the model supports formatted output.'), metadata: z.any().optional().describe('Additional model metadata.') }); +let mapModel = (m: any): z.infer => ({ + modelId: m.uuid || m.model_id || m.canonical_name || '', + name: m.name, + canonicalName: m.canonical_name, + architecture: m.architecture, + version: m.version, + languages: m.languages, + batch: m.batch, + streaming: m.streaming, + formattedOutput: m.formatted_output, + metadata: m.metadata +}); + +let mapModels = (models: any[]): z.infer[] => (models || []).map(mapModel); + +let modelsOutputSchema = z.object({ + sttModels: z.array(modelSchema).optional().describe('Available speech-to-text models.'), + ttsModels: z.array(modelSchema).optional().describe('Available text-to-speech models.') +}); + export let listModelsTool = SlateTool.create(spec, { name: 'List Models', key: 'list_models', @@ -27,27 +58,13 @@ export let listModelsTool = SlateTool.create(spec, { .describe('Include outdated/deprecated models in the results.') }) ) - .output( - z.object({ - sttModels: z.array(modelSchema).optional().describe('Available speech-to-text models.'), - ttsModels: z.array(modelSchema).optional().describe('Available text-to-speech models.') - }) - ) + .output(modelsOutputSchema) .handleInvocation(async ctx => { let client = new DeepgramClient(ctx.auth.token); let result = await client.listModels({ includeOutdated: ctx.input.includeOutdated }); - let mapModels = (models: any[]): z.infer[] => - (models || []).map((m: any) => ({ - modelId: m.uuid || m.model_id || m.canonical_name || '', - name: m.name, - version: m.version, - languages: m.languages, - metadata: m.metadata - })); - let sttModels = mapModels(result.stt || []); let ttsModels = mapModels(result.tts || []); @@ -78,16 +95,81 @@ export let getModelTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new DeepgramClient(ctx.auth.token); let result = await client.getModel(ctx.input.modelId); + let model = mapModel(result.model ?? result); + + return { + output: { + ...model, + modelId: model.modelId || ctx.input.modelId + }, + message: `Retrieved model **${model.name || ctx.input.modelId}**.` + }; + }) + .build(); + +export let listProjectModelsTool = SlateTool.create(spec, { + name: 'List Project Models', + key: 'list_project_models', + description: `List models available to a specific Deepgram project, including custom or non-public models that do not appear in the global model list.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + includeOutdated: z + .boolean() + .optional() + .describe('Include outdated/deprecated models in the results.') + }) + ) + .output(modelsOutputSchema) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.listProjectModels(ctx.input.projectId, { + includeOutdated: ctx.input.includeOutdated + }); + + let sttModels = mapModels(result.stt || []); + let ttsModels = mapModels(result.tts || []); + + return { + output: { + sttModels, + ttsModels + }, + message: `Found **${sttModels.length}** STT model(s) and **${ttsModels.length}** TTS model(s) for project **${ctx.input.projectId}**.` + }; + }) + .build(); + +export let getProjectModelTool = SlateTool.create(spec, { + name: 'Get Project Model', + key: 'get_project_model', + description: `Get detailed metadata for a Deepgram model available to a specific project. Use this for custom or non-public project models.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + modelId: z.string().describe('ID/UUID of the project model to retrieve.') + }) + ) + .output(modelSchema) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.getProjectModel(ctx.input.projectId, ctx.input.modelId); + let model = mapModel(result.model ?? result); return { output: { - modelId: result.uuid || result.model_id || result.canonical_name || ctx.input.modelId, - name: result.name, - version: result.version, - languages: result.languages, - metadata: result.metadata + ...model, + modelId: model.modelId || ctx.input.modelId }, - message: `Retrieved model **${result.name || ctx.input.modelId}**.` + message: `Retrieved project model **${model.name || ctx.input.modelId}**.` }; }) .build(); diff --git a/integrations/deepgram/src/tools/manage-auth.ts b/integrations/deepgram/src/tools/manage-auth.ts new file mode 100644 index 0000000000..230930ec34 --- /dev/null +++ b/integrations/deepgram/src/tools/manage-auth.ts @@ -0,0 +1,48 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DeepgramClient } from '../lib/client'; +import { spec } from '../spec'; + +export let createTemporaryTokenTool = SlateTool.create(spec, { + name: 'Create Temporary Token', + key: 'create_temporary_token', + description: `Create a short-lived Deepgram JWT for client-side or temporary use with core voice APIs. Deepgram temporary tokens do not work with Manage API endpoints.`, + constraints: [ + 'Requires an API key with Member or higher permissions.', + 'The temporary token is intended for short-lived core voice API access, not Deepgram Manage APIs.' + ], + tags: { + destructive: false + } +}) + .input( + z.object({ + ttlSeconds: z + .number() + .min(1) + .max(3600) + .optional() + .describe('Token lifetime in seconds. Deepgram defaults to 30 and allows 1-3600.') + }) + ) + .output( + z.object({ + accessToken: z.string().describe('Temporary Deepgram JWT access token.'), + expiresIn: z.number().optional().describe('Token lifetime in seconds.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.createTemporaryToken({ + ttlSeconds: ctx.input.ttlSeconds + }); + + return { + output: { + accessToken: result.access_token, + expiresIn: result.expires_in + }, + message: `Created temporary Deepgram token${result.expires_in ? ` valid for ${result.expires_in}s` : ''}.` + }; + }) + .build(); diff --git a/integrations/deepgram/src/tools/manage-keys.ts b/integrations/deepgram/src/tools/manage-keys.ts index 354f15add2..d9e3abc5c5 100644 --- a/integrations/deepgram/src/tools/manage-keys.ts +++ b/integrations/deepgram/src/tools/manage-keys.ts @@ -50,6 +50,40 @@ export let listKeysTool = SlateTool.create(spec, { }) .build(); +export let getKeyTool = SlateTool.create(spec, { + name: 'Get API Key', + key: 'get_key', + description: `Get metadata for a specific Deepgram API key. Deepgram does not return the secret key value after creation.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + keyId: z.string().describe('ID of the API key.') + }) + ) + .output(keySchema) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.getKey(ctx.input.projectId, ctx.input.keyId); + let key = result.api_key || result; + + return { + output: { + keyId: key.api_key_id || key.id || ctx.input.keyId, + comment: key.comment, + scopes: key.scopes, + tags: key.tags, + created: key.created, + expirationDate: key.expiration_date + }, + message: `Retrieved API key **${key.comment || ctx.input.keyId}**.` + }; + }) + .build(); + export let createKeyTool = SlateTool.create(spec, { name: 'Create API Key', key: 'create_key', @@ -71,11 +105,7 @@ export let createKeyTool = SlateTool.create(spec, { .array(z.string()) .describe('Permission scopes (e.g., ["member"], ["admin"], ["owner"]).'), tags: z.array(z.string()).optional().describe('Tags for usage tracking.'), - expirationDate: z.string().optional().describe('Expiration date in ISO 8601 format.'), - timeToLiveInSeconds: z - .number() - .optional() - .describe('Time to live in seconds. Alternative to expirationDate.') + expirationDate: z.string().optional().describe('Expiration date in ISO 8601 format.') }) ) .output( @@ -97,20 +127,20 @@ export let createKeyTool = SlateTool.create(spec, { comment: ctx.input.comment, scopes: ctx.input.scopes, tags: ctx.input.tags, - expirationDate: ctx.input.expirationDate, - timeToLiveInSeconds: ctx.input.timeToLiveInSeconds + expirationDate: ctx.input.expirationDate }); + let key = result.api_key || result; return { output: { - keyId: result.api_key_id, - key: result.key, - comment: result.comment, - scopes: result.scopes, - tags: result.tags, - created: result.created + keyId: key.api_key_id || key.id, + key: result.key || key.key, + comment: key.comment, + scopes: key.scopes, + tags: key.tags, + created: key.created }, - message: `Created API key **${result.comment || result.api_key_id}**.` + message: `Created API key **${key.comment || key.api_key_id || key.id}**.` }; }) .build(); diff --git a/integrations/deepgram/src/tools/manage-members.ts b/integrations/deepgram/src/tools/manage-members.ts index 19759184dd..6fc7b4165d 100644 --- a/integrations/deepgram/src/tools/manage-members.ts +++ b/integrations/deepgram/src/tools/manage-members.ts @@ -11,6 +11,28 @@ let memberSchema = z.object({ scopes: z.array(z.string()).optional().describe('Member permission scopes.') }); +let invitationSchema = z.object({ + email: z.string().optional().describe('Invitee email address.'), + scope: z.string().optional().describe('Role or scope attached to the invitation.'), + created: z.string().optional().describe('Invitation creation timestamp.'), + metadata: z.any().optional().describe('Additional Deepgram invitation metadata.') +}); + +let toMember = (m: any): z.infer => ({ + memberId: m.member_id || m.memberId || m.id || '', + email: m.email, + firstName: m.first_name || m.firstName, + lastName: m.last_name || m.lastName, + scopes: m.scopes +}); + +let toInvitation = (invite: any): z.infer => ({ + email: invite.email, + scope: invite.scope, + created: invite.created, + metadata: invite +}); + export let listMembersTool = SlateTool.create(spec, { name: 'List Project Members', key: 'list_members', @@ -32,14 +54,7 @@ export let listMembersTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new DeepgramClient(ctx.auth.token); let result = await client.listMembers(ctx.input.projectId); - - let members = (result.members || []).map((m: any) => ({ - memberId: m.member_id, - email: m.email, - firstName: m.first_name, - lastName: m.last_name, - scopes: m.scopes - })); + let members = (result.members || []).map(toMember); return { output: { members }, @@ -48,6 +63,45 @@ export let listMembersTool = SlateTool.create(spec, { }) .build(); +export let getMemberScopesTool = SlateTool.create(spec, { + name: 'Get Member Scopes', + key: 'get_member_scopes', + description: `Get the role/scopes for a specific Deepgram project member.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + memberId: z.string().describe('ID of the member.') + }) + ) + .output( + z.object({ + memberId: z.string().describe('Member ID.'), + scopes: z.array(z.string()).describe('Member permission scopes.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.getMemberScopes(ctx.input.projectId, ctx.input.memberId); + let scopes = Array.isArray(result.scopes) + ? result.scopes + : typeof result.scope === 'string' + ? [result.scope] + : []; + + return { + output: { + memberId: ctx.input.memberId, + scopes + }, + message: `Retrieved **${scopes.length}** scope(s) for member **${ctx.input.memberId}**.` + }; + }) + .build(); + export let removeMemberTool = SlateTool.create(spec, { name: 'Remove Project Member', key: 'remove_member', @@ -109,6 +163,36 @@ export let updateMemberScopesTool = SlateTool.create(spec, { }) .build(); +export let listInvitationsTool = SlateTool.create(spec, { + name: 'List Project Invitations', + key: 'list_invitations', + description: `List pending invitations for a Deepgram project.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.') + }) + ) + .output( + z.object({ + invitations: z.array(invitationSchema).describe('Pending project invitations.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + let result = await client.listInvitations(ctx.input.projectId); + let invitations = (result.invites || result.invitations || []).map(toInvitation); + + return { + output: { invitations }, + message: `Found **${invitations.length}** pending invitation(s).` + }; + }) + .build(); + export let sendInvitationTool = SlateTool.create(spec, { name: 'Send Project Invitation', key: 'send_invitation', @@ -139,3 +223,33 @@ export let sendInvitationTool = SlateTool.create(spec, { }; }) .build(); + +export let deleteInvitationTool = SlateTool.create(spec, { + name: 'Delete Project Invitation', + key: 'delete_invitation', + description: `Delete a pending Deepgram project invitation by invitee email address.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project.'), + email: z.string().describe('Email address on the invitation to delete.') + }) + ) + .output( + z.object({ + message: z.string().describe('Confirmation message.') + }) + ) + .handleInvocation(async ctx => { + let client = new DeepgramClient(ctx.auth.token); + await client.deleteInvitation(ctx.input.projectId, ctx.input.email); + + return { + output: { message: `Invitation deleted for ${ctx.input.email}.` }, + message: `Deleted invitation for **${ctx.input.email}**.` + }; + }) + .build(); diff --git a/integrations/deepgram/src/tools/manage-projects.ts b/integrations/deepgram/src/tools/manage-projects.ts index cec25f5342..da45d602ee 100644 --- a/integrations/deepgram/src/tools/manage-projects.ts +++ b/integrations/deepgram/src/tools/manage-projects.ts @@ -72,7 +72,7 @@ export let getProjectTool = SlateTool.create(spec, { export let updateProjectTool = SlateTool.create(spec, { name: 'Update Project', key: 'update_project', - description: `Update a Deepgram project's name or company. Provide the fields you want to change.`, + description: `Update a Deepgram project's name.`, tags: { destructive: false } @@ -80,8 +80,7 @@ export let updateProjectTool = SlateTool.create(spec, { .input( z.object({ projectId: z.string().describe('ID of the project to update.'), - name: z.string().optional().describe('New project name.'), - company: z.string().optional().describe('New company name.') + name: z.string().describe('New project name.') }) ) .output( @@ -91,13 +90,12 @@ export let updateProjectTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = new DeepgramClient(ctx.auth.token); - await client.updateProject(ctx.input.projectId, { - name: ctx.input.name, - company: ctx.input.company + let result = await client.updateProject(ctx.input.projectId, { + name: ctx.input.name }); return { - output: { message: 'Project updated successfully.' }, + output: { message: result.message ?? 'Project updated successfully.' }, message: `Updated project **${ctx.input.projectId}**.` }; }) diff --git a/integrations/deepgram/src/tools/text-to-speech.ts b/integrations/deepgram/src/tools/text-to-speech.ts index dfea0977b1..7086787043 100644 --- a/integrations/deepgram/src/tools/text-to-speech.ts +++ b/integrations/deepgram/src/tools/text-to-speech.ts @@ -1,16 +1,17 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { DeepgramClient } from '../lib/client'; +import { deepgramServiceError } from '../lib/errors'; import { spec } from '../spec'; export let textToSpeechTool = SlateTool.create(spec, { name: 'Text to Speech', key: 'text_to_speech', - description: `Convert text into natural-sounding speech audio. Returns base64-encoded audio data. Supports 40+ English voices with localized accents, configurable encoding formats, sample rates, and bit rates.`, + description: `Convert text into natural-sounding speech audio using Deepgram's text-to-speech REST API. Returns generated audio as a Slate attachment and reports only metadata in the structured output.`, instructions: [ - 'The default model is "aura-2-en". Specify a different model for other voices or locales.', - 'Supported encodings include "linear16", "mulaw", "alaw", "mp3", "opus", "flac", "aac".', - 'The audio is returned as base64-encoded data along with its content type.' + 'Specify a TTS model/voice when you need a particular voice or locale.', + 'Supported encodings include "linear16", "mulaw", "alaw", "mp3", "opus", "flac", and "aac".', + 'Use callbackUrl for asynchronous generation; callback requests return metadata without an audio attachment.' ], tags: { readOnly: true @@ -23,35 +24,58 @@ export let textToSpeechTool = SlateTool.create(spec, { .string() .optional() .describe( - 'TTS model/voice to use (e.g., "aura-2-en", "aura-asteria-en", "aura-luna-en"). Defaults to the latest model.' + 'TTS model/voice to use, for example "aura-2-thalia-en" or another Deepgram Aura model.' ), encoding: z .string() .optional() .describe( - 'Audio encoding format (e.g., "mp3", "linear16", "mulaw", "alaw", "opus", "flac", "aac").' + 'Audio encoding format, for example "mp3", "linear16", "mulaw", "alaw", "opus", "flac", or "aac".' ), sampleRate: z .number() .optional() - .describe('Audio sample rate in Hz (e.g., 8000, 16000, 24000, 48000).'), + .describe('Audio sample rate in Hz, for example 8000, 16000, 24000, or 48000.'), bitRate: z.number().optional().describe('Audio bit rate for lossy encodings.'), container: z .string() .optional() - .describe('Audio container format (e.g., "wav", "ogg", "none").') + .describe('Audio container format, for example "wav", "ogg", or "none".'), + speed: z + .number() + .optional() + .describe('Speech speed multiplier supported by Deepgram TTS.'), + tag: z.string().optional().describe('Tag for tracking the request in usage reports.'), + mipOptOut: z.boolean().optional().describe('Opt out of model improvement processing.'), + callbackUrl: z + .string() + .optional() + .describe('Optional callback URL for asynchronous TTS results.'), + callbackMethod: z + .enum(['POST', 'PUT']) + .optional() + .describe('HTTP method Deepgram should use for callback delivery.') }) ) .output( z.object({ - audioBase64: z.string().describe('Base64-encoded audio data.'), contentType: z .string() - .describe('MIME type of the audio (e.g., "audio/mpeg", "audio/wav").'), - requestId: z.string().optional().describe('Unique request identifier.') + .describe('MIME type of the audio attachment or callback response.'), + byteLength: z.number().describe('Size of generated audio in bytes.'), + attachmentCount: z.number().describe('Number of Slate attachments returned.'), + requestId: z.string().optional().describe('Unique request identifier.'), + callbackSubmitted: z + .boolean() + .optional() + .describe('True when Deepgram accepted an asynchronous callback request.') }) ) .handleInvocation(async ctx => { + if (ctx.input.callbackMethod && !ctx.input.callbackUrl) { + throw deepgramServiceError('callbackMethod requires callbackUrl.'); + } + let client = new DeepgramClient(ctx.auth.token); let result = await client.textToSpeech({ @@ -60,19 +84,33 @@ export let textToSpeechTool = SlateTool.create(spec, { encoding: ctx.input.encoding, sampleRate: ctx.input.sampleRate, bitRate: ctx.input.bitRate, - container: ctx.input.container + container: ctx.input.container, + speed: ctx.input.speed, + tag: ctx.input.tag, + mipOptOut: ctx.input.mipOptOut, + callback: ctx.input.callbackUrl, + callbackMethod: ctx.input.callbackMethod }); let textPreview = ctx.input.text.substring(0, 100); if (ctx.input.text.length > 100) textPreview += '...'; + let attachment = result.audioBase64 + ? createBase64Attachment(result.audioBase64, result.contentType) + : undefined; + return { output: { - audioBase64: result.audioBase64, contentType: result.contentType, - requestId: result.requestId + byteLength: result.byteLength, + attachmentCount: attachment ? 1 : 0, + requestId: result.requestId, + callbackSubmitted: result.callbackSubmitted }, - message: `Generated speech audio for: "${textPreview}" (format: ${result.contentType})` + attachments: attachment ? [attachment] : [], + message: result.callbackSubmitted + ? `Submitted asynchronous Deepgram speech request for: "${textPreview}"` + : `Generated speech audio for: "${textPreview}" (format: ${result.contentType})` }; }) .build(); diff --git a/integrations/deepgram/src/tools/transcribe-audio.ts b/integrations/deepgram/src/tools/transcribe-audio.ts index c90bc0af26..614f4c4783 100644 --- a/integrations/deepgram/src/tools/transcribe-audio.ts +++ b/integrations/deepgram/src/tools/transcribe-audio.ts @@ -1,22 +1,35 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DeepgramClient } from '../lib/client'; +import { deepgramServiceError } from '../lib/errors'; import { spec } from '../spec'; +let customModeSchema = z + .enum(['strict', 'extended']) + .describe('How Deepgram should match custom topics or intents.'); + let transcriptionOptionsSchema = z.object({ model: z .string() .optional() .describe( - 'Model to use for transcription (e.g., "nova-3", "nova-2", "whisper-large"). Defaults to the latest general model.' + 'Model to use for transcription (for example, "nova-3", "nova-2", or "whisper-large"). Defaults to the latest general model.' ), + version: z + .string() + .optional() + .describe('Specific model version to use, or "latest" for the latest model version.'), language: z .string() .optional() .describe( - 'BCP-47 language code (e.g., "en", "es", "fr"). If not set, automatic language detection is used.' + 'BCP-47 language code (for example, "en", "es", or "fr"). Leave unset when using detectLanguage.' ), detectLanguage: z.boolean().optional().describe('Enable automatic language detection.'), + detectEntities: z + .boolean() + .optional() + .describe('Extract named entities from submitted audio when supported.'), punctuate: z.boolean().optional().describe('Add punctuation to the transcript.'), smartFormat: z .boolean() @@ -25,16 +38,54 @@ let transcriptionOptionsSchema = z.object({ diarize: z .boolean() .optional() - .describe('Identify and label different speakers in the audio.'), + .describe('Deprecated Deepgram diarization flag. Prefer diarizeModel.'), + diarizeModel: z + .enum(['latest', 'v1', 'v2']) + .optional() + .describe('Enable diarization with a specific Deepgram diarization model version.'), utterances: z .boolean() .optional() - .describe('Segment transcript into utterances (speaker turns).'), + .describe('Segment transcript into utterances, typically speaker turns.'), + dictation: z + .boolean() + .optional() + .describe('Enable dictated formatting commands when supported by the selected model.'), + encoding: z + .string() + .optional() + .describe( + 'Expected input audio encoding, for example "linear16", "flac", "mulaw", or "opus".' + ), + fillerWords: z + .boolean() + .optional() + .describe('Include filler words such as "uh" and "um" when supported.'), + measurements: z + .boolean() + .optional() + .describe('Convert spoken measurements to abbreviations when supported.'), + multichannel: z + .boolean() + .optional() + .describe('Transcribe each audio channel independently.'), + numerals: z + .boolean() + .optional() + .describe('Convert written numbers to numerical format when supported.'), keywords: z .array(z.string()) .optional() - .describe('Keywords to boost recognition for (e.g., ["Deepgram", "AI"]).'), + .describe('Keywords to boost recognition for supported models.'), + keyterms: z + .array(z.string()) + .optional() + .describe('Key term prompting values for Nova-3; sent as Deepgram keyterm parameters.'), search: z.array(z.string()).optional().describe('Terms to search for in the transcript.'), + replace: z + .array(z.string()) + .optional() + .describe('Search/replace terms to apply using Deepgram replace query values.'), summarize: z.boolean().optional().describe('Generate a summary of the transcript.'), topics: z.boolean().optional().describe('Detect topics discussed in the audio.'), intents: z.boolean().optional().describe('Detect intents in the audio.'), @@ -43,8 +94,32 @@ let transcriptionOptionsSchema = z.object({ redact: z .array(z.string()) .optional() - .describe('Types of information to redact (e.g., ["pci", "ssn", "numbers"]).'), - tag: z.string().optional().describe('Tag for tracking the request in usage reports.') + .describe('Types of information to redact, such as "pci", "ssn", or "numbers".'), + customTopics: z + .array(z.string()) + .optional() + .describe('Custom topics to look for when topics is enabled.'), + customTopicMode: customModeSchema.optional(), + customIntents: z + .array(z.string()) + .optional() + .describe('Custom intents to look for when intents is enabled.'), + customIntentMode: customModeSchema.optional(), + profanityFilter: z.boolean().optional().describe('Replace profanity in transcripts.'), + uttSplit: z + .number() + .optional() + .describe('Seconds of silence before Deepgram splits utterances.'), + mipOptOut: z.boolean().optional().describe('Opt out of model improvement processing.'), + tag: z.string().optional().describe('Tag for tracking the request in usage reports.'), + callbackUrl: z + .string() + .optional() + .describe('Optional callback URL for asynchronous transcription results.'), + callbackMethod: z + .enum(['POST', 'PUT']) + .optional() + .describe('HTTP method Deepgram should use for callback delivery.') }); let wordSchema = z.object({ @@ -72,13 +147,39 @@ let channelSchema = z.object({ detectedLanguage: z.string().optional() }); +let validateCustomOptions = (input: z.infer) => { + if ((input.customTopics?.length ?? 0) > 0 && !input.topics) { + throw deepgramServiceError('customTopics requires topics=true.'); + } + + if (input.customTopicMode && (input.customTopics?.length ?? 0) === 0) { + throw deepgramServiceError('customTopicMode requires at least one customTopics value.'); + } + + if ((input.customIntents?.length ?? 0) > 0 && !input.intents) { + throw deepgramServiceError('customIntents requires intents=true.'); + } + + if (input.customIntentMode && (input.customIntents?.length ?? 0) === 0) { + throw deepgramServiceError('customIntentMode requires at least one customIntents value.'); + } + + if (input.callbackMethod && !input.callbackUrl) { + throw deepgramServiceError('callbackMethod requires callbackUrl.'); + } + + if (input.diarize && input.diarizeModel) { + throw deepgramServiceError('Use either diarize or diarizeModel, not both.'); + } +}; + export let transcribeAudioTool = SlateTool.create(spec, { name: 'Transcribe Audio', key: 'transcribe_audio', - description: `Transcribe pre-recorded audio to text. Supports audio from a URL or raw audio data (base64-encoded). Provides options for model selection, language detection, speaker diarization, smart formatting, keyword boosting, and text intelligence features (summarization, topic detection, sentiment analysis). Returns the full transcript with word-level timestamps and confidence scores.`, + description: `Transcribe pre-recorded audio to text from a URL or base64-encoded audio file. Supports model selection, language detection, diarization, smart formatting, keyword or keyterm prompting, callbacks, redaction, and Deepgram text intelligence features such as summarization, topic detection, intent detection, and sentiment analysis.`, instructions: [ - 'Provide either an audioUrl OR audioData+mimetype, not both.', - 'For best results with smart formatting, use the "nova-3" model.', + 'Provide either audioUrl or audioData+mimetype, not both.', + 'Use callbackUrl for asynchronous transcription; callback requests return a requestId instead of transcript results.', 'Enable diarize=true and utterances=true together for speaker-attributed transcripts.' ], tags: { @@ -90,7 +191,7 @@ export let transcribeAudioTool = SlateTool.create(spec, { audioUrl: z .string() .optional() - .describe('URL of the audio file to transcribe. Use this OR audioData, not both.'), + .describe('URL of the audio file to transcribe. Use this or audioData, not both.'), audioData: z .string() .optional() @@ -99,7 +200,7 @@ export let transcribeAudioTool = SlateTool.create(spec, { .string() .optional() .describe( - 'MIME type of the audio data (e.g., "audio/wav", "audio/mp3"). Required when using audioData.' + 'MIME type of the audio data, for example "audio/wav" or "audio/mpeg". Required when using audioData.' ), ...transcriptionOptionsSchema.shape }) @@ -107,6 +208,10 @@ export let transcribeAudioTool = SlateTool.create(spec, { .output( z.object({ requestId: z.string().optional().describe('Unique request identifier.'), + callbackSubmitted: z + .boolean() + .optional() + .describe('True when Deepgram accepted an asynchronous callback request.'), transcript: z .string() .describe('Full transcript text from the primary channel/alternative.'), @@ -159,56 +264,81 @@ export let transcribeAudioTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new DeepgramClient(ctx.auth.token); - if (!ctx.input.audioUrl && !ctx.input.audioData) { - throw new Error('Either audioUrl or audioData must be provided.'); + let hasAudioUrl = Boolean(ctx.input.audioUrl); + let hasAudioData = Boolean(ctx.input.audioData); + + if (hasAudioUrl === hasAudioData) { + throw deepgramServiceError('Provide exactly one of audioUrl or audioData.'); } - let result: any; + if (hasAudioData && !ctx.input.mimetype) { + throw deepgramServiceError('mimetype is required when providing audioData.'); + } + + validateCustomOptions(ctx.input); + + let commonParams = { + model: ctx.input.model, + version: ctx.input.version, + language: ctx.input.language, + detectLanguage: ctx.input.detectLanguage, + detectEntities: ctx.input.detectEntities, + punctuate: ctx.input.punctuate, + smartFormat: ctx.input.smartFormat, + diarize: ctx.input.diarize, + diarizeModel: ctx.input.diarizeModel, + utterances: ctx.input.utterances, + dictation: ctx.input.dictation, + encoding: ctx.input.encoding, + fillerWords: ctx.input.fillerWords, + measurements: ctx.input.measurements, + multichannel: ctx.input.multichannel, + numerals: ctx.input.numerals, + keywords: ctx.input.keywords, + keyterms: ctx.input.keyterms, + search: ctx.input.search, + replace: ctx.input.replace, + summarize: ctx.input.summarize, + topics: ctx.input.topics, + intents: ctx.input.intents, + sentiment: ctx.input.sentiment, + paragraphs: ctx.input.paragraphs, + redact: ctx.input.redact, + customTopics: ctx.input.customTopics, + customTopicMode: ctx.input.customTopicMode, + customIntents: ctx.input.customIntents, + customIntentMode: ctx.input.customIntentMode, + profanityFilter: ctx.input.profanityFilter, + uttSplit: ctx.input.uttSplit, + mipOptOut: ctx.input.mipOptOut, + tag: ctx.input.tag, + callback: ctx.input.callbackUrl, + callbackMethod: ctx.input.callbackMethod + }; + + let result: any = hasAudioUrl + ? await client.transcribeUrl({ + url: ctx.input.audioUrl!, + ...commonParams + }) + : await client.transcribeAudio({ + audioData: ctx.input.audioData!, + mimetype: ctx.input.mimetype!, + ...commonParams + }); + + let requestId = result.metadata?.request_id || result.request_id; - if (ctx.input.audioUrl) { - result = await client.transcribeUrl({ - url: ctx.input.audioUrl, - model: ctx.input.model, - language: ctx.input.language, - detectLanguage: ctx.input.detectLanguage, - punctuate: ctx.input.punctuate, - smartFormat: ctx.input.smartFormat, - diarize: ctx.input.diarize, - utterances: ctx.input.utterances, - keywords: ctx.input.keywords, - search: ctx.input.search, - summarize: ctx.input.summarize, - topics: ctx.input.topics, - intents: ctx.input.intents, - sentiment: ctx.input.sentiment, - paragraphs: ctx.input.paragraphs, - redact: ctx.input.redact, - tag: ctx.input.tag - }); - } else { - if (!ctx.input.mimetype) { - throw new Error('mimetype is required when providing audioData.'); - } - result = await client.transcribeAudio({ - audioData: ctx.input.audioData!, - mimetype: ctx.input.mimetype, - model: ctx.input.model, - language: ctx.input.language, - detectLanguage: ctx.input.detectLanguage, - punctuate: ctx.input.punctuate, - smartFormat: ctx.input.smartFormat, - diarize: ctx.input.diarize, - utterances: ctx.input.utterances, - keywords: ctx.input.keywords, - search: ctx.input.search, - summarize: ctx.input.summarize, - topics: ctx.input.topics, - intents: ctx.input.intents, - sentiment: ctx.input.sentiment, - paragraphs: ctx.input.paragraphs, - redact: ctx.input.redact, - tag: ctx.input.tag - }); + if (ctx.input.callbackUrl && !result.results) { + return { + output: { + requestId, + callbackSubmitted: true, + transcript: '', + metadata: result.metadata ?? result + }, + message: `Submitted asynchronous Deepgram transcription request${requestId ? ` **${requestId}**` : ''}.` + }; } let firstChannel = result.results?.channels?.[0]; @@ -268,7 +398,8 @@ export let transcribeAudioTool = SlateTool.create(spec, { return { output: { - requestId: result.metadata?.request_id, + requestId, + callbackSubmitted: false, transcript, confidence: firstAlt?.confidence, words, diff --git a/integrations/deepgram/vitest.config.ts b/integrations/deepgram/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/deepgram/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/deepseek/README.md b/integrations/deepseek/README.md index 8365832a87..a340f7153a 100644 --- a/integrations/deepseek/README.md +++ b/integrations/deepseek/README.md @@ -1,6 +1,6 @@ -# Deep Seek +# DeepSeek -Generate chat completions, chain-of-thought reasoning responses, and code completions using DeepSeek large language models. Supports streaming and non-streaming chat, structured JSON output, function calling with up to 128 parallel tool invocations, fill-in-the-middle (FIM) code completion, and chat prefix completion. List available models and check account balance. Compatible with OpenAI API format. +Generate chat completions, thinking-mode reasoning responses, and code completions using DeepSeek V4 models. Supports non-streaming chat, structured JSON output, function calling with up to 128 tool definitions, beta strict tool-call mode, beta fill-in-the-middle (FIM) completion, and beta chat prefix completion. List available models and check account balance. Compatible with the OpenAI-format DeepSeek API. ## License diff --git a/integrations/deepseek/docs/SPEC.md b/integrations/deepseek/docs/SPEC.md index b702a50998..d2b18c688c 100644 --- a/integrations/deepseek/docs/SPEC.md +++ b/integrations/deepseek/docs/SPEC.md @@ -1,73 +1,58 @@ -Now let me check the API reference for the full list of endpoints and the balance/models endpoints:I now have enough information to write the specification. - # Slates Specification for DeepSeek ## Overview -DeepSeek is an AI company that provides cloud-based API access to its large language models for chat, reasoning, and code completion tasks. The DeepSeek API uses an API format compatible with OpenAI, making it straightforward to integrate using existing OpenAI-compatible SDKs and tooling. The two primary model identifiers are `deepseek-chat` (non-thinking mode) and `deepseek-reasoner` (thinking mode), both corresponding to DeepSeek-V3.2 with a 128K context limit. +DeepSeek provides OpenAI-compatible API access to DeepSeek V4 language models for +chat, thinking-mode reasoning, tool calling, structured JSON output, and beta code +completion workflows. ## Authentication -The primary and sole method for authenticating with the DeepSeek API is through an API key. There is no support for OAuth 2.0 or other complex authentication protocols. - -- **Obtaining an API key**: Log in to your DeepSeek account (or create one) at `platform.deepseek.com`, open the API keys page, and select "Create new secret key". Once created, copy and store the key securely. For security reasons, you will not be able to view it again through the platform. If you lose it, you must create a new one. -- **Using the API key**: Pass it as a Bearer token in the `Authorization` header: `Authorization: Bearer YOUR_API_KEY` -- **Base URL**: `https://api.deepseek.com` (or `https://api.deepseek.com/v1` for OpenAI compatibility — the `v1` does not indicate model version) -- **Compatibility**: Because DeepSeek's API is compatible with OpenAI's, you can use OpenAI's SDKs by changing the `base_url` and providing your DeepSeek API key. - -## Features - -### Chat Completion - -Generate conversational responses using DeepSeek's language models. Send a list of messages with roles (`system`, `user`, `assistant`) and receive a model-generated response. Supports both streaming and non-streaming modes. - -- **Models**: `deepseek-chat` for general-purpose chat; `deepseek-reasoner` for chain-of-thought reasoning. -- **Key parameters**: `temperature`, `top_p`, `max_tokens`, `frequency_penalty`, `presence_penalty`, `stop` sequences. -- Supports JSON output mode for structured, machine-readable responses, configured via `response_format`. - -### Thinking Mode (Chain-of-Thought Reasoning) - -`deepseek-reasoner` is the thinking mode of DeepSeek-V3.2, where the model outputs step-by-step reasoning (`reasoning_content`) before the final answer (`content`). Can also be enabled via the `thinking` parameter on `deepseek-chat`. +DeepSeek uses API-key authentication. The API key is sent as a bearer token in the +`Authorization` header. -- The reasoning content is returned as a separate field and is not concatenated into multi-turn context by default. -- Supports tool calls within the thinking process, allowing multi-step reasoning combined with external tool invocations. -- Does not support `temperature`, `top_p`, `presence_penalty`, `frequency_penalty`, or `logprobs` parameters. +## Base URLs -### Tool Calls (Function Calling) +- OpenAI-format stable API: `https://api.deepseek.com` +- OpenAI-format beta API: `https://api.deepseek.com/beta` -DeepSeek API supports Function Calling, compatible with OpenAI API, allowing the model to interact with the physical world via external tools. Function Calling supports multiple functions in one call (up to 128) and supports parallel function calls. +The integration exposes the stable base URL as package config. Beta-only tools and +features route to the beta base URL internally. -- Define tools with JSON Schema descriptions and the model will produce structured calls when appropriate. -- Works in both standard chat mode and thinking mode. +## Supported Models -### FIM Completion (Beta) +- `deepseek-v4-flash` +- `deepseek-v4-pro` -In FIM (Fill In the Middle) completion, users can provide a prefix and a suffix (optional), and the model will complete the content in between. FIM is commonly used for content completion and code completion. +The older `deepseek-chat` and `deepseek-reasoner` aliases are deprecated by +DeepSeek and should not be used for new tool calls. -- Requires setting `base_url` to `https://api.deepseek.com/beta` to enable this Beta feature. -- Not available in thinking mode. +## Tools -### Chat Prefix Completion (Beta) +### `chat_completion` -Chat Prefix Completion follows the API format of Chat Completion, allowing users to specify the prefix of the last assistant message for the model to complete. This feature can also be used to concatenate messages that were truncated due to reaching the `max_tokens` limit. +Creates a non-streaming chat completion with optional thinking mode, reasoning +effort, JSON output, function/tool definitions, beta strict tool-call mode, +logprob output, and `user_id` isolation. -- Also requires the Beta base URL. +### `chat_prefix_completion` -### Context Caching +Uses the beta chat prefix completion feature. The tool builds a final assistant +message with `prefix=true` and sends the request to the beta API. -When you send a request, DeepSeek stores the input on its servers. If you send a similar request later, the system retrieves the stored response instead of recalculating everything. This makes the process faster and cheaper. +### `fim_completion` -- Most effective when reused text is placed at the beginning of inputs. -- Useful for multi-turn conversations, repeated queries, few-shot learning prompts, and code analysis with shared context. +Uses the beta FIM completion endpoint. The current DeepSeek API accepts +`deepseek-v4-pro` for FIM and limits generation to 4096 tokens. -### Model Listing +### `list_models` -List all currently available models along with basic metadata such as model ID and ownership. +Lists available DeepSeek models and their owner metadata. -### Account Balance +### `get_balance` -Retrieve the current account balance and usage information programmatically. +Retrieves the current account balance and account availability status. -## Events +## Triggers -The provider does not support events. DeepSeek does not offer webhooks, event subscriptions, or purpose-built polling mechanisms through its API. +DeepSeek does not provide provider-native webhooks or polling event APIs. diff --git a/integrations/deepseek/package.json b/integrations/deepseek/package.json index 42baa7823a..67114c7a3a 100644 --- a/integrations/deepseek/package.json +++ b/integrations/deepseek/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/deepseek/slate.json b/integrations/deepseek/slate.json index 7ea843ce43..49a645becf 100644 --- a/integrations/deepseek/slate.json +++ b/integrations/deepseek/slate.json @@ -1,13 +1,12 @@ { "name": "@metorial/deepseek", - "description": "Generate chat completions, chain-of-thought reasoning responses, and code completions using DeepSeek large language models. Supports streaming and non-streaming chat, structured JSON output, function calling with up to 128 parallel tool invocations, fill-in-the-middle (FIM) code completion, and chat prefix completion. List available models and check account balance. Compatible with OpenAI API format.", + "description": "Generate non-streaming chat completions, thinking-mode reasoning responses, and code completions using DeepSeek V4 models. Supports structured JSON output, function calling with up to 128 tool definitions, beta strict tool-call mode, beta FIM completion, and beta chat prefix completion. List available models and check account balance. Compatible with the OpenAI-format DeepSeek API.", "categories": ["apis-and-http-requests"], "skills": [ "generate chat completions", - "chain-of-thought reasoning", + "thinking-mode reasoning", "call external functions", "fill-in-the-middle completion", - "stream model responses", "generate structured JSON output", "list available models", "check account balance", diff --git a/integrations/deepseek/src/index.ts b/integrations/deepseek/src/index.ts index bd595b0cac..1f48bc1fbb 100644 --- a/integrations/deepseek/src/index.ts +++ b/integrations/deepseek/src/index.ts @@ -1,11 +1,17 @@ import { Slate } from 'slates'; import { spec } from './spec'; -import { chatCompletion, fimCompletion, getBalance, listModels } from './tools'; +import { + chatCompletion, + chatPrefixCompletion, + fimCompletion, + getBalance, + listModels +} from './tools'; import { inboundWebhook } from './triggers/inbound-webhook'; export let provider = Slate.create({ spec, - tools: [chatCompletion, fimCompletion, listModels, getBalance], + tools: [chatCompletion, chatPrefixCompletion, fimCompletion, listModels, getBalance], triggers: [inboundWebhook] }); diff --git a/integrations/deepseek/src/lib/client.ts b/integrations/deepseek/src/lib/client.ts index 18b509951b..289011ded3 100644 --- a/integrations/deepseek/src/lib/client.ts +++ b/integrations/deepseek/src/lib/client.ts @@ -1,4 +1,5 @@ -import { createAxios } from 'slates'; +import { createAxios, pickDefined } from 'slates'; +import { deepSeekApiError } from './errors'; import type { ChatCompletionRequest, ChatCompletionResponse, @@ -21,70 +22,84 @@ export class DeepSeekClient { }); } - async createChatCompletion(request: ChatCompletionRequest): Promise { - let body: Record = { - model: request.model, - messages: request.messages, - stream: false - }; - - if (request.temperature !== undefined) body.temperature = request.temperature; - if (request.top_p !== undefined) body.top_p = request.top_p; - if (request.max_tokens !== undefined) body.max_tokens = request.max_tokens; - if (request.frequency_penalty !== undefined) - body.frequency_penalty = request.frequency_penalty; - if (request.presence_penalty !== undefined) - body.presence_penalty = request.presence_penalty; - if (request.stop !== undefined) body.stop = request.stop; - if (request.response_format !== undefined) body.response_format = request.response_format; - if (request.tools !== undefined) body.tools = request.tools; - if (request.tool_choice !== undefined) body.tool_choice = request.tool_choice; - if (request.thinking !== undefined) body.thinking = request.thinking; - if (request.logprobs !== undefined) body.logprobs = request.logprobs; - if (request.top_logprobs !== undefined) body.top_logprobs = request.top_logprobs; - - let response = await this.axios.post('/chat/completions', body); - return response.data as ChatCompletionResponse; - } - - async createFimCompletion(request: FimCompletionRequest): Promise { - let betaAxios = createAxios({ + private createBetaAxios() { + return createAxios({ baseURL: 'https://api.deepseek.com/beta', headers: { Authorization: `Bearer ${this.params.token}`, 'Content-Type': 'application/json' } }); + } - let body: Record = { + async createChatCompletion( + request: ChatCompletionRequest, + options?: { beta?: boolean } + ): Promise { + let body: Record = pickDefined({ model: request.model, - prompt: request.prompt, - stream: false - }; + messages: request.messages, + stream: false, + temperature: request.temperature, + top_p: request.top_p, + max_tokens: request.max_tokens, + stop: request.stop, + response_format: request.response_format, + tools: request.tools, + tool_choice: request.tool_choice, + thinking: request.thinking, + reasoning_effort: request.reasoning_effort, + logprobs: request.logprobs, + top_logprobs: request.top_logprobs, + user_id: request.user_id + }); + + try { + let axios = options?.beta ? this.createBetaAxios() : this.axios; + let response = await axios.post('/chat/completions', body); + return response.data as ChatCompletionResponse; + } catch (error) { + throw deepSeekApiError(error, 'create chat completion'); + } + } - if (request.suffix !== undefined) body.suffix = request.suffix; - if (request.echo !== undefined) body.echo = request.echo; - if (request.max_tokens !== undefined) body.max_tokens = request.max_tokens; - if (request.temperature !== undefined) body.temperature = request.temperature; - if (request.top_p !== undefined) body.top_p = request.top_p; - if (request.frequency_penalty !== undefined) - body.frequency_penalty = request.frequency_penalty; - if (request.presence_penalty !== undefined) - body.presence_penalty = request.presence_penalty; - if (request.stop !== undefined) body.stop = request.stop; - if (request.logprobs !== undefined) body.logprobs = request.logprobs; + async createFimCompletion(request: FimCompletionRequest): Promise { + let body: Record = pickDefined({ + model: request.model, + prompt: request.prompt, + stream: false, + suffix: request.suffix, + echo: request.echo, + max_tokens: request.max_tokens, + temperature: request.temperature, + top_p: request.top_p, + stop: request.stop, + logprobs: request.logprobs + }); - let response = await betaAxios.post('/completions', body); - return response.data as FimCompletionResponse; + try { + let response = await this.createBetaAxios().post('/completions', body); + return response.data as FimCompletionResponse; + } catch (error) { + throw deepSeekApiError(error, 'create FIM completion'); + } } async listModels(): Promise { - let response = await this.axios.get('/models'); - return response.data as ListModelsResponse; + try { + let response = await this.axios.get('/models'); + return response.data as ListModelsResponse; + } catch (error) { + throw deepSeekApiError(error, 'list models'); + } } async getBalance(): Promise { - let response = await this.axios.get('/user/balance'); - return response.data as GetBalanceResponse; + try { + let response = await this.axios.get('/user/balance'); + return response.data as GetBalanceResponse; + } catch (error) { + throw deepSeekApiError(error, 'get user balance'); + } } } diff --git a/integrations/deepseek/src/lib/errors.ts b/integrations/deepseek/src/lib/errors.ts new file mode 100644 index 0000000000..eac4d06196 --- /dev/null +++ b/integrations/deepseek/src/lib/errors.ts @@ -0,0 +1,84 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let extractDeepSeekMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let messages: string[] = []; + + if (isRecord(data)) { + addMessage(messages, data.message); + addMessage(messages, data.error); + addMessage(messages, data.error_msg); + + if (isRecord(data.error)) { + addMessage(messages, data.error.message); + addMessage(messages, data.error.type); + addMessage(messages, data.error.code); + } + } else { + addMessage(messages, data); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getDeepSeekErrorStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + let status = response?.status ?? error.status; + return typeof status === 'number' || typeof status === 'string' ? status : undefined; +}; + +export let deepSeekServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let deepSeekApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getDeepSeekErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = deepSeekServiceError( + `DeepSeek API ${operation} failed: ${statusLabel}${extractDeepSeekMessage(error)}` + ); + serviceError.data.reason = 'deepseek_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/deepseek/src/lib/types.ts b/integrations/deepseek/src/lib/types.ts index 7023aeb690..c24663a4f5 100644 --- a/integrations/deepseek/src/lib/types.ts +++ b/integrations/deepseek/src/lib/types.ts @@ -1,11 +1,13 @@ // Chat Completion types export type ChatMessage = - | { role: 'system'; content: string } - | { role: 'user'; content: string } + | { role: 'system'; content: string; name?: string } + | { role: 'user'; content: string; name?: string } | { role: 'assistant'; content: string | null; + name?: string; + prefix?: boolean; reasoning_content?: string; tool_calls?: ToolCall[]; } @@ -26,6 +28,7 @@ export type ToolDefinition = { name: string; description?: string; parameters?: Record; + strict?: boolean; }; }; @@ -35,7 +38,6 @@ export type ResponseFormat = { export type ThinkingConfig = { type: 'enabled' | 'disabled'; - budget_tokens?: number; }; export type ChatCompletionRequest = { @@ -44,16 +46,16 @@ export type ChatCompletionRequest = { temperature?: number; top_p?: number; max_tokens?: number; - frequency_penalty?: number; - presence_penalty?: number; stop?: string | string[]; stream?: boolean; response_format?: ResponseFormat; tools?: ToolDefinition[]; tool_choice?: string | { type: 'function'; function: { name: string } }; thinking?: ThinkingConfig; + reasoning_effort?: 'high' | 'max'; logprobs?: boolean; top_logprobs?: number; + user_id?: string; }; export type ChatCompletionChoice = { @@ -99,8 +101,6 @@ export type FimCompletionRequest = { max_tokens?: number; temperature?: number; top_p?: number; - frequency_penalty?: number; - presence_penalty?: number; stop?: string | string[]; logprobs?: number; stream?: boolean; diff --git a/integrations/deepseek/src/spec.ts b/integrations/deepseek/src/spec.ts index 272d3ca449..4de4d2fb79 100644 --- a/integrations/deepseek/src/spec.ts +++ b/integrations/deepseek/src/spec.ts @@ -6,7 +6,7 @@ export let spec = SlateSpecification.create({ key: 'deepseek', name: 'DeepSeek', description: - 'AI company providing cloud-based API access to large language models for chat, reasoning, and code completion. Compatible with OpenAI API format.', + 'AI company providing OpenAI-compatible API access to DeepSeek V4 models for chat, thinking-mode reasoning, tool calling, and code completion.', metadata: {}, config, auth diff --git a/integrations/deepseek/src/tools/chat-completion.ts b/integrations/deepseek/src/tools/chat-completion.ts index 630c6ee9c7..482d4ca622 100644 --- a/integrations/deepseek/src/tools/chat-completion.ts +++ b/integrations/deepseek/src/tools/chat-completion.ts @@ -1,24 +1,30 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DeepSeekClient } from '../lib/client'; +import { deepSeekServiceError } from '../lib/errors'; import { spec } from '../spec'; +let modelSchema = z.enum(['deepseek-v4-flash', 'deepseek-v4-pro']); + let messageSchema = z.discriminatedUnion('role', [ z.object({ role: z.literal('system'), - content: z.string().describe('System prompt content') + content: z.string().describe('System prompt content'), + name: z.string().optional().describe('Optional participant name') }), z.object({ role: z.literal('user'), - content: z.string().describe('User message content') + content: z.string().describe('User message content'), + name: z.string().optional().describe('Optional participant name') }), z.object({ role: z.literal('assistant'), content: z.string().nullable().describe('Assistant message content'), + name: z.string().optional().describe('Optional participant name'), reasoningContent: z .string() .optional() - .describe('Previous reasoning content from the assistant (for multi-turn reasoning)'), + .describe('Previous reasoning content from the assistant for thinking-mode tool turns'), toolCalls: z .array( z.object({ @@ -43,108 +49,199 @@ let toolDefinitionSchema = z.object({ parameters: z .record(z.string(), z.unknown()) .optional() - .describe('JSON Schema describing the function parameters') + .describe('JSON Schema object describing the function parameters'), + strict: z + .boolean() + .optional() + .describe('Use DeepSeek beta strict-mode validation for this function definition') }); +let stopSchema = z + .union([z.string(), z.array(z.string()).max(16)]) + .optional() + .describe('Stop sequences that halt generation. Arrays are limited to 16 strings.'); + +let chatCompletionInputSchema = z.object({ + model: modelSchema.default('deepseek-v4-flash').describe('DeepSeek V4 model to use'), + messages: z.array(messageSchema).min(1).describe('Conversation messages'), + thinkingMode: z + .enum(['disabled', 'enabled']) + .default('disabled') + .describe('Controls DeepSeek V4 thinking mode. Use enabled for reasoning tasks.'), + reasoningEffort: z + .enum(['high', 'max']) + .optional() + .describe('Reasoning effort when thinkingMode is enabled'), + temperature: z + .number() + .min(0) + .max(2) + .optional() + .describe('Sampling temperature (0-2). Only applies when thinkingMode is disabled.'), + topP: z + .number() + .min(0) + .max(1) + .optional() + .describe('Nucleus sampling parameter (0-1). Only applies when thinkingMode is disabled.'), + maxTokens: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of tokens to generate'), + stop: stopSchema, + responseFormat: z + .enum(['text', 'json_object']) + .optional() + .describe('Output format. Use json_object for strict JSON text responses.'), + tools: z + .array(toolDefinitionSchema) + .max(128) + .optional() + .describe('Available function definitions the model may call'), + toolChoice: z + .union([ + z.enum(['none', 'auto', 'required']), + z.object({ + functionName: z.string().describe('Force the model to call this specific function') + }) + ]) + .optional() + .describe('Controls tool invocation behavior'), + strictToolCalls: z + .boolean() + .optional() + .describe( + 'Use DeepSeek beta strict mode for tool calls. All outbound tool definitions are sent with strict=true.' + ), + logprobs: z + .boolean() + .optional() + .describe('Whether to return log probabilities of output tokens'), + topLogprobs: z + .number() + .int() + .min(0) + .max(20) + .optional() + .describe('Number of most likely tokens to return per position (0-20)'), + userId: z + .string() + .max(512) + .regex(/^[a-zA-Z0-9_-]+$/) + .optional() + .describe( + 'Optional privacy-safe user identifier for safety, cache, and scheduling isolation' + ) +}); + +type ChatCompletionInput = z.infer; + +let assertValidChatInput = (input: ChatCompletionInput) => { + if (input.reasoningEffort && input.thinkingMode !== 'enabled') { + throw deepSeekServiceError( + 'reasoningEffort can only be used when thinkingMode is enabled.' + ); + } + + if ( + input.thinkingMode === 'enabled' && + (input.temperature !== undefined || input.topP !== undefined) + ) { + throw deepSeekServiceError( + 'temperature and topP are only supported when thinkingMode is disabled.' + ); + } + + if (input.responseFormat === 'json_object') { + let hasJsonInstruction = input.messages.some( + message => + (message.role === 'system' || message.role === 'user') && + message.content.toLowerCase().includes('json') + ); + + if (!hasJsonInstruction) { + throw deepSeekServiceError( + 'responseFormat=json_object requires a system or user message that explicitly asks for JSON.' + ); + } + } + + if (input.topLogprobs !== undefined && input.logprobs !== true) { + throw deepSeekServiceError('topLogprobs requires logprobs=true.'); + } + + let hasStrictTool = input.tools?.some(tool => tool.strict === true) ?? false; + let useStrictToolCalls = input.strictToolCalls === true || hasStrictTool; + if (!useStrictToolCalls) return; + + if (!input.tools || input.tools.length === 0) { + throw deepSeekServiceError('strictToolCalls requires at least one tool definition.'); + } + + if (hasStrictTool && !input.tools.every(tool => tool.strict === true)) { + throw deepSeekServiceError( + 'DeepSeek strict tool-call mode requires every provided tool definition to set strict=true.' + ); + } +}; + +let toApiMessages = (messages: ChatCompletionInput['messages']) => + messages.map(msg => { + if (msg.role === 'assistant') { + let assistantMsg: Record = { + role: 'assistant', + content: msg.content + }; + if (msg.name) assistantMsg.name = msg.name; + if (msg.reasoningContent) assistantMsg.reasoning_content = msg.reasoningContent; + if (msg.toolCalls && msg.toolCalls.length > 0) { + assistantMsg.tool_calls = msg.toolCalls.map(tc => ({ + id: tc.toolCallId, + type: 'function' as const, + function: { + name: tc.functionName, + arguments: tc.arguments + } + })); + } + return assistantMsg; + } + + if (msg.role === 'tool') { + return { + role: 'tool' as const, + content: msg.content, + tool_call_id: msg.toolCallId + }; + } + + return msg; + }); + export let chatCompletion = SlateTool.create(spec, { name: 'Chat Completion', key: 'chat_completion', - description: `Generate a conversational response using DeepSeek's language models. Supports general-purpose chat (\`deepseek-chat\`) and chain-of-thought reasoning (\`deepseek-reasoner\`). -Enables function calling with tool definitions, structured JSON output via response format, and thinking mode for step-by-step reasoning.`, + description: `Generate a conversational response using DeepSeek V4 models. Supports non-thinking chat, thinking-mode reasoning, JSON output, and function/tool calling.`, instructions: [ - 'Use `deepseek-chat` for general-purpose conversations and `deepseek-reasoner` for tasks requiring step-by-step reasoning.', - 'When using `deepseek-reasoner` or enabling thinking, `temperature`, `topP`, `frequencyPenalty`, `presencePenalty`, and `logprobs` are not supported.', - 'Set `responseFormat` to `json_object` to receive structured JSON output. Ensure the system or user message instructs the model to produce JSON.', - 'For multi-turn conversations with reasoning, pass the previous `reasoningContent` back in the assistant message.' + 'Use `deepseek-v4-flash` for lower-latency and lower-cost chat, and `deepseek-v4-pro` for higher-capability reasoning or code tasks.', + 'Set `thinkingMode` to `enabled` for reasoning tasks and optionally set `reasoningEffort` to `high` or `max`.', + 'Set `responseFormat` to `json_object` only when the system or user message explicitly asks for JSON.', + 'When continuing a thinking-mode tool-call conversation, pass prior assistant `reasoningContent` back with the assistant message.' ], constraints: [ - 'Maximum context length is 128K tokens.', + 'Current DeepSeek V4 models support a 1M token context length.', 'Up to 128 tool definitions can be provided.', - 'Stop sequences are limited to 16.' + 'Stop sequences are limited to 16.', + '`temperature` and `topP` only apply when `thinkingMode` is disabled.' ], tags: { destructive: false, readOnly: false } }) - .input( - z.object({ - model: z - .enum(['deepseek-chat', 'deepseek-reasoner']) - .default('deepseek-chat') - .describe('Model to use for the completion'), - messages: z.array(messageSchema).min(1).describe('Conversation messages'), - temperature: z - .number() - .min(0) - .max(2) - .optional() - .describe('Sampling temperature (0-2). Higher values produce more random output.'), - topP: z.number().min(0).max(1).optional().describe('Nucleus sampling parameter (0-1)'), - maxTokens: z - .number() - .int() - .positive() - .optional() - .describe('Maximum number of tokens to generate'), - frequencyPenalty: z - .number() - .min(-2) - .max(2) - .optional() - .describe('Penalizes repeated tokens (-2 to 2)'), - presencePenalty: z - .number() - .min(-2) - .max(2) - .optional() - .describe('Encourages new topics (-2 to 2)'), - stop: z - .union([z.string(), z.array(z.string())]) - .optional() - .describe('Stop sequences that halt generation'), - responseFormat: z - .enum(['text', 'json_object']) - .optional() - .describe('Output format. Use json_object for structured JSON responses.'), - tools: z - .array(toolDefinitionSchema) - .optional() - .describe('Available tool/function definitions the model may call'), - toolChoice: z - .union([ - z.enum(['none', 'auto', 'required']), - z.object({ - functionName: z.string().describe('Force the model to call this specific function') - }) - ]) - .optional() - .describe('Controls tool invocation behavior'), - enableThinking: z - .boolean() - .optional() - .describe( - 'Enable thinking/reasoning mode on deepseek-chat. Automatically enabled for deepseek-reasoner.' - ), - thinkingBudgetTokens: z - .number() - .int() - .positive() - .optional() - .describe('Maximum tokens for the thinking/reasoning process'), - logprobs: z - .boolean() - .optional() - .describe('Whether to return log probabilities of output tokens'), - topLogprobs: z - .number() - .int() - .min(0) - .max(20) - .optional() - .describe('Number of most likely tokens to return per position (0-20)') - }) - ) + .input(chatCompletionInputSchema) .output( z.object({ completionId: z.string().describe('Unique identifier for this completion'), @@ -154,7 +251,7 @@ Enables function calling with tool definitions, structured JSON output via respo .string() .nullable() .optional() - .describe('Step-by-step reasoning content (when using thinking mode)'), + .describe('Step-by-step reasoning content when thinking mode is enabled'), finishReason: z .string() .describe( @@ -185,48 +282,24 @@ Enables function calling with tool definitions, structured JSON output via respo }) ) .handleInvocation(async ctx => { + assertValidChatInput(ctx.input); + let client = new DeepSeekClient({ token: ctx.auth.token, baseUrl: ctx.config.baseUrl }); - let apiMessages = ctx.input.messages.map(msg => { - if (msg.role === 'assistant') { - let assistantMsg: Record = { - role: 'assistant', - content: msg.content - }; - if (msg.reasoningContent) { - assistantMsg.reasoning_content = msg.reasoningContent; - } - if (msg.toolCalls && msg.toolCalls.length > 0) { - assistantMsg.tool_calls = msg.toolCalls.map(tc => ({ - id: tc.toolCallId, - type: 'function' as const, - function: { - name: tc.functionName, - arguments: tc.arguments - } - })); - } - return assistantMsg; - } - if (msg.role === 'tool') { - return { - role: 'tool' as const, - content: msg.content, - tool_call_id: msg.toolCallId - }; - } - return msg; - }); + let useStrictToolCalls = + ctx.input.strictToolCalls === true || + (ctx.input.tools?.some(tool => tool.strict === true) ?? false); let apiTools = ctx.input.tools?.map(t => ({ type: 'function' as const, function: { name: t.functionName, description: t.description, - parameters: t.parameters + parameters: t.parameters, + strict: useStrictToolCalls ? true : t.strict } })); @@ -242,34 +315,34 @@ Enables function calling with tool definitions, structured JSON output via respo } } - let thinking: { type: 'enabled' | 'disabled'; budget_tokens?: number } | undefined; - if (ctx.input.model === 'deepseek-reasoner' || ctx.input.enableThinking) { - thinking = { type: 'enabled' }; - if (ctx.input.thinkingBudgetTokens) { - thinking.budget_tokens = ctx.input.thinkingBudgetTokens; - } - } - - let result = await client.createChatCompletion({ - model: ctx.input.model, - messages: apiMessages as any, - temperature: ctx.input.temperature, - top_p: ctx.input.topP, - max_tokens: ctx.input.maxTokens, - frequency_penalty: ctx.input.frequencyPenalty, - presence_penalty: ctx.input.presencePenalty, - stop: ctx.input.stop, - response_format: ctx.input.responseFormat - ? { type: ctx.input.responseFormat } - : undefined, - tools: apiTools, - tool_choice: toolChoice, - thinking, - logprobs: ctx.input.logprobs, - top_logprobs: ctx.input.topLogprobs - }); + let result = await client.createChatCompletion( + { + model: ctx.input.model, + messages: toApiMessages(ctx.input.messages) as any, + temperature: ctx.input.temperature, + top_p: ctx.input.topP, + max_tokens: ctx.input.maxTokens, + stop: ctx.input.stop, + response_format: ctx.input.responseFormat + ? { type: ctx.input.responseFormat } + : undefined, + tools: apiTools, + tool_choice: toolChoice, + thinking: { type: ctx.input.thinkingMode }, + reasoning_effort: ctx.input.reasoningEffort, + logprobs: ctx.input.logprobs, + top_logprobs: ctx.input.topLogprobs, + user_id: ctx.input.userId + }, + { beta: useStrictToolCalls } + ); - let choice = result.choices[0]!; + let choice = result.choices[0]; + if (!choice) { + throw deepSeekServiceError( + 'DeepSeek chat completion response did not include a choice.' + ); + } let outputToolCalls = choice.message.tool_calls?.map(tc => ({ toolCallId: tc.id, diff --git a/integrations/deepseek/src/tools/chat-prefix-completion.ts b/integrations/deepseek/src/tools/chat-prefix-completion.ts new file mode 100644 index 0000000000..6f5cd6ce6f --- /dev/null +++ b/integrations/deepseek/src/tools/chat-prefix-completion.ts @@ -0,0 +1,210 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DeepSeekClient } from '../lib/client'; +import { deepSeekServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let chatPrefixInputSchema = z.object({ + model: z + .enum(['deepseek-v4-flash', 'deepseek-v4-pro']) + .default('deepseek-v4-pro') + .describe('DeepSeek V4 model to use for beta chat prefix completion'), + prompt: z.string().describe('User instruction or prompt before the assistant prefix'), + assistantPrefix: z + .string() + .min(1) + .describe('Assistant message prefix that the model should continue from'), + systemPrompt: z.string().optional().describe('Optional system prompt for the request'), + thinkingMode: z + .enum(['disabled', 'enabled']) + .default('disabled') + .describe('Controls DeepSeek V4 thinking mode for this prefix completion'), + reasoningEffort: z + .enum(['high', 'max']) + .optional() + .describe('Reasoning effort when thinkingMode is enabled'), + reasoningPrefix: z + .string() + .optional() + .describe('Optional reasoning_content prefix for thinking-mode beta prefix completion'), + maxTokens: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of tokens to generate'), + temperature: z + .number() + .min(0) + .max(2) + .optional() + .describe('Sampling temperature (0-2). Only applies when thinkingMode is disabled.'), + topP: z + .number() + .min(0) + .max(1) + .optional() + .describe('Nucleus sampling parameter (0-1). Only applies when thinkingMode is disabled.'), + stop: z + .union([z.string(), z.array(z.string()).max(16)]) + .optional() + .describe('Stop sequences that halt generation. Arrays are limited to 16 strings.'), + userId: z + .string() + .max(512) + .regex(/^[a-zA-Z0-9_-]+$/) + .optional() + .describe( + 'Optional privacy-safe user identifier for safety, cache, and scheduling isolation' + ) +}); + +type ChatPrefixInput = z.infer; + +let assertValidChatPrefixInput = (input: ChatPrefixInput) => { + if (input.reasoningEffort && input.thinkingMode !== 'enabled') { + throw deepSeekServiceError( + 'reasoningEffort can only be used when thinkingMode is enabled.' + ); + } + + if (input.reasoningPrefix && input.thinkingMode !== 'enabled') { + throw deepSeekServiceError( + 'reasoningPrefix can only be used when thinkingMode is enabled.' + ); + } + + if ( + input.thinkingMode === 'enabled' && + (input.temperature !== undefined || input.topP !== undefined) + ) { + throw deepSeekServiceError( + 'temperature and topP are only supported when thinkingMode is disabled.' + ); + } +}; + +export let chatPrefixCompletion = SlateTool.create(spec, { + name: 'Chat Prefix Completion', + key: 'chat_prefix_completion', + description: + 'Continue from a supplied assistant prefix using DeepSeek beta chat prefix completion. Useful for forcing an answer to start with a code fence, template, or partial response.', + instructions: [ + 'Use `assistantPrefix` for the exact assistant text the model should continue from.', + 'This beta feature always uses `https://api.deepseek.com/beta` regardless of the configured base URL.', + 'Set `stop` when using code fences or templates to prevent trailing explanations.' + ], + constraints: [ + 'The last API message is always an assistant message with `prefix=true`.', + 'Stop sequences are limited to 16.', + '`temperature` and `topP` only apply when `thinkingMode` is disabled.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input(chatPrefixInputSchema) + .output( + z.object({ + completionId: z.string().describe('Unique identifier for this completion'), + model: z.string().describe('Model used for the completion'), + completedText: z.string().nullable().describe('Text generated after the prefix'), + fullText: z + .string() + .nullable() + .describe('Assistant prefix plus the generated continuation when available'), + reasoningContent: z + .string() + .nullable() + .optional() + .describe('Step-by-step reasoning content when thinking mode is enabled'), + finishReason: z.string().describe('Reason generation stopped'), + promptTokens: z.number().describe('Number of tokens in the prompt'), + completionTokens: z.number().describe('Number of tokens in the completion'), + totalTokens: z.number().describe('Total tokens used'), + reasoningTokens: z.number().optional().describe('Number of tokens used for reasoning'), + cacheHitTokens: z + .number() + .optional() + .describe('Number of prompt tokens served from cache'), + cacheMissTokens: z + .number() + .optional() + .describe('Number of prompt tokens not found in cache') + }) + ) + .handleInvocation(async ctx => { + assertValidChatPrefixInput(ctx.input); + + let client = new DeepSeekClient({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + let messages: Record[] = []; + if (ctx.input.systemPrompt) { + messages.push({ role: 'system', content: ctx.input.systemPrompt }); + } + messages.push({ role: 'user', content: ctx.input.prompt }); + + let assistantMessage: Record = { + role: 'assistant', + content: ctx.input.assistantPrefix, + prefix: true + }; + if (ctx.input.reasoningPrefix) { + assistantMessage.reasoning_content = ctx.input.reasoningPrefix; + } + messages.push(assistantMessage); + + let result = await client.createChatCompletion( + { + model: ctx.input.model, + messages: messages as any, + thinking: { type: ctx.input.thinkingMode }, + reasoning_effort: ctx.input.reasoningEffort, + max_tokens: ctx.input.maxTokens, + temperature: ctx.input.temperature, + top_p: ctx.input.topP, + stop: ctx.input.stop, + user_id: ctx.input.userId + }, + { beta: true } + ); + + let choice = result.choices[0]; + if (!choice) { + throw deepSeekServiceError( + 'DeepSeek chat prefix completion response did not include a choice.' + ); + } + + let completedText = choice.message.content ?? null; + let output = { + completionId: result.id, + model: result.model, + completedText, + fullText: completedText === null ? null : `${ctx.input.assistantPrefix}${completedText}`, + reasoningContent: choice.message.reasoning_content ?? null, + finishReason: choice.finish_reason, + promptTokens: result.usage.prompt_tokens, + completionTokens: result.usage.completion_tokens, + totalTokens: result.usage.total_tokens, + reasoningTokens: result.usage.completion_tokens_details?.reasoning_tokens, + cacheHitTokens: result.usage.prompt_cache_hit_tokens, + cacheMissTokens: result.usage.prompt_cache_miss_tokens + }; + + let preview = output.fullText + ? output.fullText.length > 200 + ? `${output.fullText.substring(0, 200)}...` + : output.fullText + : '(empty response)'; + + return { + output, + message: `**Completion:** ${preview}\n**Usage:** ${output.totalTokens} total tokens (${output.promptTokens} prompt, ${output.completionTokens} completion)` + }; + }) + .build(); diff --git a/integrations/deepseek/src/tools/fim-completion.ts b/integrations/deepseek/src/tools/fim-completion.ts index d5ff29ad07..d831f47a04 100644 --- a/integrations/deepseek/src/tools/fim-completion.ts +++ b/integrations/deepseek/src/tools/fim-completion.ts @@ -1,19 +1,21 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DeepSeekClient } from '../lib/client'; +import { deepSeekServiceError } from '../lib/errors'; import { spec } from '../spec'; export let fimCompletion = SlateTool.create(spec, { name: 'FIM Completion', key: 'fim_completion', - description: `Fill In the Middle (FIM) completion for code and content. Provide a prefix and optional suffix, and the model generates the content that belongs in between. -Commonly used for code completion, inserting missing code segments, and content gap-filling.`, + description: `Fill In the Middle (FIM) completion for code and content. Provide a prefix and optional suffix, and DeepSeek generates the content that belongs in between.`, instructions: [ 'Provide the code or text before the insertion point as `prefix`, and optionally the code after the insertion point as `suffix`.', - 'This is a Beta feature that always uses the Beta API endpoint regardless of the configured base URL.' + 'This is a beta feature that always uses `https://api.deepseek.com/beta` regardless of the configured base URL.', + 'Use this for code completion, inserting missing code segments, and content gap-filling.' ], constraints: [ - 'Not available in thinking/reasoning mode.', + 'The current DeepSeek FIM API accepts `deepseek-v4-pro`.', + 'FIM completion maxTokens is limited to 4096.', 'Stop sequences are limited to 16.' ], tags: { @@ -24,9 +26,9 @@ Commonly used for code completion, inserting missing code segments, and content .input( z.object({ model: z - .string() - .default('deepseek-chat') - .describe('Model to use. Defaults to deepseek-chat.'), + .enum(['deepseek-v4-pro']) + .default('deepseek-v4-pro') + .describe('Model to use for FIM completion'), prefix: z .string() .describe('Text/code before the point where completion should be inserted'), @@ -38,30 +40,26 @@ Commonly used for code completion, inserting missing code segments, and content .number() .int() .positive() + .max(4096) .optional() - .describe('Maximum number of tokens to generate'), + .describe('Maximum number of tokens to generate, up to 4096'), temperature: z.number().min(0).max(2).optional().describe('Sampling temperature (0-2)'), topP: z.number().min(0).max(1).optional().describe('Nucleus sampling parameter (0-1)'), - frequencyPenalty: z - .number() - .min(-2) - .max(2) - .optional() - .describe('Penalizes repeated tokens (-2 to 2)'), - presencePenalty: z - .number() - .min(-2) - .max(2) - .optional() - .describe('Encourages new topics (-2 to 2)'), stop: z - .union([z.string(), z.array(z.string())]) + .union([z.string(), z.array(z.string()).max(16)]) .optional() - .describe('Stop sequences that halt generation'), + .describe('Stop sequences that halt generation. Arrays are limited to 16 strings.'), echo: z .boolean() .optional() - .describe('Whether to echo the prompt alongside the completion') + .describe('Whether to echo the prompt alongside the completion'), + logprobs: z + .number() + .int() + .min(0) + .max(20) + .optional() + .describe('Return log probabilities for up to this many likely output tokens') }) ) .output( @@ -98,13 +96,15 @@ Commonly used for code completion, inserting missing code segments, and content max_tokens: ctx.input.maxTokens, temperature: ctx.input.temperature, top_p: ctx.input.topP, - frequency_penalty: ctx.input.frequencyPenalty, - presence_penalty: ctx.input.presencePenalty, stop: ctx.input.stop, - echo: ctx.input.echo + echo: ctx.input.echo, + logprobs: ctx.input.logprobs }); - let choice = result.choices[0]!; + let choice = result.choices[0]; + if (!choice) { + throw deepSeekServiceError('DeepSeek FIM completion response did not include a choice.'); + } let output = { completionId: result.id, diff --git a/integrations/deepseek/src/tools/index.ts b/integrations/deepseek/src/tools/index.ts index 41401caad2..9a8534efc3 100644 --- a/integrations/deepseek/src/tools/index.ts +++ b/integrations/deepseek/src/tools/index.ts @@ -1,4 +1,5 @@ export * from './chat-completion'; +export * from './chat-prefix-completion'; export * from './fim-completion'; export * from './get-balance'; export * from './list-models'; diff --git a/integrations/docker-hub/README.md b/integrations/docker-hub/README.md index e317020ac6..bc59ff3475 100644 --- a/integrations/docker-hub/README.md +++ b/integrations/docker-hub/README.md @@ -1,6 +1,6 @@ # Docker Hub -Manage Docker container image repositories, tags, and organizations on Docker Hub. Create, list, update, and delete repositories and image tags. Search and discover public container images. Manage organization members, teams, and repository-level permissions. Create and manage webhooks for image push events. Handle personal and organization access tokens programmatically. View audit logs for organization and repository activity. Provision and de-provision users via SCIM. Categorize repositories for improved discoverability. +Manage Docker container image repositories, tags, and organizations on Docker Hub. Create, list, update, and delete repositories and image tags. Search and discover public container images. Manage organization members, teams, repository-level permissions, immutable tag settings, webhooks, personal access tokens, organization access tokens, and audit logs. ## Tools @@ -8,6 +8,10 @@ Manage Docker container image repositories, tags, and organizations on Docker Hu Retrieve audit log events for a Docker Hub account (user or organization). Tracks actions like repository changes, team membership updates, and settings modifications. Available for Docker Team and Business subscriptions. +### List Audit Log Actions + +List all available audit log action types for a Docker Hub account. + ### Create Repository Create a new Docker Hub repository under a namespace. Repositories can be public or private and are used to store and distribute Docker container images. @@ -20,6 +24,10 @@ Permanently delete a Docker Hub repository and all of its tags and images. This Delete a specific tag from a Docker Hub repository. This removes the tag reference but does not delete the underlying image layers if other tags reference them. +### Get Image Tag + +Get details for a specific Docker Hub repository tag, including digest, size, last update time, and platform image metadata. + ### Get Repository Get detailed information about a specific Docker Hub repository, including its description, visibility, star/pull counts, and content types. @@ -36,18 +44,86 @@ List tags for a Docker Hub repository. Returns tag details including size, diges List personal access tokens (PATs) for the authenticated Docker Hub user. Shows token labels, scopes, creation dates, and activity status. +### Create Access Token + +Create a new personal access token (PAT) for Docker Hub. + +### Get Access Token + +Get metadata for a personal access token (PAT) by UUID. Docker Hub does not return the token secret after creation. + +### Update Access Token + +Update a personal access token's label or active status. + +### Delete Access Token + +Permanently delete a personal access token. + +### List Organization Access Tokens + +List Docker Hub organization access tokens (OATs) for an organization. + +### Get Organization Access Token + +Get details for a Docker Hub organization access token, including active status, expiration, and resource scopes. + +### Create Organization Access Token + +Create a Docker Hub organization access token (OAT) for automation. + +### Update Organization Access Token + +Update a Docker Hub organization access token's label, description, resources, or active status. + +### Delete Organization Access Token + +Permanently delete a Docker Hub organization access token. + ### List Organization Members List members of a Docker Hub organization, including their roles and team memberships. Supports pagination for large organizations. +### Update Organization Member Role + +Update a Docker Hub organization member's role. + +### Remove Organization Member + +Remove a member from a Docker Hub organization. + ### List Teams List teams (groups) within a Docker Hub organization. Returns team names, descriptions, and member counts. +### Create Team + +Create a new team within a Docker Hub organization. + +### Delete Team + +Delete a team from a Docker Hub organization. + +### Manage Team Members + +List, add, or remove members from a Docker Hub organization team. + +### Assign Repository Team + +Grant a Docker Hub organization team access to a repository with read, write, or admin permission. + ### List Webhooks List webhooks configured for a Docker Hub repository. Webhooks fire on image push events and can trigger actions in external services. +### Create Webhook + +Create a webhook for a Docker Hub repository. + +### Delete Webhook + +Delete a webhook from a Docker Hub repository. + ### Search Repositories Search for public Docker Hub repositories by keyword. Discovers images for operating systems, frameworks, databases, and more from the Docker Hub content library. @@ -56,6 +132,14 @@ Search for public Docker Hub repositories by keyword. Discovers images for opera Update an existing Docker Hub repository's description, full description, or visibility. Only provided fields will be updated. +### Update Repository Immutable Tags + +Update immutable tag settings for a Docker Hub repository. + +### Verify Repository Immutable Tags + +Validate an immutable tag regex rule for a Docker Hub repository and return repository tags that match the rule. + ## License This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE). diff --git a/integrations/docker-hub/docs/SPEC.md b/integrations/docker-hub/docs/SPEC.md index 2f1ad842ec..0869bf9b55 100644 --- a/integrations/docker-hub/docs/SPEC.md +++ b/integrations/docker-hub/docs/SPEC.md @@ -6,17 +6,17 @@ Docker Hub is a cloud-based container image registry that allows users to store, ## Authentication -Docker Hub uses a **credential-based authentication** flow to obtain a short-lived JWT (JSON Web Token) for API access. +Docker Hub uses a **credential-based authentication** flow to obtain a short-lived bearer token for API access. -**Obtaining a JWT Token:** +**Obtaining a bearer token:** -Authenticate by sending a POST request with your username and password (or Personal Access Token) to `https://hub.docker.com/v2/users/login/`, which returns a JWT token. Include this token in subsequent requests via the `Authorization: JWT ` header. +Authenticate by sending a POST request with an identifier and secret to `https://hub.docker.com/v2/auth/token`, which returns a short-lived access token. Include this token in subsequent requests via the `Authorization: Bearer ` header. **Personal Access Tokens (PATs):** Personal access tokens (PATs) provide a secure alternative to passwords for Docker CLI authentication. Use PATs to authenticate automated systems, CI/CD pipelines, and development tools without exposing your Docker Hub password. -PATs can be used in place of your password when obtaining a JWT. Pro and Team plan members have access to 4 scopes: Read, write, delete (full repo access); Read, write; Read only; and Public repo read only. Free users can continue to use their single read, write, delete token. +PATs can be used in place of your password when obtaining a bearer token. Pro and Team plan members have access to 4 scopes: Read, write, delete (full repo access); Read, write; Read only; and Public repo read only. Free users can continue to use their single read, write, delete token. **Organization Access Tokens (OATs):** @@ -48,7 +48,15 @@ In Docker Hub, an organization is a collection of teams. Image repositories can ### Access Token Management -You can manage your tokens through the Hub APIs. This includes creating, listing, updating, and revoking both personal and organization access tokens programmatically. +You can manage tokens through the Hub APIs. This includes creating, listing, reading, updating, and revoking both personal and organization access tokens programmatically. Organization access tokens can be scoped to organization or repository resources. + +### Repository Team Access + +Docker Hub teams can be granted repository access with read, write, or admin permission. This integration supports assigning a team to a repository after listing or creating the team. + +### Immutable Tags + +Docker Hub supports immutable tag settings on repositories. Immutable tag rules can be updated and verified so repository administrators can prevent matching tags from being overwritten. ### Audit Logs @@ -60,14 +68,6 @@ You can view activity logs using the Docker Hub API via the Audit logs endpoints You can create and manage webhooks on repositories via the API. Webhooks trigger an action in another service in response to a push event in the repository. You can list, create, and delete webhooks and their associated hook URLs for repositories you own. -### SCIM Provisioning - -With SCIM, you can manage users within your identity provider (IdP). Docker Hub exposes a SCIM API that supports automatic user provisioning and de-provisioning through identity providers like Okta and Microsoft Entra ID (Azure AD). This is available for Docker Business subscriptions. - -### Repository Categories - -You can tag Docker Hub repositories with categories to improve discoverability and organization. - ## Events Docker Hub supports webhooks at the repository level. diff --git a/integrations/docker-hub/package.json b/integrations/docker-hub/package.json index 45c582d6eb..84b17c39ef 100644 --- a/integrations/docker-hub/package.json +++ b/integrations/docker-hub/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/docker-hub/src/auth.ts b/integrations/docker-hub/src/auth.ts index 00447cf188..5ef25e1996 100644 --- a/integrations/docker-hub/src/auth.ts +++ b/integrations/docker-hub/src/auth.ts @@ -1,11 +1,47 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { createDockerHubBearerToken } from './lib/client'; +import { dockerHubApiError } from './lib/errors'; + +let getProfile = async (ctx: { + output: { token: string; username: string }; + input: Record; +}) => { + let http = createAxios({ baseURL: 'https://hub.docker.com' }); + + try { + let response = await http.get(`/v2/user/`, { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); + + return { + profile: { + id: response.data.id, + name: response.data.full_name || response.data.username, + email: response.data.email, + imageUrl: response.data.gravatar_url + } + }; + } catch (error) { + throw dockerHubApiError(error, 'profile request'); + } +}; export let auth = SlateAuth.create() .output( z.object({ token: z.string(), - username: z.string() + username: z.string(), + identifier: z + .string() + .optional() + .describe('Docker Hub username or organization identifier used for token renewal.'), + secret: z + .string() + .optional() + .describe('Docker Hub password, personal access token, or organization access token.') }) ) .addCustomAuth({ @@ -19,42 +55,22 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { - let http = createAxios({ baseURL: 'https://hub.docker.com' }); - - let response = await http.post('/v2/users/login', { - username: ctx.input.username, - password: ctx.input.password + let token = await createDockerHubBearerToken({ + identifier: ctx.input.username, + secret: ctx.input.password }); return { output: { - token: response.data.token as string, - username: ctx.input.username + token, + username: ctx.input.username, + identifier: ctx.input.username, + secret: ctx.input.password } }; }, - getProfile: async (ctx: { - output: { token: string; username: string }; - input: { username: string; password: string }; - }) => { - let http = createAxios({ baseURL: 'https://hub.docker.com' }); - - let response = await http.get(`/v2/user/`, { - headers: { - Authorization: `JWT ${ctx.output.token}` - } - }); - - return { - profile: { - id: response.data.id, - name: response.data.full_name || response.data.username, - email: response.data.email, - imageUrl: response.data.gravatar_url - } - }; - } + getProfile }) .addTokenAuth({ type: 'auth.token', @@ -67,40 +83,20 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { - let http = createAxios({ baseURL: 'https://hub.docker.com' }); - - let response = await http.post('/v2/users/login', { - username: ctx.input.username, - password: ctx.input.token + let token = await createDockerHubBearerToken({ + identifier: ctx.input.username, + secret: ctx.input.token }); return { output: { - token: response.data.token as string, - username: ctx.input.username + token, + username: ctx.input.username, + identifier: ctx.input.username, + secret: ctx.input.token } }; }, - getProfile: async (ctx: { - output: { token: string; username: string }; - input: { username: string; token: string }; - }) => { - let http = createAxios({ baseURL: 'https://hub.docker.com' }); - - let response = await http.get(`/v2/user/`, { - headers: { - Authorization: `JWT ${ctx.output.token}` - } - }); - - return { - profile: { - id: response.data.id, - name: response.data.full_name || response.data.username, - email: response.data.email, - imageUrl: response.data.gravatar_url - } - }; - } + getProfile }); diff --git a/integrations/docker-hub/src/index.ts b/integrations/docker-hub/src/index.ts index e4aca2a65a..f4542d201a 100644 --- a/integrations/docker-hub/src/index.ts +++ b/integrations/docker-hub/src/index.ts @@ -1,19 +1,26 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + assignRepositoryTeam, createAccessToken, + createOrgAccessToken, createRepository, createTeam, createWebhook, deleteAccessToken, + deleteOrgAccessToken, deleteRepository, deleteTag, deleteTeam, deleteWebhook, + getAccessToken, + getOrgAccessToken, getRepository, + getTag, listAccessTokens, listAuditLogActions, listAuditLogs, + listOrgAccessTokens, listOrgMembers, listRepositories, listTags, @@ -23,7 +30,11 @@ import { removeOrgMember, searchRepositories, updateAccessToken, - updateRepository + updateOrgAccessToken, + updateOrgMemberRole, + updateRepository, + updateRepositoryImmutableTags, + verifyRepositoryImmutableTags } from './tools'; import { imagePush } from './triggers'; @@ -36,21 +47,32 @@ export let provider = Slate.create({ updateRepository, deleteRepository, listTags, + getTag, deleteTag, searchRepositories, listOrgMembers, + updateOrgMemberRole, removeOrgMember, listTeams, createTeam, deleteTeam, manageTeamMembers, + assignRepositoryTeam, listWebhooks, createWebhook, deleteWebhook, listAccessTokens, + getAccessToken, createAccessToken, updateAccessToken, deleteAccessToken, + listOrgAccessTokens, + getOrgAccessToken, + createOrgAccessToken, + updateOrgAccessToken, + deleteOrgAccessToken, + updateRepositoryImmutableTags, + verifyRepositoryImmutableTags, listAuditLogs, listAuditLogActions ], diff --git a/integrations/docker-hub/src/lib/client.ts b/integrations/docker-hub/src/lib/client.ts index de947e536c..4cca48de73 100644 --- a/integrations/docker-hub/src/lib/client.ts +++ b/integrations/docker-hub/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { dockerHubApiError, dockerHubServiceError } from './errors'; export interface PaginatedResponse { count: number; @@ -8,18 +9,42 @@ export interface PaginatedResponse { } export interface Repository { + user?: string; namespace: string; name: string; description: string; - full_description: string; + full_description?: string | null; is_private: boolean; + is_automated?: boolean; star_count: number; pull_count: number; last_updated: string; date_registered: string; status: number; - repository_type: string; + status_description?: string; + repository_type: string | null; content_types: string[]; + media_types?: string[]; + categories?: RepositoryCategory[]; + permissions?: RepositoryPermissions; + immutable_tags_settings?: ImmutableTagsSettings; + storage_size?: number | null; +} + +export interface RepositoryCategory { + name: string; + slug: string; +} + +export interface RepositoryPermissions { + read: boolean; + write: boolean; + admin: boolean; +} + +export interface ImmutableTagsSettings { + enabled: boolean; + rules: string[]; } export interface Tag { @@ -89,6 +114,7 @@ export interface PersonalAccessToken { token: string; token_label: string; scopes: string[]; + expires_at?: string | null; } export interface SearchRepository { @@ -113,29 +139,176 @@ export interface AuditLogEvent { export interface AuditLogAction { name: string; description: string; + label?: string; +} + +export interface AuditLogActionGroup { + label?: string; + actions: AuditLogAction[]; +} + +export interface AuditLogActionsResponse { + actions: AuditLogAction[] | Record; +} + +export interface RepositoryGroup { + group_id: number; + group_name: string; + permission: 'read' | 'write' | 'admin'; +} + +export interface OrgAccessTokenResource { + type: 'TYPE_REPO' | 'TYPE_ORG'; + path: string; + scopes: string[]; +} + +export interface OrgAccessToken { + id: string; + label: string; + description?: string; + created_by: string; + is_active: boolean; + created_at: string; + expires_at: string | null; + last_used_at: string | null; + token?: string; + resources?: OrgAccessTokenResource[]; +} + +export interface OrgAccessTokensResponse { + total: number; + next: string | null; + previous: string | null; + results: OrgAccessToken[]; } +type AuthTokenOptions = { + identifier: string; + secret: string; +}; + +export let createDockerHubBearerToken = async (opts: AuthTokenOptions) => { + let http = createAxios({ + baseURL: 'https://hub.docker.com', + headers: { 'Content-Type': 'application/json' } + }); + + try { + let response = await http.post('/v2/auth/token', { + identifier: opts.identifier, + secret: opts.secret + }); + let token = response.data.access_token; + + if (typeof token !== 'string' || token.length === 0) { + throw dockerHubServiceError('Docker Hub did not return an access token.'); + } + + return token; + } catch (error) { + throw dockerHubApiError(error, 'authentication'); + } +}; + +let isPlainHeaders = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !('set' in value); + +let setAuthorizationHeader = (config: Record, token: string) => { + let headers = config.headers as + | Record + | { set?: (name: string, value: string) => void } + | undefined; + + if (headers && typeof headers.set === 'function') { + headers.set('Authorization', `Bearer ${token}`); + return; + } + + config.headers = { + ...(isPlainHeaders(headers) ? headers : {}), + Authorization: `Bearer ${token}` + }; +}; + export class Client { private http; + private token: string; + private tokenExpiresAt = 0; + private identifier?: string; + private secret?: string; + + constructor(opts: { token: string; identifier?: string; secret?: string }) { + this.token = opts.token; + this.identifier = opts.identifier; + this.secret = opts.secret; + this.tokenExpiresAt = Date.now() + 9 * 60 * 1000; - constructor(opts: { token: string }) { this.http = createAxios({ baseURL: 'https://hub.docker.com', headers: { - Authorization: `JWT ${opts.token}`, 'Content-Type': 'application/json' } }); + + this.http.interceptors.request.use(async config => { + setAuthorizationHeader(config, await this.getBearerToken()); + return config; + }); + + this.http.interceptors.response.use( + response => response, + async error => { + let config = error?.config as any; + let status = error?.response?.status; + + if ( + status === 401 && + config && + !config._dockerHubRetried && + this.identifier && + this.secret + ) { + config._dockerHubRetried = true; + this.tokenExpiresAt = 0; + setAuthorizationHeader(config, await this.getBearerToken()); + return this.http.request(config); + } + + return Promise.reject(dockerHubApiError(error)); + } + ); + } + + private async getBearerToken() { + if (!this.identifier || !this.secret) { + return this.token; + } + + if (Date.now() >= this.tokenExpiresAt) { + this.token = await createDockerHubBearerToken({ + identifier: this.identifier, + secret: this.secret + }); + this.tokenExpiresAt = Date.now() + 9 * 60 * 1000; + } + + return this.token; } // ── Repositories ───────────────────────────────────────────── async listRepositories( namespace: string, - params?: { page?: number; pageSize?: number } + params?: { page?: number; pageSize?: number; name?: string; ordering?: string } ): Promise> { let response = await this.http.get(`/v2/namespaces/${namespace}/repositories`, { - params: { page: params?.page, page_size: params?.pageSize } + params: { + page: params?.page, + page_size: params?.pageSize, + name: params?.name, + ordering: params?.ordering + } }); return response.data as PaginatedResponse; } @@ -154,9 +327,14 @@ export class Client { description?: string; full_description?: string; is_private?: boolean; + registry?: string; } ) { - let response = await this.http.post(`/v2/namespaces/${namespace}/repositories`, data); + let response = await this.http.post(`/v2/namespaces/${namespace}/repositories`, { + ...data, + namespace, + registry: data.registry ?? 'docker.io' + }); return response.data; } @@ -295,6 +473,18 @@ export class Client { await this.http.delete(`/v2/orgs/${orgName}/groups/${teamName}/members/${username}`); } + async assignRepositoryTeam( + namespace: string, + repository: string, + data: { groupId: number; permission: 'read' | 'write' | 'admin' } + ): Promise { + let response = await this.http.post(`/v2/repositories/${namespace}/${repository}/groups`, { + group_id: data.groupId, + permission: data.permission + }); + return response.data as RepositoryGroup; + } + // ── Webhooks ───────────────────────────────────────────────── async listWebhooks( @@ -345,7 +535,11 @@ export class Client { return response.data as PaginatedResponse; } - async createAccessToken(data: { token_label: string; scopes: string[] }) { + async createAccessToken(data: { + token_label: string; + scopes: string[]; + expires_at?: string; + }) { let response = await this.http.post(`/v2/access-tokens`, data); return response.data; } @@ -364,12 +558,88 @@ export class Client { await this.http.delete(`/v2/access-tokens/${uuid}`); } + // ── Organization Access Tokens ────────────────────────────── + + async listOrgAccessTokens( + orgName: string, + params?: { page?: number; pageSize?: number } + ): Promise { + let response = await this.http.get(`/v2/orgs/${orgName}/access-tokens`, { + params: { page: params?.page, page_size: params?.pageSize } + }); + return response.data as OrgAccessTokensResponse; + } + + async getOrgAccessToken(orgName: string, tokenId: string): Promise { + let response = await this.http.get(`/v2/orgs/${orgName}/access-tokens/${tokenId}`); + return response.data as OrgAccessToken; + } + + async createOrgAccessToken( + orgName: string, + data: { + label: string; + description?: string; + resources?: OrgAccessTokenResource[]; + expires_at?: string; + } + ): Promise { + let response = await this.http.post(`/v2/orgs/${orgName}/access-tokens`, data); + return response.data as OrgAccessToken; + } + + async updateOrgAccessToken( + orgName: string, + tokenId: string, + data: { + label?: string; + description?: string; + resources?: OrgAccessTokenResource[]; + is_active?: boolean; + } + ): Promise { + let response = await this.http.patch(`/v2/orgs/${orgName}/access-tokens/${tokenId}`, data); + return response.data as OrgAccessToken; + } + + async deleteOrgAccessToken(orgName: string, tokenId: string) { + await this.http.delete(`/v2/orgs/${orgName}/access-tokens/${tokenId}`); + } + + // ── Immutable Tags ────────────────────────────────────────── + + async updateRepositoryImmutableTags( + namespace: string, + repository: string, + data: { immutable_tags: boolean; immutable_tags_rules: string[] } + ): Promise { + let response = await this.http.patch( + `/v2/namespaces/${namespace}/repositories/${repository}/immutabletags`, + data + ); + return response.data as Repository; + } + + async verifyRepositoryImmutableTags( + namespace: string, + repository: string, + regex: string + ): Promise<{ tags: string[] }> { + let response = await this.http.post( + `/v2/namespaces/${namespace}/repositories/${repository}/immutabletags/verify`, + { regex } + ); + return response.data as { tags: string[] }; + } + // ── Audit Logs ────────────────────────────────────────────── async listAuditLogs( account: string, params?: { action?: string; + name?: string; + actor?: string; from?: string; to?: string; page?: number; @@ -379,17 +649,30 @@ export class Client { let response = await this.http.get(`/v2/auditlogs/${account}`, { params: { action: params?.action, + name: params?.name, + actor: params?.actor, from: params?.from, to: params?.to, page: params?.page, page_size: params?.pageSize } }); - return response.data as PaginatedResponse; + let data = response.data; + + if (Array.isArray(data?.logs)) { + return { + count: data.logs.length, + next: null, + previous: null, + results: data.logs + } as PaginatedResponse; + } + + return data as PaginatedResponse; } - async listAuditLogActions(account: string): Promise<{ actions: AuditLogAction[] }> { + async listAuditLogActions(account: string): Promise { let response = await this.http.get(`/v2/auditlogs/${account}/actions`); - return response.data as { actions: AuditLogAction[] }; + return response.data as AuditLogActionsResponse; } } diff --git a/integrations/docker-hub/src/lib/errors.ts b/integrations/docker-hub/src/lib/errors.ts new file mode 100644 index 0000000000..ad769bbeed --- /dev/null +++ b/integrations/docker-hub/src/lib/errors.ts @@ -0,0 +1,104 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (typeof value === 'string') { + addMessage(messages, value); + return; + } + + if (Array.isArray(value)) { + for (let item of value) { + collectMessages(item, messages); + } + return; + } + + if (!isRecord(value)) return; + + for (let key of ['detail', 'message', 'error', 'error_description', 'text']) { + collectMessages(value[key], messages); + } + + if (isRecord(value.fields)) { + collectMessages(Object.values(value.fields), messages); + } + + if (Array.isArray(value.errors)) { + collectMessages(value.errors, messages); + } +}; + +let extractDockerHubMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectMessages(response?.data, messages); + + if (isRecord(error)) { + collectMessages(error.data, messages); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let dockerHubServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let dockerHubApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = + response?.status ?? + (isRecord(error) && typeof error.status === 'number' ? error.status : undefined); + let statusText = + response?.statusText ?? + (isRecord(error) && + isRecord(error.upstream) && + typeof error.upstream.statusText === 'string' + ? error.upstream.statusText + : undefined); + let statusLabel = + status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : ''; + + let serviceError = dockerHubServiceError( + `Docker Hub API ${operation} failed: ${statusLabel}${extractDockerHubMessage(error)}` + ); + + serviceError.data.reason = 'docker_hub_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/docker-hub/src/tools/assign-repository-team.ts b/integrations/docker-hub/src/tools/assign-repository-team.ts new file mode 100644 index 0000000000..0f8b6fe2db --- /dev/null +++ b/integrations/docker-hub/src/tools/assign-repository-team.ts @@ -0,0 +1,57 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let assignRepositoryTeam = SlateTool.create(spec, { + name: 'Assign Repository Team', + key: 'assign_repository_team', + description: `Grant a Docker Hub organization team access to a repository with read, write, or admin permission. Use List Teams first to find the team ID.`, + instructions: [ + 'The repository must belong to an organization namespace.', + 'The groupId field is the numeric team ID returned by List Teams.' + ] +}) + .input( + z.object({ + namespace: z + .string() + .optional() + .describe( + 'Docker Hub organization namespace that owns the repository. Falls back to configured default namespace.' + ), + repositoryName: z.string().describe('Name of the repository to grant access to.'), + groupId: z.number().describe('Numeric Docker Hub team/group ID.'), + permission: z + .enum(['read', 'write', 'admin']) + .describe( + 'Permission to grant: read can pull, write can pull and push, admin can manage repository settings.' + ) + }) + ) + .output( + z.object({ + groupId: z.number().describe('Numeric Docker Hub team/group ID.'), + groupName: z.string().describe('Name of the team/group.'), + permission: z.string().describe('Permission granted to the team.') + }) + ) + .handleInvocation(async ctx => { + let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; + + let client = new Client(ctx.auth); + let result = await client.assignRepositoryTeam(ns, ctx.input.repositoryName, { + groupId: ctx.input.groupId, + permission: ctx.input.permission + }); + + return { + output: { + groupId: result.group_id, + groupName: result.group_name, + permission: result.permission + }, + message: `Granted **${result.permission}** access to team **${result.group_name}** for **${ns}/${ctx.input.repositoryName}**.` + }; + }) + .build(); diff --git a/integrations/docker-hub/src/tools/audit-logs.ts b/integrations/docker-hub/src/tools/audit-logs.ts index 2187bbd4a3..6c302f0fc3 100644 --- a/integrations/docker-hub/src/tools/audit-logs.ts +++ b/integrations/docker-hub/src/tools/audit-logs.ts @@ -1,8 +1,30 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { type AuditLogAction, type AuditLogActionGroup, Client } from '../lib/client'; import { spec } from '../spec'; +let flattenAuditActions = ( + actions: AuditLogAction[] | Record | undefined +) => { + if (!actions) return []; + + if (Array.isArray(actions)) { + return actions.map(action => ({ + group: undefined, + groupLabel: undefined, + ...action + })); + } + + return Object.entries(actions).flatMap(([group, value]) => + (value.actions || []).map(action => ({ + group, + groupLabel: value.label, + ...action + })) + ); +}; + export let listAuditLogs = SlateTool.create(spec, { name: 'List Audit Logs', key: 'list_audit_logs', @@ -25,6 +47,16 @@ export let listAuditLogs = SlateTool.create(spec, { .string() .optional() .describe('Filter by specific action type (e.g., "repo.create", "repo.delete").'), + name: z + .string() + .optional() + .describe( + 'Filter by resource name. For repository events this is the repository name.' + ), + actor: z + .string() + .optional() + .describe('Filter by the Docker Hub username that triggered the event.'), from: z .string() .optional() @@ -56,9 +88,11 @@ export let listAuditLogs = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let result = await client.listAuditLogs(ctx.input.account, { action: ctx.input.action, + name: ctx.input.name, + actor: ctx.input.actor, from: ctx.input.from, to: ctx.input.to, page: ctx.input.page, @@ -101,23 +135,30 @@ export let listAuditLogActions = SlateTool.create(spec, { actions: z.array( z.object({ actionName: z.string().describe('Action type identifier (e.g., "repo.create").'), - description: z.string().describe('Human-readable description of the action.') + description: z.string().describe('Human-readable description of the action.'), + label: z.string().optional().describe('Human-readable label for the action.'), + group: z.string().optional().describe('Action group key.'), + groupLabel: z.string().optional().describe('Human-readable action group label.') }) ) }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let result = await client.listAuditLogActions(ctx.input.account); + let actions = flattenAuditActions(result.actions); return { output: { - actions: (result.actions || []).map(a => ({ + actions: actions.map(a => ({ actionName: a.name, - description: a.description || '' + description: a.description || '', + label: a.label, + group: a.group, + groupLabel: a.groupLabel })) }, - message: `Found **${(result.actions || []).length}** audit log action types for **${ctx.input.account}**.` + message: `Found **${actions.length}** audit log action types for **${ctx.input.account}**.` }; }) .build(); diff --git a/integrations/docker-hub/src/tools/create-repository.ts b/integrations/docker-hub/src/tools/create-repository.ts index dd8c3268f1..5bc5976c65 100644 --- a/integrations/docker-hub/src/tools/create-repository.ts +++ b/integrations/docker-hub/src/tools/create-repository.ts @@ -35,7 +35,11 @@ export let createRepository = SlateTool.create(spec, { isPrivate: z .boolean() .optional() - .describe('Whether the repository should be private. Defaults to false (public).') + .describe('Whether the repository should be private. Defaults to false (public).'), + registry: z + .string() + .optional() + .describe('Docker registry host for the repository. Defaults to docker.io.') }) ) .output( @@ -48,12 +52,13 @@ export let createRepository = SlateTool.create(spec, { .handleInvocation(async ctx => { let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let repo = await client.createRepository(ns, { name: ctx.input.repositoryName, description: ctx.input.description, full_description: ctx.input.fullDescription, - is_private: ctx.input.isPrivate + is_private: ctx.input.isPrivate, + registry: ctx.input.registry }); return { diff --git a/integrations/docker-hub/src/tools/delete-repository.ts b/integrations/docker-hub/src/tools/delete-repository.ts index 0f9ccf9da6..6ac06757de 100644 --- a/integrations/docker-hub/src/tools/delete-repository.ts +++ b/integrations/docker-hub/src/tools/delete-repository.ts @@ -33,7 +33,7 @@ export let deleteRepository = SlateTool.create(spec, { .handleInvocation(async ctx => { let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); await client.deleteRepository(ns, ctx.input.repositoryName); return { diff --git a/integrations/docker-hub/src/tools/delete-tag.ts b/integrations/docker-hub/src/tools/delete-tag.ts index 6f46dda32c..50c7e39f86 100644 --- a/integrations/docker-hub/src/tools/delete-tag.ts +++ b/integrations/docker-hub/src/tools/delete-tag.ts @@ -31,7 +31,7 @@ export let deleteTag = SlateTool.create(spec, { .handleInvocation(async ctx => { let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); await client.deleteTag(ns, ctx.input.repositoryName, ctx.input.tagName); return { diff --git a/integrations/docker-hub/src/tools/get-repository.ts b/integrations/docker-hub/src/tools/get-repository.ts index 28ede6d755..57c4bda215 100644 --- a/integrations/docker-hub/src/tools/get-repository.ts +++ b/integrations/docker-hub/src/tools/get-repository.ts @@ -32,13 +32,44 @@ export let getRepository = SlateTool.create(spec, { starCount: z.number().describe('Number of stars.'), pullCount: z.number().describe('Number of pulls.'), lastUpdated: z.string().describe('ISO timestamp of the last update.'), - dateRegistered: z.string().describe('ISO timestamp when the repository was created.') + dateRegistered: z.string().describe('ISO timestamp when the repository was created.'), + statusDescription: z.string().optional().describe('Repository status label.'), + contentTypes: z.array(z.string()).describe('Repository content types.'), + mediaTypes: z.array(z.string()).describe('Repository media types.'), + storageSize: z + .number() + .nullable() + .optional() + .describe('Repository storage size in bytes when returned by Docker Hub.'), + categories: z + .array( + z.object({ + name: z.string().describe('Category display name.'), + slug: z.string().describe('Category slug.') + }) + ) + .describe('Docker Hub repository categories.'), + permissions: z + .object({ + read: z.boolean().describe('Whether the caller can read the repository.'), + write: z.boolean().describe('Whether the caller can write to the repository.'), + admin: z.boolean().describe('Whether the caller can administer the repository.') + }) + .optional() + .describe('Caller permissions when returned by Docker Hub.'), + immutableTags: z + .object({ + enabled: z.boolean().describe('Whether immutable tags are enabled.'), + rules: z.array(z.string()).describe('Immutable tag regex rules.') + }) + .optional() + .describe('Immutable tag settings for the repository.') }) ) .handleInvocation(async ctx => { let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let repo = await client.getRepository(ns, ctx.input.repositoryName); return { @@ -51,7 +82,14 @@ export let getRepository = SlateTool.create(spec, { starCount: repo.star_count, pullCount: repo.pull_count, lastUpdated: repo.last_updated, - dateRegistered: repo.date_registered + dateRegistered: repo.date_registered, + statusDescription: repo.status_description, + contentTypes: repo.content_types || [], + mediaTypes: repo.media_types || [], + storageSize: repo.storage_size, + categories: repo.categories || [], + permissions: repo.permissions, + immutableTags: repo.immutable_tags_settings }, message: `Retrieved details for repository **${ns}/${ctx.input.repositoryName}** (${repo.is_private ? 'private' : 'public'}, ${repo.star_count} stars, ${repo.pull_count} pulls).` }; diff --git a/integrations/docker-hub/src/tools/get-tag.ts b/integrations/docker-hub/src/tools/get-tag.ts new file mode 100644 index 0000000000..dc880a3aae --- /dev/null +++ b/integrations/docker-hub/src/tools/get-tag.ts @@ -0,0 +1,72 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client, type TagImage } from '../lib/client'; +import { spec } from '../spec'; + +export let getTag = SlateTool.create(spec, { + name: 'Get Image Tag', + key: 'get_tag', + description: `Get details for a specific Docker Hub repository tag, including digest, size, last update time, and platform image metadata.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + namespace: z + .string() + .optional() + .describe( + 'Docker Hub namespace (username or organization). Falls back to configured default namespace.' + ), + repositoryName: z.string().describe('Name of the repository.'), + tagName: z.string().describe('Name of the tag to read.') + }) + ) + .output( + z.object({ + tagName: z.string().describe('Name of the tag.'), + fullSize: z.number().describe('Full compressed size in bytes.'), + lastUpdated: z.string().describe('ISO timestamp of the last update.'), + lastUpdaterUsername: z.string().describe('Username of the last updater.'), + digest: z.string().describe('Image digest.'), + status: z.string().describe('Tag status.'), + images: z + .array( + z.object({ + architecture: z.string().describe('CPU architecture.'), + os: z.string().describe('Operating system.'), + size: z.number().describe('Compressed size in bytes.'), + digest: z.string().describe('Image digest for this platform.'), + status: z.string().describe('Image status.') + }) + ) + .describe('Platform-specific images for multi-arch tags.') + }) + ) + .handleInvocation(async ctx => { + let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; + + let client = new Client(ctx.auth); + let tag = await client.getTag(ns, ctx.input.repositoryName, ctx.input.tagName); + + return { + output: { + tagName: tag.name, + fullSize: tag.full_size, + lastUpdated: tag.last_updated, + lastUpdaterUsername: tag.last_updater_username || '', + digest: tag.digest || '', + status: tag.tag_status || '', + images: (tag.images || []).map((img: TagImage) => ({ + architecture: img.architecture, + os: img.os, + size: img.size, + digest: img.digest, + status: img.status || '' + })) + }, + message: `Retrieved tag **${ctx.input.tagName}** from **${ns}/${ctx.input.repositoryName}**.` + }; + }) + .build(); diff --git a/integrations/docker-hub/src/tools/index.ts b/integrations/docker-hub/src/tools/index.ts index 2d75c09b44..1184c8ee18 100644 --- a/integrations/docker-hub/src/tools/index.ts +++ b/integrations/docker-hub/src/tools/index.ts @@ -1,17 +1,31 @@ +export { assignRepositoryTeam } from './assign-repository-team'; export { listAuditLogActions, listAuditLogs } from './audit-logs'; export { createRepository } from './create-repository'; export { deleteRepository } from './delete-repository'; export { deleteTag } from './delete-tag'; export { getRepository } from './get-repository'; +export { getTag } from './get-tag'; export { listRepositories } from './list-repositories'; export { listTags } from './list-tags'; export { createAccessToken, deleteAccessToken, + getAccessToken, listAccessTokens, updateAccessToken } from './manage-access-tokens'; -export { listOrgMembers, removeOrgMember } from './manage-org-members'; +export { + updateRepositoryImmutableTags, + verifyRepositoryImmutableTags +} from './manage-immutable-tags'; +export { + createOrgAccessToken, + deleteOrgAccessToken, + getOrgAccessToken, + listOrgAccessTokens, + updateOrgAccessToken +} from './manage-org-access-tokens'; +export { listOrgMembers, removeOrgMember, updateOrgMemberRole } from './manage-org-members'; export { createTeam, deleteTeam, listTeams, manageTeamMembers } from './manage-teams'; export { createWebhook, deleteWebhook, listWebhooks } from './manage-webhooks'; export { searchRepositories } from './search-repositories'; diff --git a/integrations/docker-hub/src/tools/list-repositories.ts b/integrations/docker-hub/src/tools/list-repositories.ts index 91ba1a3f52..97c24064bc 100644 --- a/integrations/docker-hub/src/tools/list-repositories.ts +++ b/integrations/docker-hub/src/tools/list-repositories.ts @@ -23,7 +23,17 @@ export let listRepositories = SlateTool.create(spec, { pageSize: z .number() .optional() - .describe('Number of results per page (default 25, max 100).') + .describe('Number of results per page (default 10, max 100).'), + name: z + .string() + .optional() + .describe('Filter repositories by a partial repository name match.'), + ordering: z + .enum(['name', '-name', 'last_updated', '-last_updated', 'pull_count', '-pull_count']) + .optional() + .describe( + 'Order repositories by name, last_updated, or pull_count. Prefix with "-" for descending order.' + ) }) ) .output( @@ -37,7 +47,24 @@ export let listRepositories = SlateTool.create(spec, { isPrivate: z.boolean().describe('Whether the repository is private.'), starCount: z.number().describe('Number of stars.'), pullCount: z.number().describe('Number of pulls.'), - lastUpdated: z.string().describe('ISO timestamp of the last update.') + lastUpdated: z.string().describe('ISO timestamp of the last update.'), + statusDescription: z.string().optional().describe('Repository status label.'), + categories: z + .array( + z.object({ + name: z.string().describe('Category display name.'), + slug: z.string().describe('Category slug.') + }) + ) + .describe('Docker Hub repository categories.'), + permissions: z + .object({ + read: z.boolean().describe('Whether the caller can read the repository.'), + write: z.boolean().describe('Whether the caller can write to the repository.'), + admin: z.boolean().describe('Whether the caller can administer the repository.') + }) + .optional() + .describe('Caller permissions when returned by Docker Hub.') }) ) }) @@ -48,10 +75,12 @@ export let listRepositories = SlateTool.create(spec, { ns = ctx.auth.username; } - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let result = await client.listRepositories(ns, { page: ctx.input.page, - pageSize: ctx.input.pageSize + pageSize: ctx.input.pageSize, + name: ctx.input.name, + ordering: ctx.input.ordering }); return { @@ -64,7 +93,10 @@ export let listRepositories = SlateTool.create(spec, { isPrivate: r.is_private, starCount: r.star_count, pullCount: r.pull_count, - lastUpdated: r.last_updated + lastUpdated: r.last_updated, + statusDescription: r.status_description, + categories: r.categories || [], + permissions: r.permissions })) }, message: `Found **${result.count}** repositories in namespace **${ns}**.` diff --git a/integrations/docker-hub/src/tools/list-tags.ts b/integrations/docker-hub/src/tools/list-tags.ts index 1a11ee4567..a2283f643a 100644 --- a/integrations/docker-hub/src/tools/list-tags.ts +++ b/integrations/docker-hub/src/tools/list-tags.ts @@ -55,7 +55,7 @@ export let listTags = SlateTool.create(spec, { .handleInvocation(async ctx => { let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let result = await client.listTags(ns, ctx.input.repositoryName, { page: ctx.input.page, pageSize: ctx.input.pageSize diff --git a/integrations/docker-hub/src/tools/manage-access-tokens.ts b/integrations/docker-hub/src/tools/manage-access-tokens.ts index 0d73faccb1..3ba767aaf7 100644 --- a/integrations/docker-hub/src/tools/manage-access-tokens.ts +++ b/integrations/docker-hub/src/tools/manage-access-tokens.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { dockerHubServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listAccessTokens = SlateTool.create(spec, { @@ -30,13 +31,18 @@ export let listAccessTokens = SlateTool.create(spec, { lastUsed: z .string() .nullable() - .describe('ISO timestamp of the last use, or null if never used.') + .describe('ISO timestamp of the last use, or null if never used.'), + expiresAt: z + .string() + .nullable() + .optional() + .describe('ISO timestamp when the token expires, or null for no expiration.') }) ) }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let result = await client.listAccessTokens({ page: ctx.input.page, pageSize: ctx.input.pageSize @@ -51,7 +57,8 @@ export let listAccessTokens = SlateTool.create(spec, { scopes: t.scopes || [], isActive: t.is_active, createdAt: t.created_at, - lastUsed: t.last_used + lastUsed: t.last_used, + expiresAt: t.expires_at })) }, message: `Found **${result.count}** personal access tokens.` @@ -75,7 +82,12 @@ export let createAccessToken = SlateTool.create(spec, { .describe('Display label for the token (e.g., "CI/CD Pipeline", "Dev Machine").'), scopes: z .array(z.string()) - .describe('Permission scopes for the token (e.g., ["repo:read"]).') + .min(1) + .describe('Permission scopes for the token (e.g., ["repo:read"]).'), + expiresAt: z + .string() + .optional() + .describe('Optional expiration date for the token in ISO 8601 format.') }) ) .output( @@ -85,14 +97,20 @@ export let createAccessToken = SlateTool.create(spec, { .string() .describe('The generated token value. Store securely - this is only shown once.'), label: z.string().describe('Display label of the token.'), - scopes: z.array(z.string()).describe('Permission scopes of the token.') + scopes: z.array(z.string()).describe('Permission scopes of the token.'), + expiresAt: z + .string() + .nullable() + .optional() + .describe('ISO timestamp when the token expires, or null for no expiration.') }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let pat = await client.createAccessToken({ token_label: ctx.input.label, - scopes: ctx.input.scopes + scopes: ctx.input.scopes, + expires_at: ctx.input.expiresAt }); return { @@ -100,7 +118,8 @@ export let createAccessToken = SlateTool.create(spec, { tokenUuid: pat.uuid, tokenValue: pat.token, label: pat.token_label, - scopes: pat.scopes || [] + scopes: pat.scopes || [], + expiresAt: pat.expires_at }, message: `Created access token **${pat.token_label}**. Store the token value securely - it won't be shown again.` }; @@ -130,7 +149,11 @@ export let updateAccessToken = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + if (ctx.input.label === undefined && ctx.input.isActive === undefined) { + throw dockerHubServiceError('Provide label or isActive to update the access token.'); + } + + let client = new Client(ctx.auth); let pat = await client.updateAccessToken(ctx.input.tokenUuid, { token_label: ctx.input.label, is_active: ctx.input.isActive @@ -147,6 +170,56 @@ export let updateAccessToken = SlateTool.create(spec, { }) .build(); +export let getAccessToken = SlateTool.create(spec, { + name: 'Get Access Token', + key: 'get_access_token', + description: `Get metadata for a personal access token (PAT) by UUID. Docker Hub does not return the token secret after creation.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + tokenUuid: z.string().describe('UUID of the token to retrieve.') + }) + ) + .output( + z.object({ + tokenUuid: z.string().describe('UUID of the token.'), + label: z.string().describe('Display label for the token.'), + scopes: z.array(z.string()).describe('Permission scopes of the token.'), + isActive: z.boolean().describe('Whether the token is active.'), + createdAt: z.string().describe('ISO timestamp when the token was created.'), + lastUsed: z + .string() + .nullable() + .describe('ISO timestamp of the last use, or null if never used.'), + expiresAt: z + .string() + .nullable() + .optional() + .describe('ISO timestamp when the token expires, or null for no expiration.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth); + let pat = await client.getAccessToken(ctx.input.tokenUuid); + + return { + output: { + tokenUuid: pat.uuid, + label: pat.token_label, + scopes: pat.scopes || [], + isActive: pat.is_active, + createdAt: pat.created_at, + lastUsed: pat.last_used, + expiresAt: pat.expires_at + }, + message: `Retrieved access token **${pat.token_label}**.` + }; + }) + .build(); + export let deleteAccessToken = SlateTool.create(spec, { name: 'Delete Access Token', key: 'delete_access_token', @@ -166,7 +239,7 @@ export let deleteAccessToken = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); await client.deleteAccessToken(ctx.input.tokenUuid); return { diff --git a/integrations/docker-hub/src/tools/manage-immutable-tags.ts b/integrations/docker-hub/src/tools/manage-immutable-tags.ts new file mode 100644 index 0000000000..4793dde3b5 --- /dev/null +++ b/integrations/docker-hub/src/tools/manage-immutable-tags.ts @@ -0,0 +1,114 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { dockerHubServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let updateRepositoryImmutableTags = SlateTool.create(spec, { + name: 'Update Repository Immutable Tags', + key: 'update_repository_immutable_tags', + description: `Update immutable tag settings for a Docker Hub repository. Immutable tags prevent matching image tags from being overwritten after they are pushed.`, + instructions: [ + 'Only users with administrative privileges for the repository can modify immutable tag settings.', + 'When enabling immutable tags, provide at least one regex rule.' + ] +}) + .input( + z.object({ + namespace: z + .string() + .optional() + .describe( + 'Docker Hub namespace (username or organization). Falls back to configured default namespace.' + ), + repositoryName: z.string().describe('Name of the repository.'), + enabled: z.boolean().describe('Whether immutable tags should be enabled.'), + rules: z + .array(z.string()) + .optional() + .describe('Immutable tag regex rules. Required when enabled is true.') + }) + ) + .output( + z.object({ + namespace: z.string().describe('Namespace the repository belongs to.'), + repositoryName: z.string().describe('Name of the repository.'), + immutableTags: z.object({ + enabled: z.boolean().describe('Whether immutable tags are enabled.'), + rules: z.array(z.string()).describe('Immutable tag regex rules.') + }) + }) + ) + .handleInvocation(async ctx => { + let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; + let rules = ctx.input.rules || []; + + if (ctx.input.enabled && rules.length === 0) { + throw dockerHubServiceError( + 'Provide at least one immutable tag rule when enabling immutable tags.' + ); + } + + let client = new Client(ctx.auth); + let repo = await client.updateRepositoryImmutableTags(ns, ctx.input.repositoryName, { + immutable_tags: ctx.input.enabled, + immutable_tags_rules: rules + }); + + return { + output: { + namespace: repo.namespace, + repositoryName: repo.name, + immutableTags: repo.immutable_tags_settings || { + enabled: ctx.input.enabled, + rules + } + }, + message: `Updated immutable tag settings for **${ns}/${ctx.input.repositoryName}**.` + }; + }) + .build(); + +export let verifyRepositoryImmutableTags = SlateTool.create(spec, { + name: 'Verify Repository Immutable Tags', + key: 'verify_repository_immutable_tags', + description: `Validate an immutable tag regex rule for a Docker Hub repository and return repository tags that match the rule.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + namespace: z + .string() + .optional() + .describe( + 'Docker Hub namespace (username or organization). Falls back to configured default namespace.' + ), + repositoryName: z.string().describe('Name of the repository.'), + regex: z.string().describe('Immutable tag regex rule to validate.') + }) + ) + .output( + z.object({ + matchingTags: z.array(z.string()).describe('Repository tags that match the regex.') + }) + ) + .handleInvocation(async ctx => { + let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; + + let client = new Client(ctx.auth); + let result = await client.verifyRepositoryImmutableTags( + ns, + ctx.input.repositoryName, + ctx.input.regex + ); + + return { + output: { + matchingTags: result.tags || [] + }, + message: `Verified immutable tag rule for **${ns}/${ctx.input.repositoryName}**.` + }; + }) + .build(); diff --git a/integrations/docker-hub/src/tools/manage-org-access-tokens.ts b/integrations/docker-hub/src/tools/manage-org-access-tokens.ts new file mode 100644 index 0000000000..5e259bd418 --- /dev/null +++ b/integrations/docker-hub/src/tools/manage-org-access-tokens.ts @@ -0,0 +1,261 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client, type OrgAccessTokenResource } from '../lib/client'; +import { dockerHubServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let orgAccessTokenResourceSchema = z.object({ + type: z + .enum(['TYPE_REPO', 'TYPE_ORG']) + .describe('Resource type. Use TYPE_REPO for repository paths or TYPE_ORG for org scope.'), + path: z + .string() + .describe( + 'Resource path, such as "myorg/myrepo", "myorg/*", or "*/*/public" for public repositories.' + ), + scopes: z + .array(z.string()) + .min(1) + .describe( + 'Docker organization token scopes for this resource, such as "scope-image-pull".' + ) +}); + +let orgAccessTokenOutput = z.object({ + tokenId: z.string().describe('Organization access token ID.'), + label: z.string().describe('Token label.'), + description: z + .string() + .optional() + .describe('Token description when returned by Docker Hub.'), + createdBy: z.string().describe('Docker Hub username that created the token.'), + isActive: z.boolean().describe('Whether the token is active.'), + createdAt: z.string().describe('ISO timestamp when the token was created.'), + expiresAt: z + .string() + .nullable() + .describe('ISO timestamp when the token expires, or null for no expiration.'), + lastUsedAt: z + .string() + .nullable() + .describe('ISO timestamp when the token was last used, or null if never used.'), + resources: z + .array(orgAccessTokenResourceSchema) + .optional() + .describe('Resources this organization token can access.') +}); + +let formatOrgAccessToken = (token: { + id: string; + label: string; + description?: string; + created_by: string; + is_active: boolean; + created_at: string; + expires_at: string | null; + last_used_at: string | null; + resources?: OrgAccessTokenResource[]; +}) => ({ + tokenId: token.id, + label: token.label, + description: token.description, + createdBy: token.created_by || '', + isActive: token.is_active, + createdAt: token.created_at, + expiresAt: token.expires_at, + lastUsedAt: token.last_used_at, + resources: token.resources +}); + +export let listOrgAccessTokens = SlateTool.create(spec, { + name: 'List Organization Access Tokens', + key: 'list_org_access_tokens', + description: `List Docker Hub organization access tokens (OATs) for an organization. OATs are organization-owned automation tokens managed by organization owners.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + orgName: z.string().describe('Name of the Docker Hub organization.'), + page: z.number().optional().describe('Page number for pagination.'), + pageSize: z.number().optional().describe('Number of results per page.') + }) + ) + .output( + z.object({ + totalCount: z.number().describe('Total number of organization access tokens.'), + tokens: z.array(orgAccessTokenOutput).describe('Organization access tokens.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth); + let result = await client.listOrgAccessTokens(ctx.input.orgName, { + page: ctx.input.page, + pageSize: ctx.input.pageSize + }); + + return { + output: { + totalCount: result.total, + tokens: result.results.map(formatOrgAccessToken) + }, + message: `Found **${result.total}** organization access tokens in **${ctx.input.orgName}**.` + }; + }) + .build(); + +export let getOrgAccessToken = SlateTool.create(spec, { + name: 'Get Organization Access Token', + key: 'get_org_access_token', + description: `Get details for a Docker Hub organization access token, including active status, expiration, and resource scopes.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + orgName: z.string().describe('Name of the Docker Hub organization.'), + tokenId: z.string().describe('Organization access token ID.') + }) + ) + .output(orgAccessTokenOutput) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth); + let token = await client.getOrgAccessToken(ctx.input.orgName, ctx.input.tokenId); + + return { + output: formatOrgAccessToken(token), + message: `Retrieved organization access token **${token.label}**.` + }; + }) + .build(); + +export let createOrgAccessToken = SlateTool.create(spec, { + name: 'Create Organization Access Token', + key: 'create_org_access_token', + description: `Create a Docker Hub organization access token (OAT) for automation. The token value is returned only once when the token is created.`, + instructions: [ + 'The generated organization token value is only returned once - store it securely.', + 'Use repository resources such as "myorg/myrepo" or "myorg/*" with scopes such as "scope-image-pull" or "scope-image-push".' + ] +}) + .input( + z.object({ + orgName: z.string().describe('Name of the Docker Hub organization.'), + label: z.string().describe('Label for the organization access token.'), + description: z.string().optional().describe('Description for the token.'), + resources: z + .array(orgAccessTokenResourceSchema) + .optional() + .describe('Resources this organization token can access.'), + expiresAt: z + .string() + .optional() + .describe('Optional expiration date for the token in ISO 8601 format.') + }) + ) + .output( + orgAccessTokenOutput.extend({ + tokenValue: z + .string() + .describe('The generated organization token value. Store securely - shown once.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth); + let token = await client.createOrgAccessToken(ctx.input.orgName, { + label: ctx.input.label, + description: ctx.input.description, + resources: ctx.input.resources, + expires_at: ctx.input.expiresAt + }); + + return { + output: { + ...formatOrgAccessToken(token), + tokenValue: token.token || '' + }, + message: `Created organization access token **${token.label}**. Store the token value securely - it won't be shown again.` + }; + }) + .build(); + +export let updateOrgAccessToken = SlateTool.create(spec, { + name: 'Update Organization Access Token', + key: 'update_org_access_token', + description: `Update a Docker Hub organization access token's label, description, resources, or active status.` +}) + .input( + z.object({ + orgName: z.string().describe('Name of the Docker Hub organization.'), + tokenId: z.string().describe('Organization access token ID.'), + label: z.string().optional().describe('New token label.'), + description: z.string().optional().describe('New token description.'), + resources: z + .array(orgAccessTokenResourceSchema) + .optional() + .describe('Replacement resource scope list for the token.'), + isActive: z + .boolean() + .optional() + .describe('Set to false to deactivate the token, true to reactivate.') + }) + ) + .output(orgAccessTokenOutput) + .handleInvocation(async ctx => { + if ( + ctx.input.label === undefined && + ctx.input.description === undefined && + ctx.input.resources === undefined && + ctx.input.isActive === undefined + ) { + throw dockerHubServiceError( + 'Provide label, description, resources, or isActive to update the organization access token.' + ); + } + + let client = new Client(ctx.auth); + let token = await client.updateOrgAccessToken(ctx.input.orgName, ctx.input.tokenId, { + label: ctx.input.label, + description: ctx.input.description, + resources: ctx.input.resources, + is_active: ctx.input.isActive + }); + + return { + output: formatOrgAccessToken(token), + message: `Updated organization access token **${token.label}**.` + }; + }) + .build(); + +export let deleteOrgAccessToken = SlateTool.create(spec, { + name: 'Delete Organization Access Token', + key: 'delete_org_access_token', + description: `Permanently delete a Docker Hub organization access token. Any automation using it immediately loses access.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + orgName: z.string().describe('Name of the Docker Hub organization.'), + tokenId: z.string().describe('Organization access token ID.') + }) + ) + .output( + z.object({ + deleted: z.boolean().describe('Whether the token was successfully deleted.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth); + await client.deleteOrgAccessToken(ctx.input.orgName, ctx.input.tokenId); + + return { + output: { deleted: true }, + message: `Deleted organization access token **${ctx.input.tokenId}**.` + }; + }) + .build(); diff --git a/integrations/docker-hub/src/tools/manage-org-members.ts b/integrations/docker-hub/src/tools/manage-org-members.ts index 66cde1dbd3..da388a3082 100644 --- a/integrations/docker-hub/src/tools/manage-org-members.ts +++ b/integrations/docker-hub/src/tools/manage-org-members.ts @@ -34,7 +34,7 @@ export let listOrgMembers = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let result = await client.listOrgMembers(ctx.input.orgName, { page: ctx.input.page, pageSize: ctx.input.pageSize @@ -77,7 +77,7 @@ export let removeOrgMember = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); await client.removeOrgMember(ctx.input.orgName, ctx.input.username); return { @@ -86,3 +86,41 @@ export let removeOrgMember = SlateTool.create(spec, { }; }) .build(); + +export let updateOrgMemberRole = SlateTool.create(spec, { + name: 'Update Organization Member Role', + key: 'update_org_member_role', + description: `Update a Docker Hub organization member's role. Use this for organization member lifecycle management without removing and re-inviting the user.` +}) + .input( + z.object({ + orgName: z.string().describe('Name of the Docker Hub organization.'), + username: z.string().describe('Docker Hub username of the member to update.'), + role: z + .enum(['owner', 'editor', 'member']) + .describe('Organization role to assign to the member.') + }) + ) + .output( + z.object({ + username: z.string().describe('Docker Hub username of the updated member.'), + role: z.string().describe('Updated role in the organization.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth); + let member = await client.updateOrgMemberRole( + ctx.input.orgName, + ctx.input.username, + ctx.input.role + ); + + return { + output: { + username: member.username || ctx.input.username, + role: member.role || ctx.input.role + }, + message: `Updated **${ctx.input.username}** to **${ctx.input.role}** in organization **${ctx.input.orgName}**.` + }; + }) + .build(); diff --git a/integrations/docker-hub/src/tools/manage-teams.ts b/integrations/docker-hub/src/tools/manage-teams.ts index 3f12e3eef0..dd9ca16939 100644 --- a/integrations/docker-hub/src/tools/manage-teams.ts +++ b/integrations/docker-hub/src/tools/manage-teams.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { dockerHubServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listTeams = SlateTool.create(spec, { @@ -32,7 +33,7 @@ export let listTeams = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let result = await client.listTeams(ctx.input.orgName, { page: ctx.input.page, pageSize: ctx.input.pageSize @@ -73,7 +74,7 @@ export let createTeam = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let team = await client.createTeam(ctx.input.orgName, { name: ctx.input.teamName, description: ctx.input.description @@ -110,7 +111,7 @@ export let deleteTeam = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); await client.deleteTeam(ctx.input.orgName, ctx.input.teamName); return { @@ -156,7 +157,7 @@ export let manageTeamMembers = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); if (ctx.input.action === 'list') { let result = await client.listTeamMembers(ctx.input.orgName, ctx.input.teamName); @@ -173,7 +174,7 @@ export let manageTeamMembers = SlateTool.create(spec, { } if (!ctx.input.username) { - throw new Error(`Username is required for "${ctx.input.action}" action.`); + throw dockerHubServiceError(`Username is required for "${ctx.input.action}" action.`); } if (ctx.input.action === 'add') { diff --git a/integrations/docker-hub/src/tools/manage-webhooks.ts b/integrations/docker-hub/src/tools/manage-webhooks.ts index c7d66922f7..69abb0d75e 100644 --- a/integrations/docker-hub/src/tools/manage-webhooks.ts +++ b/integrations/docker-hub/src/tools/manage-webhooks.ts @@ -37,7 +37,7 @@ export let listWebhooks = SlateTool.create(spec, { .handleInvocation(async ctx => { let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let result = await client.listWebhooks(ns, ctx.input.repositoryName); return { @@ -86,7 +86,7 @@ export let createWebhook = SlateTool.create(spec, { .handleInvocation(async ctx => { let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let webhook = await client.createWebhook(ns, ctx.input.repositoryName, { name: ctx.input.webhookName, webhookUrl: ctx.input.webhookUrl @@ -129,7 +129,7 @@ export let deleteWebhook = SlateTool.create(spec, { .handleInvocation(async ctx => { let ns = ctx.input.namespace || ctx.config.namespace || ctx.auth.username; - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); await client.deleteWebhook(ns, ctx.input.repositoryName, ctx.input.webhookId); return { diff --git a/integrations/docker-hub/src/tools/search-repositories.ts b/integrations/docker-hub/src/tools/search-repositories.ts index 0a47b6ee69..06ac1694cc 100644 --- a/integrations/docker-hub/src/tools/search-repositories.ts +++ b/integrations/docker-hub/src/tools/search-repositories.ts @@ -36,7 +36,7 @@ export let searchRepositories = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); let result = await client.searchRepositories(ctx.input.query, { page: ctx.input.page, pageSize: ctx.input.pageSize diff --git a/integrations/docker-hub/src/tools/update-repository.ts b/integrations/docker-hub/src/tools/update-repository.ts index ae5a79ac5f..0cdf697ec5 100644 --- a/integrations/docker-hub/src/tools/update-repository.ts +++ b/integrations/docker-hub/src/tools/update-repository.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { dockerHubServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateRepository = SlateTool.create(spec, { @@ -48,7 +49,13 @@ export let updateRepository = SlateTool.create(spec, { updateData.full_description = ctx.input.fullDescription; if (ctx.input.isPrivate !== undefined) updateData.is_private = ctx.input.isPrivate; - let client = new Client({ token: ctx.auth.token }); + if (Object.keys(updateData).length === 0) { + throw dockerHubServiceError( + 'Provide description, fullDescription, or isPrivate to update the repository.' + ); + } + + let client = new Client(ctx.auth); let repo = await client.updateRepository(ns, ctx.input.repositoryName, updateData); return { diff --git a/integrations/docker-hub/src/triggers/image-push.ts b/integrations/docker-hub/src/triggers/image-push.ts index 4757c131c2..78d329aaed 100644 --- a/integrations/docker-hub/src/triggers/image-push.ts +++ b/integrations/docker-hub/src/triggers/image-push.ts @@ -71,7 +71,7 @@ export let imagePush = SlateTrigger.create(spec, { repositoryName?: string; }; if (details.webhookId && details.namespace && details.repositoryName) { - let client = new Client({ token: ctx.auth.token }); + let client = new Client(ctx.auth); await client.deleteWebhook( details.namespace, details.repositoryName, diff --git a/integrations/docparser/README.md b/integrations/docparser/README.md index 820e50d44f..5715633f14 100644 --- a/integrations/docparser/README.md +++ b/integrations/docparser/README.md @@ -1,6 +1,6 @@ # Docparser -Extract structured data from PDFs, Word documents, and image-based documents using OCR and pattern recognition. Import documents via file upload, base64 content, or URL fetch. Manage document parsers and model layouts, track document processing status, and retrieve parsed data as JSON or flat key/value pairs. Re-parse documents after rule changes and re-integrate results through webhooks. Filter and sort parsed results by date, processing status, or remote ID. +Extract structured data from PDFs, Word documents, and image-based documents using OCR and pattern recognition. Import documents via base64 content or URL fetch, manage document parsers and model layouts, track document processing status, and retrieve parsed data as JSON or flat key/value pairs. Re-parse documents after rule changes and re-integrate results through webhooks. Filter and sort parsed results by date, processing status, processing queue inclusion, or remote ID. ## License diff --git a/integrations/docparser/docs/SPEC.md b/integrations/docparser/docs/SPEC.md index 2fb051fa48..6e889dabae 100644 --- a/integrations/docparser/docs/SPEC.md +++ b/integrations/docparser/docs/SPEC.md @@ -26,37 +26,37 @@ There are no OAuth flows or scopes. A single API key provides full access to all ### Document Parser Management -List all Document Parsers in your account along with their IDs and labels. You can also list Model Layouts (templates) configured within a specific parser. Parser IDs are required for most other API operations. +List all Document Parsers in your account along with their IDs and labels. You can also list Model Layouts (templates) configured within a specific parser through `GET /v1/parser/models/`. Parser IDs are required for most other API operations. ### Document Import Import documents into a specific Document Parser for processing. Three import methods are supported: -- **File upload:** Upload a file directly via multipart form-data. -- **Base64 content:** Send file content as base64-encoded data along with an optional filename. -- **Fetch from URL:** Provide a publicly accessible URL from which Docparser will retrieve the document. +- **File upload:** Upload a file directly to `POST /v1/document/upload/` via multipart form-data field `file`. +- **Base64 content:** Send base64-encoded content to `POST /v1/document/upload/` via multipart form-data field `file_content` and optional `file_name`. +- **Fetch from URL:** Provide a publicly accessible URL to `POST /v2/document/fetch/` via multipart form-data field `url`. All import methods accept an optional `remote_id` parameter — an arbitrary string that stays associated with the document through processing and is included when retrieving parsed data, useful for correlating results with records in your own system. ### Document Status Tracking -Check the processing status of a specific document, including timestamps for upload, import, OCR, preprocessing, parsing, and webhook dispatch. Failed processing jobs are listed in the response. +Check the processing status of a specific document with `GET /v2/document/status//`, including timestamps and state flags for import, processing, and webhook dispatch. Failed processing jobs are listed in the response. ### Parsed Data Retrieval Retrieve structured data extracted from documents. Data can be fetched for a single document or for multiple documents from a parser. Results include parsed fields, metadata (filename, page count, timestamps), media links, and the optional `remote_id`. - **Format options:** Results can be returned as nested JSON objects or as flat key/value pairs. -- **Filtering:** Results for multiple documents can be filtered by upload date, processing date, or `remote_id`. -- **Sorting:** Results can be sorted by various timestamp fields in ascending or descending order. -- **Child documents:** If a document was split during preprocessing, child document data can be included. +- **Filtering:** Results for multiple documents can be filtered by `list`, `date`, `remote_id`, and whether the processing queue should be included. The `date` parameter is required when `list` is `uploaded_after` or `processed_after`. +- **Sorting:** Results can be sorted by documented timestamp fields with `sort_by` and `sort_order`. +- **Child documents:** If a document was split during preprocessing, child document data can be included when fetching one document. ### Re-Parse and Re-Integrate - **Re-parse:** Schedule previously imported documents to be parsed again (e.g., after updating parsing rules). - **Re-integrate:** Schedule documents to be re-sent through configured integrations and webhooks. -Both operations accept an array of document IDs. +Both operations accept an array of document IDs sent as repeated `document_ids[]` form fields. ## Events diff --git a/integrations/docparser/package.json b/integrations/docparser/package.json index 5fb4bc65b9..8995cb4434 100644 --- a/integrations/docparser/package.json +++ b/integrations/docparser/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/docparser/src/auth.ts b/integrations/docparser/src/auth.ts index 4426cc510a..671a543682 100644 --- a/integrations/docparser/src/auth.ts +++ b/integrations/docparser/src/auth.ts @@ -1,5 +1,7 @@ -import { createAxios, SlateAuth } from 'slates'; +import { SlateAuth } from 'slates'; import { z } from 'zod'; +import { Client } from './lib/client'; +import { docparserApiError, docparserServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -29,24 +31,21 @@ export let auth = SlateAuth.create() }, getProfile: async (ctx: { output: { token: string }; input: { token: string } }) => { - let client = createAxios({ - baseURL: 'https://api.docparser.com/v1', - auth: { - username: ctx.output.token, - password: '' - } - }); + try { + let client = new Client({ token: ctx.output.token }); + let response = await client.ping(); - let response = await client.get('/ping'); + if (response.msg === 'pong') { + return { + profile: { + name: 'Docparser Account' + } + }; + } - if (response.data?.msg === 'pong') { - return { - profile: { - name: 'Docparser Account' - } - }; + throw docparserServiceError('Invalid Docparser API key.'); + } catch (error) { + throw docparserApiError(error, 'authentication check'); } - - throw new Error('Invalid API key'); } }); diff --git a/integrations/docparser/src/lib/client.ts b/integrations/docparser/src/lib/client.ts index ede135aba7..77ba431375 100644 --- a/integrations/docparser/src/lib/client.ts +++ b/integrations/docparser/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { applyDocparserApiErrorInterceptor } from './errors'; export interface DocparserParser { parserId: string; @@ -13,55 +14,98 @@ export interface ModelLayout { export interface ImportResult { documentId: string; + parserId?: string; + remoteId?: string; + fileSize?: number; pageCount: number; uploadDuration: number; quotaUsed: number; quotaLeft: number; quotaRefill: string; + message?: string; } export interface DocumentStatus { documentId: string; + token?: string; + fileSource?: string; + filename?: string; + mimeType?: string; + pages?: number; + supported?: boolean; + importingInProgress?: boolean; + processingInProgress?: boolean; + webhookDispatchingInProgress?: boolean; uploadedAt: string; importedAt: string; ocrAt: string; preprocessedAt: string; parsedAt: string; + firstProcessedAt: string; webhookAt: string; + dispatchedWebhook?: boolean; + dispatchedWebhookProblem?: boolean; failedJobs: any[]; [key: string]: any; } +export type ParsedDataSortBy = + | 'parsed_at' + | 'processed_at' + | 'uploaded_at' + | 'first_processed_at' + | 'imported_at' + | 'integrated_at' + | 'dispatched_webhook_at' + | 'preprocessed_at'; + export interface ParsedDataOptions { format?: 'object' | 'flat'; list?: 'last_uploaded' | 'uploaded_after' | 'processed_after'; + date?: string; remoteId?: string; - sort?: string; - order?: 'asc' | 'desc'; + sortBy?: ParsedDataSortBy; + sortOrder?: 'ASC' | 'DESC'; limit?: number; + includeProcessingQueue?: boolean; +} + +export interface ParsedDocumentOptions { + format?: 'object' | 'flat'; includeChildren?: boolean; } +export interface ReparseResult { + totalReparsed: number; + message: string; +} + +export interface ReintegrateResult { + totalReintegrated: number; + message: string; +} + export class Client { private axios: ReturnType; constructor(config: { token: string }) { this.axios = createAxios({ - baseURL: 'https://api.docparser.com/v1', + baseURL: 'https://api.docparser.com', auth: { username: config.token, password: '' } }); + applyDocparserApiErrorInterceptor(this.axios); } async ping(): Promise<{ msg: string }> { - let response = await this.axios.get('/ping'); + let response = await this.axios.get('/v1/ping'); return response.data; } async listParsers(): Promise { - let response = await this.axios.get('/parsers'); + let response = await this.axios.get('/v1/parsers'); return (response.data || []).map((p: any) => ({ parserId: p.id, label: p.label @@ -69,7 +113,7 @@ export class Client { } async listModelLayouts(parserId: string): Promise { - let response = await this.axios.get(`/parser/layouts/${parserId}`); + let response = await this.axios.get(`/v1/parser/models/${parserId}`); return (response.data || []).map((l: any) => ({ layoutId: l.id, label: l.label, @@ -90,11 +134,7 @@ export class Client { formData.append('remote_id', remoteId); } - let response = await this.axios.post(`/document/upload/${parserId}`, formData, { - headers: { - 'Content-Type': 'multipart/form-data' - } - }); + let response = await this.axios.post(`/v1/document/upload/${parserId}`, formData); return this.mapImportResult(response.data); } @@ -105,17 +145,16 @@ export class Client { fileName?: string, remoteId?: string ): Promise { - let body: Record = { - file_content: fileContent - }; + let formData = new FormData(); + formData.append('file_content', fileContent); if (fileName) { - body.file_name = fileName; + formData.append('file_name', fileName); } if (remoteId) { - body.remote_id = remoteId; + formData.append('remote_id', remoteId); } - let response = await this.axios.post(`/document/upload/${parserId}`, body); + let response = await this.axios.post(`/v1/document/upload/${parserId}`, formData); return this.mapImportResult(response.data); } @@ -124,28 +163,39 @@ export class Client { fileUrl: string, remoteId?: string ): Promise { - let body: Record = { - url: fileUrl - }; + let formData = new FormData(); + formData.append('url', fileUrl); if (remoteId) { - body.remote_id = remoteId; + formData.append('remote_id', remoteId); } - let response = await this.axios.post(`/document/fetch/${parserId}`, body); + let response = await this.axios.post(`/v2/document/fetch/${parserId}`, formData); return this.mapImportResult(response.data); } async getDocumentStatus(parserId: string, documentId: string): Promise { - let response = await this.axios.get(`/document/status/${parserId}/${documentId}`); + let response = await this.axios.get(`/v2/document/status/${parserId}/${documentId}`); let data = response.data; return { - documentId: data.id || documentId, - uploadedAt: data.uploaded_at || '', - importedAt: data.imported_at || '', - ocrAt: data.ocr_at || '', - preprocessedAt: data.preprocessed_at || '', - parsedAt: data.parsed_at || '', - webhookAt: data.webhook_at || '', + documentId, + token: data.token, + fileSource: data.file_source, + filename: data.filename ?? data.file_name, + mimeType: data.mime_type, + pages: data.pages, + supported: data.supported, + importingInProgress: data.importing_in_progress, + processingInProgress: data.processing_in_progress, + webhookDispatchingInProgress: data.webhook_dispatching_in_progress, + uploadedAt: this.mapTimestamp(data.uploaded_at), + importedAt: this.mapTimestamp(data.imported_at), + ocrAt: this.mapTimestamp(data.ocr_at ?? data.ocr_started_at), + preprocessedAt: this.mapTimestamp(data.preprocessed_at), + parsedAt: this.mapTimestamp(data.parsed_at ?? data.processed_at), + firstProcessedAt: this.mapTimestamp(data.first_processed_at), + webhookAt: this.mapTimestamp(data.webhook_at ?? data.dispatched_webhook_at), + dispatchedWebhook: data.dispatched_webhook, + dispatchedWebhookProblem: data.dispatched_webhook_problem, failedJobs: data.failed_jobs || [], ...data }; @@ -154,14 +204,17 @@ export class Client { async getParsedDataByDocument( parserId: string, documentId: string, - format?: 'object' | 'flat' + options?: ParsedDocumentOptions ): Promise { - let params: Record = {}; - if (format) { - params.format = format; + let params: Record = {}; + if (options?.format) { + params.format = options.format; + } + if (options?.includeChildren) { + params.include_children = options.includeChildren; } - let response = await this.axios.get(`/results/${parserId}/${documentId}`, { params }); + let response = await this.axios.get(`/v1/results/${parserId}/${documentId}`, { params }); return Array.isArray(response.data) ? response.data : [response.data]; } @@ -169,38 +222,72 @@ export class Client { let params: Record = {}; if (options?.format) params.format = options.format; if (options?.list) params.list = options.list; + if (options?.date) params.date = options.date; if (options?.remoteId) params.remote_id = options.remoteId; - if (options?.sort) params.sort = options.sort; - if (options?.order) params.order = options.order; + if (options?.sortBy) params.sort_by = options.sortBy; + if (options?.sortOrder) params.sort_order = options.sortOrder; if (options?.limit) params.limit = options.limit; - if (options?.includeChildren) params.include_children = options.includeChildren; + if (options?.includeProcessingQueue) { + params.include_processing_queue = options.includeProcessingQueue; + } - let response = await this.axios.get(`/results/${parserId}`, { params }); + let response = await this.axios.get(`/v1/results/${parserId}`, { params }); return Array.isArray(response.data) ? response.data : [response.data]; } - async reparseDocuments(parserId: string, documentIds: string[]): Promise { - let response = await this.axios.post(`/document/reparse/${parserId}`, { - document_ids: documentIds - }); - return response.data; + async reparseDocuments(parserId: string, documentIds: string[]): Promise { + let formData = new FormData(); + for (let documentId of documentIds) { + formData.append('document_ids[]', documentId); + } + + let response = await this.axios.post(`/v1/document/reparse/${parserId}`, formData); + return { + totalReparsed: Number(response.data?.total_reparsed ?? documentIds.length), + message: typeof response.data?.msg === 'string' ? response.data.msg : '' + }; } - async reintegrateDocuments(parserId: string, documentIds: string[]): Promise { - let response = await this.axios.post(`/document/reintegrate/${parserId}`, { - document_ids: documentIds - }); - return response.data; + async reintegrateDocuments( + parserId: string, + documentIds: string[] + ): Promise { + let formData = new FormData(); + for (let documentId of documentIds) { + formData.append('document_ids[]', documentId); + } + + let response = await this.axios.post(`/v1/document/reintegrate/${parserId}`, formData); + return { + totalReintegrated: Number(response.data?.total_reintegrate ?? documentIds.length), + message: typeof response.data?.msg === 'string' ? response.data.msg : '' + }; } private mapImportResult(data: any): ImportResult { return { - documentId: data.id || '', - pageCount: data.page_count ?? 0, - uploadDuration: data.upload_duration ?? 0, - quotaUsed: data.quota_used ?? 0, - quotaLeft: data.quota_left ?? 0, - quotaRefill: data.quota_refill || '' + documentId: String(data.id ?? data.document_id ?? ''), + parserId: data.parser_id, + remoteId: data.remote_id, + fileSize: data.file_size, + pageCount: Number(data.page_count ?? data.pages ?? 0), + uploadDuration: Number(data.upload_duration ?? 0), + quotaUsed: Number(data.quota_used ?? 0), + quotaLeft: Number(data.quota_left ?? 0), + quotaRefill: data.quota_refill || '', + message: data.message }; } + + private mapTimestamp(value: unknown) { + if (typeof value === 'number') { + return value > 0 ? new Date(value * 1000).toISOString() : ''; + } + + if (typeof value === 'string') { + return value; + } + + return ''; + } } diff --git a/integrations/docparser/src/lib/errors.ts b/integrations/docparser/src/lib/errors.ts new file mode 100644 index 0000000000..1e2af14ffb --- /dev/null +++ b/integrations/docparser/src/lib/errors.ts @@ -0,0 +1,105 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + for (let key of ['message', 'msg', 'detail', 'title', 'error', 'error_description']) { + pushDetail(details, value[key]); + } + + let errors = value.errors; + if (isRecord(errors)) { + for (let [field, fieldErrors] of Object.entries(errors)) { + let fieldDetails: string[] = []; + collectDetails(fieldErrors, fieldDetails); + for (let detail of fieldDetails) { + pushDetail(details, `${field}: ${detail}`); + } + } + } else { + collectDetails(errors, details); + } +}; + +let errorResponse = (error: unknown) => + isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let extractDocparserMessage = (error: unknown) => { + let details: string[] = []; + collectDetails(errorResponse(error)?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let docparserServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let docparserApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = errorResponse(error); + let serviceError = docparserServiceError( + `Docparser API ${operation} failed: ${statusLabelFor(response)}${extractDocparserMessage(error)}` + ); + + serviceError.data.reason = 'docparser_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let applyDocparserApiErrorInterceptor = (axios: unknown) => { + (axios as any).interceptors?.response?.use?.( + (response: any) => response, + (error: unknown) => Promise.reject(docparserApiError(error)) + ); +}; diff --git a/integrations/docparser/src/tools/get-document-status.ts b/integrations/docparser/src/tools/get-document-status.ts index c4a86348bb..29c74be725 100644 --- a/integrations/docparser/src/tools/get-document-status.ts +++ b/integrations/docparser/src/tools/get-document-status.ts @@ -20,12 +20,33 @@ export let getDocumentStatus = SlateTool.create(spec, { .output( z.object({ documentId: z.string().describe('ID of the document'), + token: z.string().optional().describe('Docparser document token when returned'), + fileSource: z.string().optional().describe('How the document entered Docparser'), + filename: z.string().optional().describe('Filename associated with the document'), + mimeType: z.string().optional().describe('Detected MIME type'), + pages: z.number().optional().describe('Detected page count'), + supported: z.boolean().optional().describe('Whether Docparser supports this document'), + importingInProgress: z.boolean().optional().describe('Whether import is still running'), + processingInProgress: z + .boolean() + .optional() + .describe('Whether parsing is still running'), + webhookDispatchingInProgress: z + .boolean() + .optional() + .describe('Whether webhook dispatching is still running'), uploadedAt: z.string().describe('Timestamp when the document was uploaded'), importedAt: z.string().describe('Timestamp when the document was imported'), ocrAt: z.string().describe('Timestamp when OCR was completed'), preprocessedAt: z.string().describe('Timestamp when preprocessing was completed'), parsedAt: z.string().describe('Timestamp when parsing was completed'), + firstProcessedAt: z.string().describe('Timestamp when parsing first completed'), webhookAt: z.string().describe('Timestamp when webhook was dispatched'), + dispatchedWebhook: z.boolean().optional().describe('Whether webhook dispatch completed'), + dispatchedWebhookProblem: z + .boolean() + .optional() + .describe('Whether webhook dispatch reported a problem'), failedJobs: z.array(z.any()).describe('List of failed processing jobs, if any') }) ) @@ -39,12 +60,24 @@ export let getDocumentStatus = SlateTool.create(spec, { return { output: { documentId: status.documentId, + token: status.token, + fileSource: status.fileSource, + filename: status.filename, + mimeType: status.mimeType, + pages: status.pages, + supported: status.supported, + importingInProgress: status.importingInProgress, + processingInProgress: status.processingInProgress, + webhookDispatchingInProgress: status.webhookDispatchingInProgress, uploadedAt: status.uploadedAt, importedAt: status.importedAt, ocrAt: status.ocrAt, preprocessedAt: status.preprocessedAt, parsedAt: status.parsedAt, + firstProcessedAt: status.firstProcessedAt, webhookAt: status.webhookAt, + dispatchedWebhook: status.dispatchedWebhook, + dispatchedWebhookProblem: status.dispatchedWebhookProblem, failedJobs: status.failedJobs }, message: hasFailed diff --git a/integrations/docparser/src/tools/get-parsed-data.ts b/integrations/docparser/src/tools/get-parsed-data.ts index 2adafc7f23..593fc9126c 100644 --- a/integrations/docparser/src/tools/get-parsed-data.ts +++ b/integrations/docparser/src/tools/get-parsed-data.ts @@ -1,8 +1,20 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { Client } from '../lib/client'; +import { Client, type ParsedDataSortBy } from '../lib/client'; +import { docparserServiceError } from '../lib/errors'; import { spec } from '../spec'; +let sortByValues = [ + 'parsed_at', + 'processed_at', + 'uploaded_at', + 'first_processed_at', + 'imported_at', + 'integrated_at', + 'dispatched_webhook_at', + 'preprocessed_at' +] as const; + export let getParsedData = SlateTool.create(spec, { name: 'Get Parsed Data', key: 'get_parsed_data', @@ -34,17 +46,37 @@ export let getParsedData = SlateTool.create(spec, { .enum(['last_uploaded', 'uploaded_after', 'processed_after']) .optional() .describe('Filter mode for multi-document results'), - remoteId: z.string().optional().describe('Filter results by remote ID'), - sort: z + date: z .string() .optional() - .describe('Field to sort results by (e.g. a timestamp field)'), - order: z.enum(['asc', 'desc']).optional().describe('Sort order'), - limit: z.number().optional().describe('Maximum number of results to return'), + .describe( + 'ISO 8601 date string or Linux timestamp. Required when list is uploaded_after or processed_after.' + ), + remoteId: z.string().optional().describe('Filter results by remote ID'), + sortBy: z + .enum(sortByValues) + .optional() + .describe('Document timestamp field to sort multi-document results by'), + sortOrder: z.enum(['ASC', 'DESC']).optional().describe('Sort order'), + limit: z + .number() + .int() + .min(1) + .max(10000) + .optional() + .describe('Maximum number of multi-document results to return'), includeChildren: z .boolean() .optional() - .describe('Include child documents created by document splitting during preprocessing') + .describe( + 'Include child documents created by document splitting. Applies only when documentId is provided.' + ), + includeProcessingQueue: z + .boolean() + .optional() + .describe( + 'Include documents that are still processing. Applies only to multi-document parser results.' + ) }) ) .output( @@ -59,22 +91,55 @@ export let getParsedData = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); - let { parserId, documentId, format, list, remoteId, sort, order, limit, includeChildren } = - ctx.input; + let { + parserId, + documentId, + format, + list, + date, + remoteId, + sortBy, + sortOrder, + limit, + includeChildren, + includeProcessingQueue + } = ctx.input; let results: any[]; if (documentId) { - results = await client.getParsedDataByDocument(parserId, documentId, format); + if (list || date || remoteId || sortBy || sortOrder || limit || includeProcessingQueue) { + throw docparserServiceError( + 'list, date, remoteId, sortBy, sortOrder, limit, and includeProcessingQueue apply only when documentId is omitted.' + ); + } + + results = await client.getParsedDataByDocument(parserId, documentId, { + format, + includeChildren + }); } else { + if (includeChildren) { + throw docparserServiceError( + 'includeChildren applies only when documentId is provided.' + ); + } + + if ((list === 'uploaded_after' || list === 'processed_after') && !date) { + throw docparserServiceError( + 'date is required when list is uploaded_after or processed_after.' + ); + } + results = await client.getParsedDataByParser(parserId, { format, list, + date, remoteId, - sort, - order, + sortBy: sortBy as ParsedDataSortBy | undefined, + sortOrder, limit, - includeChildren + includeProcessingQueue }); } diff --git a/integrations/docparser/src/tools/import-document.ts b/integrations/docparser/src/tools/import-document.ts index bd3c58edf7..73e5dca79b 100644 --- a/integrations/docparser/src/tools/import-document.ts +++ b/integrations/docparser/src/tools/import-document.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { docparserServiceError } from '../lib/errors'; import { spec } from '../spec'; export let importDocument = SlateTool.create(spec, { @@ -8,8 +9,8 @@ export let importDocument = SlateTool.create(spec, { key: 'import_document', description: `Import a document into a Document Parser for processing. Supports three import methods: upload a file as base64-encoded content, or fetch a document from a publicly accessible URL. An optional \`remoteId\` can be attached to correlate parsed results with your own system records.`, instructions: [ - 'Provide either `fileContent` (base64-encoded) or `fileUrl` — not both.', - 'When using `fileContent`, also provide a `fileName` with the correct extension so Docparser can identify the file type.' + 'Provide exactly one of `fileContent` (base64-encoded) or `fileUrl`.', + 'When using `fileContent`, provide a `fileName` when you want Docparser to preserve a specific filename.' ] }) .input( @@ -20,7 +21,7 @@ export let importDocument = SlateTool.create(spec, { .string() .optional() .describe( - 'Filename with extension (e.g. "invoice.pdf"). Required when using fileContent.' + 'Optional filename with extension (e.g. "invoice.pdf") to associate with uploaded fileContent.' ), fileUrl: z .string() @@ -37,18 +38,28 @@ export let importDocument = SlateTool.create(spec, { .output( z.object({ documentId: z.string().describe('ID of the imported document'), + parserId: z.string().optional().describe('Parser ID returned by URL-fetch imports'), + remoteId: z.string().optional().describe('Remote ID associated with the document'), + fileSize: z.number().optional().describe('Imported file size in bytes when returned'), pageCount: z.number().describe('Number of pages in the document'), uploadDuration: z.number().describe('Time taken to upload in seconds'), quotaUsed: z.number().describe('Number of quota pages used'), quotaLeft: z.number().describe('Remaining quota pages'), - quotaRefill: z.string().describe('Date when quota will be refilled') + quotaRefill: z.string().describe('Date when quota will be refilled'), + message: z.string().optional().describe('Status message returned by Docparser') }) ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); let { parserId, fileContent, fileName, fileUrl, remoteId } = ctx.input; + let hasFileContent = typeof fileContent === 'string' && fileContent.trim().length > 0; + let hasFileUrl = typeof fileUrl === 'string' && fileUrl.trim().length > 0; - if (fileUrl) { + if (hasFileContent === hasFileUrl) { + throw docparserServiceError('Provide exactly one of fileContent or fileUrl.'); + } + + if (hasFileUrl && fileUrl) { let result = await client.importFileByUrl(parserId, fileUrl, remoteId); return { output: result, @@ -56,7 +67,7 @@ export let importDocument = SlateTool.create(spec, { }; } - if (fileContent) { + if (hasFileContent && fileContent) { let result = await client.importFileByBase64(parserId, fileContent, fileName, remoteId); return { output: result, @@ -64,6 +75,6 @@ export let importDocument = SlateTool.create(spec, { }; } - throw new Error('Either fileContent or fileUrl must be provided.'); + throw docparserServiceError('Unable to determine document import method.'); }) .build(); diff --git a/integrations/docparser/src/tools/reintegrate-documents.ts b/integrations/docparser/src/tools/reintegrate-documents.ts index 21345382a5..a2e620dd0b 100644 --- a/integrations/docparser/src/tools/reintegrate-documents.ts +++ b/integrations/docparser/src/tools/reintegrate-documents.ts @@ -19,16 +19,24 @@ export let reintegrateDocuments = SlateTool.create(spec, { ) .output( z.object({ - success: z.boolean().describe('Whether the re-integration was successfully scheduled') + success: z.boolean().describe('Whether the re-integration was successfully scheduled'), + totalReintegrated: z + .number() + .describe('Number of documents scheduled for re-integration'), + message: z.string().optional().describe('Message returned by Docparser') }) ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); - await client.reintegrateDocuments(ctx.input.parserId, ctx.input.documentIds); + let result = await client.reintegrateDocuments(ctx.input.parserId, ctx.input.documentIds); return { - output: { success: true }, - message: `Scheduled **${ctx.input.documentIds.length}** document(s) for re-integration in parser \`${ctx.input.parserId}\`.` + output: { + success: true, + totalReintegrated: result.totalReintegrated, + message: result.message + }, + message: `Scheduled **${result.totalReintegrated}** document(s) for re-integration in parser \`${ctx.input.parserId}\`.` }; }) .build(); diff --git a/integrations/docparser/src/tools/reparse-documents.ts b/integrations/docparser/src/tools/reparse-documents.ts index bd294a5e62..8a9bce7771 100644 --- a/integrations/docparser/src/tools/reparse-documents.ts +++ b/integrations/docparser/src/tools/reparse-documents.ts @@ -19,16 +19,22 @@ export let reparseDocuments = SlateTool.create(spec, { ) .output( z.object({ - success: z.boolean().describe('Whether the re-parse was successfully scheduled') + success: z.boolean().describe('Whether the re-parse was successfully scheduled'), + totalReparsed: z.number().describe('Number of documents scheduled for re-parsing'), + message: z.string().optional().describe('Message returned by Docparser') }) ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); - await client.reparseDocuments(ctx.input.parserId, ctx.input.documentIds); + let result = await client.reparseDocuments(ctx.input.parserId, ctx.input.documentIds); return { - output: { success: true }, - message: `Scheduled **${ctx.input.documentIds.length}** document(s) for re-parsing in parser \`${ctx.input.parserId}\`.` + output: { + success: true, + totalReparsed: result.totalReparsed, + message: result.message + }, + message: `Scheduled **${result.totalReparsed}** document(s) for re-parsing in parser \`${ctx.input.parserId}\`.` }; }) .build(); diff --git a/integrations/docparser/src/triggers/document-parsed.ts b/integrations/docparser/src/triggers/document-parsed.ts index aa0f37254c..ade70bb81a 100644 --- a/integrations/docparser/src/triggers/document-parsed.ts +++ b/integrations/docparser/src/triggers/document-parsed.ts @@ -43,6 +43,7 @@ export let documentParsed = SlateTrigger.create(spec, { for (let parser of parsers) { let results = await client.getParsedDataByParser(parser.parserId, { list: lastPollTime ? 'processed_after' : 'last_uploaded', + date: lastPollTime, limit: lastPollTime ? undefined : 10 }); diff --git a/integrations/docsumo/README.md b/integrations/docsumo/README.md index cd38e24975..c8b4b1a7c3 100644 --- a/integrations/docsumo/README.md +++ b/integrations/docsumo/README.md @@ -1,6 +1,6 @@ # Docsumo -Upload, process, and extract structured data from documents using AI-powered models. Supports invoices, bank statements, tax forms, insurance documents, and more. Retrieve extracted key-value pairs and table data, manage document review and approval workflows, classify documents automatically, split multi-document files, and organize documents into folders. Manage cases grouping related documents, create and maintain database lookup tables, and receive webhook notifications on document status changes. Provides specialized analytics for bank statements and MCA analysis. +Upload, process, and extract structured data from documents using AI-powered models. Supports invoices, bank statements, tax forms, insurance documents, and more. Retrieve extracted key-value pairs and table data, review document status, generate review URLs, inspect account usage and document types, retrieve bank statement analytics, manage cases and workflows, and receive webhook notifications on document status changes. ## License diff --git a/integrations/docsumo/package.json b/integrations/docsumo/package.json index a987d1bea2..320c0312f4 100644 --- a/integrations/docsumo/package.json +++ b/integrations/docsumo/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/docsumo/slate.json b/integrations/docsumo/slate.json index 78e9123a64..bf0c216f7b 100644 --- a/integrations/docsumo/slate.json +++ b/integrations/docsumo/slate.json @@ -1,16 +1,14 @@ { "name": "@metorial/docsumo", - "description": "Upload, process, and extract structured data from documents using AI-powered models. Supports invoices, bank statements, tax forms, insurance documents, and more. Retrieve extracted key-value pairs and table data, manage document review and approval workflows, classify documents automatically, split multi-document files, and organize documents into folders. Manage cases grouping related documents, create and maintain database lookup tables, and receive webhook notifications on document status changes. Provides specialized analytics for bank statements and MCA analysis.", + "description": "Upload, process, and extract structured data from documents using AI-powered models. Supports invoices, bank statements, tax forms, insurance documents, and more. Retrieve extracted key-value pairs and table data, manage document review status, generate review URLs, inspect account usage and document types, retrieve bank statement analytics, manage cases and workflows, and receive webhook notifications on document status changes.", "categories": ["apis-and-http-requests", "document-processing"], "skills": [ "upload and process documents", "extract structured document data", - "auto-classify document types", + "inspect account usage and document types", "review and validate extractions", - "manage document folders", - "split multi-document files", + "generate document review URLs", "manage cases and workflows", - "create database lookup tables", "retrieve bank statement analytics", "track document status changes" ], diff --git a/integrations/docsumo/src/index.ts b/integrations/docsumo/src/index.ts index 116fc4e450..5753bccd4d 100644 --- a/integrations/docsumo/src/index.ts +++ b/integrations/docsumo/src/index.ts @@ -1,12 +1,22 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + createCase, deleteDocument, + getAccountInfo, + getBankStatementAnalytics, + getCaseOverview, + getCaseTypeDetails, getDocument, + getDocumentsSummary, getExtractedData, getReviewUrl, + listAgents, + listCases, listDocuments, listDocumentTypes, + runCaseWorkflow, + updateCase, updateReviewStatus, uploadDocument } from './tools'; @@ -15,14 +25,24 @@ import { documentStatusChanged } from './triggers'; export let provider = Slate.create({ spec, tools: [ + getAccountInfo, + listDocumentTypes, + getDocumentsSummary, uploadDocument, listDocuments, getDocument, getExtractedData, + getBankStatementAnalytics, deleteDocument, updateReviewStatus, - listDocumentTypes, - getReviewUrl + getReviewUrl, + listAgents, + getCaseTypeDetails, + listCases, + getCaseOverview, + createCase, + updateCase, + runCaseWorkflow ], triggers: [documentStatusChanged] }); diff --git a/integrations/docsumo/src/lib/client.ts b/integrations/docsumo/src/lib/client.ts index 0320c8792d..d0f09e53d5 100644 --- a/integrations/docsumo/src/lib/client.ts +++ b/integrations/docsumo/src/lib/client.ts @@ -1,4 +1,6 @@ +import { Buffer } from 'node:buffer'; import { createAxios } from 'slates'; +import { docsumoApiError, docsumoServiceError } from './errors'; export interface DocsumoDocument { docId: string; @@ -16,6 +18,7 @@ export interface DocsumoDocument { userId?: string; folderId?: string; folderName?: string; + caseId?: string; approvedWithError?: boolean; convertedToDigital?: boolean; reviewToken?: boolean; @@ -40,9 +43,10 @@ export interface DocumentType { docTypeId: string; title: string; docType: string; - canUpload: boolean; - isDefault: boolean; - docCounts: { + canUpload?: boolean; + isDefault?: boolean; + category?: string; + docCounts?: { all: number; processed: number; reviewing: number; @@ -50,6 +54,15 @@ export interface DocumentType { uploadEmail?: string; } +export interface AccountInfo { + userId: string; + email: string; + fullName: string; + monthlyDocCurrent: number; + monthlyDocLimit: number; + documentTypes: DocumentType[]; +} + export interface ListDocumentsParams { view?: 'files' | 'folder' | 'all_files'; folderId?: string; @@ -83,11 +96,94 @@ export interface UploadBase64Params { password?: string; } +export interface UpdateReviewStatusParams { + docId: string; + action: 'start' | 'end' | 'skip'; + forced?: boolean; + strict?: boolean; +} + +export interface ListAgentsParams { + agentType?: 'all' | 'doctype' | 'casetype'; +} + +export interface ListCasesParams { + casetypeId: string; + limit?: number; + offset?: number; + sortBy?: + | 'created_date.asc' + | 'created_date.desc' + | 'modified_date.asc' + | 'modified_date.desc'; + stageIds?: string[]; + assignedTo?: string[]; + workflowStates?: string[]; + createdDateFrom?: string; + createdDateTo?: string; + modifiedDateFrom?: string; + modifiedDateTo?: string; +} + +export interface CreateCaseFile { + filename: string; + contentBase64: string; + contentType?: string; +} + +export interface CreateCaseParams { + casetypeId: string; + caseId?: string; + userCaseId?: string; + caseName?: string; + stageId?: string; + assignedTo?: string; + doctype?: string; + triggerWorkflow?: boolean; + userCaseMetadata?: Record; + caseFields?: Record; + files?: CreateCaseFile[]; +} + +export interface UpdateCaseParams { + casetypeId: string; + caseId: string; + stageId?: string; + caseFields?: Record; + assignedTo?: string; + approval?: { + id: string; + isApproved: boolean; + reason?: string; + }; + triggerWorkflow?: boolean; + caseName?: string; +} + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let documentCount = (value: unknown) => { + if (!isRecord(value)) { + return { + all: 0, + processed: 0, + reviewing: 0 + }; + } + + return { + all: Number(value.all) || 0, + processed: Number(value.processed) || 0, + reviewing: Number(value.reviewing) || 0 + }; +}; + let mapDocument = (doc: any): DocsumoDocument => ({ - docId: doc.doc_id, - title: doc.title, - status: doc.status, - type: doc.type, + docId: String(doc.doc_id ?? doc.docId ?? ''), + title: String(doc.title ?? ''), + status: String(doc.status ?? ''), + type: String(doc.type ?? doc.doc_type ?? ''), typeTitle: doc.type_title, createdAt: doc.created_at, createdAtIso: doc.created_at_iso, @@ -99,6 +195,7 @@ let mapDocument = (doc: any): DocsumoDocument => ({ userId: doc.user_id, folderId: doc.folder_id, folderName: doc.folder_name, + caseId: doc.case_id, approvedWithError: doc.approved_with_error, convertedToDigital: doc.converted_to_digital, reviewToken: doc.review_token, @@ -119,12 +216,60 @@ let mapDocument = (doc: any): DocsumoDocument => ({ : undefined, timeDict: doc.time_dict ? { - processingTime: doc.time_dict.processing_time, - totalTime: doc.time_dict.total_time + processingTime: Number(doc.time_dict.processing_time) || 0, + totalTime: Number(doc.time_dict.total_time) || 0 } : undefined }); +let mapDocumentType = (docType: any): DocumentType => ({ + docTypeId: String( + docType.id ?? docType.doc_type_id ?? docType.value ?? docType.doc_type ?? '' + ), + title: String(docType.title ?? ''), + docType: String(docType.doc_type ?? docType.value ?? ''), + canUpload: docType.can_upload, + isDefault: docType.default, + category: docType.category, + docCounts: docType.doc_counts ? documentCount(docType.doc_counts) : undefined, + uploadEmail: docType.upload_email +}); + +let asArray = (value: unknown): any[] => { + if (Array.isArray(value)) return value; + if (value === undefined || value === null) return []; + return [value]; +}; + +let dataUrlPattern = /^data:[^;,]+;base64,/i; + +let normalizeBase64 = (value: string) => value.replace(dataUrlPattern, '').trim(); + +let toBlob = (file: CreateCaseFile) => { + let normalized = normalizeBase64(file.contentBase64); + let bytes = Buffer.from(normalized, 'base64'); + let roundTrip = bytes.toString('base64').replace(/=+$/, ''); + + if (!bytes.length || roundTrip !== normalized.replace(/=+$/, '')) { + throw docsumoServiceError('Case file contentBase64 must be valid non-empty base64 data.'); + } + + return new Blob([bytes], { type: file.contentType || 'application/octet-stream' }); +}; + +let appendFormField = (formData: FormData, name: string, value: unknown) => { + if (value === undefined || value === null) return; + if (typeof value === 'boolean') { + formData.append(name, value ? 'true' : 'false'); + return; + } + if (typeof value === 'object') { + formData.append(name, JSON.stringify(value)); + return; + } + formData.append(name, String(value)); +}; + export class Client { private axios; @@ -132,11 +277,82 @@ export class Client { this.axios = createAxios({ baseURL: 'https://app.docsumo.com', headers: { - 'X-API-KEY': options.token + 'X-API-KEY': options.token, + apikey: options.token } }); } + private async request(operation: string, run: () => Promise): Promise { + try { + let response = await run(); + this.assertSuccessfulResponse(response, operation); + return response.data; + } catch (error) { + throw docsumoApiError(error, operation); + } + } + + private async requestData(operation: string, run: () => Promise): Promise { + let body = await this.request(operation, run); + return (body?.data ?? body) as T; + } + + private assertSuccessfulResponse(response: any, operation: string) { + let body = response?.data; + let statusCode = Number(body?.status_code ?? response?.status); + let status = typeof body?.status === 'string' ? body.status.toLowerCase() : ''; + + if (status === 'fail' || statusCode >= 400 || statusCode === 202) { + let reason = [body?.error, body?.message, body?.error_code] + .filter((value: unknown) => typeof value === 'string' && value.trim()) + .join(' - '); + + throw docsumoServiceError( + `Docsumo API ${operation} failed: ${statusCode ? `HTTP ${statusCode}: ` : ''}${reason || 'Unknown error'}` + ); + } + } + + async getAccountInfo(): Promise { + let data = await this.requestData('get account info', () => + this.axios.get('/api/v1/eevee/apikey/limit/') + ); + + return { + userId: String(data.user_id ?? ''), + email: String(data.email ?? ''), + fullName: String(data.full_name ?? ''), + monthlyDocCurrent: Number(data.monthly_doc_current) || 0, + monthlyDocLimit: Number(data.monthly_doc_limit) || 0, + documentTypes: asArray(data.document_types).map(mapDocumentType) + }; + } + + async listEnabledDocumentTypes(): Promise { + let data = await this.requestData('list enabled document types', () => + this.axios.get('/api/v1/mew/documents/types/') + ); + + return asArray(data.document ?? data.documents ?? data.document_types).map( + mapDocumentType + ); + } + + async getDocumentsSummary(): Promise<{ + documentTypes: DocumentType[]; + disabledDocumentTypes: string[]; + }> { + let data = await this.requestData('get documents summary', () => + this.axios.get('/api/v1/mew/apikey/documents/summary/') + ); + + return { + documentTypes: asArray(data.document ?? data.documents).map(mapDocumentType), + disabledDocumentTypes: asArray(data.disabled_doc_types).map(String) + }; + } + async listDocuments(params: ListDocumentsParams = {}): Promise<{ documents: DocsumoDocument[]; total: number; @@ -160,29 +376,33 @@ export class Client { : `lte:${params.createdDateLte}`; } - let response = await this.axios.get('/api/v1/eevee/apikey/documents/all/', { - params: queryParams - }); - - let data = response.data?.data || {}; - let documents = (data.documents || []).map(mapDocument); + let data = await this.requestData('list documents', () => + this.axios.get('/api/v1/eevee/apikey/documents/all/', { + params: queryParams + }) + ); + let documents = asArray(data.documents).map(mapDocument); return { documents, - total: data.total || 0, - limit: data.limit || 0, - offset: data.offset || 0 + total: Number(data.total) || 0, + limit: Number(data.limit) || 0, + offset: Number(data.offset) || 0 }; } async getDocumentDetail(docId: string): Promise { - let response = await this.axios.get(`/api/v1/eevee/apikey/documents/detail/${docId}/`); - let doc = response.data?.data?.document || response.data?.data || {}; + let data = await this.requestData('get document detail', () => + this.axios.get(`/api/v1/eevee/apikey/documents/detail/${docId}/`) + ); + let doc = data.document || data; return mapDocument(doc); } async deleteDocument(docId: string): Promise { - await this.axios.delete(`/api/v1/eevee/apikey/delete/${docId}/`); + await this.request('delete document', () => + this.axios.delete(`/api/v1/eevee/apikey/delete/${docId}/`) + ); } async uploadFromUrl(params: UploadUrlParams): Promise { @@ -190,16 +410,16 @@ export class Client { formData.append('file', params.fileUrl); formData.append('file_type', 'url'); formData.append('type', params.documentType); - if (params.userDocId) formData.append('user_doc_id', params.userDocId); - if (params.docMetaData) formData.append('doc_meta_data', params.docMetaData); - if (params.reviewToken !== undefined) - formData.append('review_token', String(params.reviewToken)); - if (params.filename) formData.append('filename', params.filename); - if (params.password) formData.append('password', params.password); - - let response = await this.axios.post('/api/v1/eevee/apikey/upload/custom/', formData); - let documents = (response.data?.data?.document || []).map(mapDocument); - return documents; + appendFormField(formData, 'user_doc_id', params.userDocId); + appendFormField(formData, 'doc_meta_data', params.docMetaData); + appendFormField(formData, 'review_token', params.reviewToken); + appendFormField(formData, 'filename', params.filename); + appendFormField(formData, 'password', params.password); + + let data = await this.requestData('upload document from URL', () => + this.axios.post('/api/v1/eevee/apikey/upload/custom/', formData) + ); + return asArray(data.document).map(mapDocument); } async uploadFromBase64(params: UploadBase64Params): Promise { @@ -208,23 +428,24 @@ export class Client { formData.append('file_type', 'base64'); formData.append('type', params.documentType); formData.append('filename', params.filename); - if (params.userDocId) formData.append('user_doc_id', params.userDocId); - if (params.docMetaData) formData.append('doc_meta_data', params.docMetaData); - if (params.reviewToken !== undefined) - formData.append('review_token', String(params.reviewToken)); - if (params.password) formData.append('password', params.password); - - let response = await this.axios.post('/api/v1/eevee/apikey/upload/custom/', formData); - let documents = (response.data?.data?.document || []).map(mapDocument); - return documents; + appendFormField(formData, 'user_doc_id', params.userDocId); + appendFormField(formData, 'doc_meta_data', params.docMetaData); + appendFormField(formData, 'review_token', params.reviewToken); + appendFormField(formData, 'password', params.password); + + let data = await this.requestData('upload document from base64', () => + this.axios.post('/api/v1/eevee/apikey/upload/custom/', formData) + ); + return asArray(data.document).map(mapDocument); } async getExtractedData(docId: string): Promise<{ sections: Record; metaData: Record; }> { - let response = await this.axios.get(`/api/v1/eevee/apikey/data/simplified/${docId}/`); - let data = response.data?.data || {}; + let data = await this.requestData('get extracted data', () => + this.axios.get(`/api/v1/eevee/apikey/data/simplified/${docId}/`) + ); let metaData = data.meta_data || {}; let sections: Record = {}; @@ -237,86 +458,186 @@ export class Client { return { sections, metaData }; } - async updateReviewStatus( + async updateReviewStatus(params: UpdateReviewStatusParams): Promise<{ + status: string; + statusCode: number; + message?: string; + }> { + let queryParams: Record = {}; + if (params.forced !== undefined) queryParams.forced = params.forced; + if (params.strict !== undefined) queryParams.strict = params.strict; + + let body = await this.request('update review status', () => + this.axios.post(`/api/v1/pik/review/${params.docId}/${params.action}/`, null, { + params: queryParams + }) + ); + + return { + status: String(body.status ?? ''), + statusCode: Number(body.status_code) || 0, + message: body.message + }; + } + + async getReviewUrl(docId: string): Promise { + let data = await this.requestData('get review URL', () => + this.axios.get(`/api/v1/eevee/apikey/review-url/${docId}/`) + ); + return data.review_url || data.url || ''; + } + + async getBankStatementAnalytics( docId: string, - action: 'review' | 'skip' | 'finish' - ): Promise { - let actionPath: string; - switch (action) { - case 'review': - actionPath = 'start_review'; - break; - case 'skip': - actionPath = 'review_skipped'; - break; - case 'finish': - actionPath = 'finish_review'; - break; - } + mode: 'basic' | 'all' = 'basic' + ): Promise> { + let body = await this.request('get bank statement analytics', () => + this.axios.get(`/api/v1/mew/usbs-analytics/${docId}/`, { + params: { + output: 'json', + mode + }, + headers: { + 'Content-Type': 'application/json' + } + }) + ); + + return body.data ?? body; + } + + async listAgents(params: ListAgentsParams = {}): Promise<{ + agents: Record[]; + disabledAgents: Record[]; + }> { + let data = await this.requestData('list agents', () => + this.axios.get('/api/v1/external/agents', { + params: { + type: params.agentType || 'all' + } + }) + ); - await this.axios.post(`/api/v1/pik/review/${docId}/${actionPath}/`); + return { + agents: asArray(data.agents), + disabledAgents: asArray(data.disabled_agents) + }; } - async getDocumentTypes(): Promise { - let response = await this.axios.get('/api/v1/eevee/apikey/limit/'); - let data = response.data?.data || {}; - let docTypes = data.document_types || data.documents || []; - - return docTypes.map((dt: any) => ({ - docTypeId: dt.id || dt.doc_type_id || '', - title: dt.title || '', - docType: dt.doc_type || '', - canUpload: dt.can_upload ?? true, - isDefault: dt.default ?? false, - docCounts: { - all: dt.doc_counts?.all || 0, - processed: dt.doc_counts?.processed || 0, - reviewing: dt.doc_counts?.reviewing || 0 - }, - uploadEmail: dt.upload_email - })); + async getCaseTypeDetails(casetypeId: string): Promise> { + return await this.requestData>('get case type details', () => + this.axios.get(`/api/v1/external/agents/${casetypeId}`) + ); } - async getDocumentsSummary(): Promise { - let response = await this.axios.get('/api/v1/eevee/apikey/documents/summary/'); - let data = response.data?.data || {}; - let docTypes = data.document || data.documents || []; - - return docTypes.map((dt: any) => ({ - docTypeId: dt.id || dt.doc_type_id || '', - title: dt.title || '', - docType: dt.doc_type || '', - canUpload: dt.can_upload ?? true, - isDefault: dt.default ?? false, - docCounts: { - all: dt.doc_counts?.all || 0, - processed: dt.doc_counts?.processed || 0, - reviewing: dt.doc_counts?.reviewing || 0 - }, - uploadEmail: dt.upload_email - })); + async listCases(params: ListCasesParams): Promise<{ + cases: Record[]; + pagination: Record; + }> { + let data = await this.requestData('list cases', () => + this.axios.get(`/api/v1/external/agents/${params.casetypeId}/cases`, { + params: { + limit: params.limit, + offset: params.offset, + sort_by: params.sortBy, + stage_id: params.stageIds, + assigned_to: params.assignedTo, + workflow_state: params.workflowStates, + created_date_from: params.createdDateFrom, + created_date_to: params.createdDateTo, + modified_date_from: params.modifiedDateFrom, + modified_date_to: params.modifiedDateTo + } + }) + ); + + return { + cases: asArray(data.cases), + pagination: isRecord(data.pagination) ? data.pagination : {} + }; } - async getReviewUrl(docId: string): Promise { - let response = await this.axios.get(`/api/v1/eevee/apikey/review-url/${docId}/`); - return response.data?.data?.review_url || response.data?.data?.url || ''; + async getCaseOverview(params: { + casetypeId: string; + caseId: string; + include?: Array<'doctypes' | 'fields' | 'approvals' | 'exports' | 'documents'>; + }): Promise> { + return await this.requestData>('get case overview', () => + this.axios.get(`/api/v1/external/agents/${params.casetypeId}/case/${params.caseId}`, { + params: { + include: params.include?.join(',') + } + }) + ); } - async getUserDetails(): Promise<{ - userId: string; - email: string; - fullName: string; - monthlyDocCurrent: number; - monthlyDocLimit: number; + async createCase(params: CreateCaseParams): Promise> { + let formData = new FormData(); + formData.append('casetype_id', params.casetypeId); + appendFormField(formData, 'case_id', params.caseId); + appendFormField(formData, 'user_case_id', params.userCaseId); + appendFormField(formData, 'case_name', params.caseName); + appendFormField(formData, 'stage_id', params.stageId); + appendFormField(formData, 'assigned_to', params.assignedTo); + appendFormField(formData, 'doctype', params.doctype); + appendFormField(formData, 'trigger_workflow', params.triggerWorkflow); + appendFormField(formData, 'user_case_metadata', params.userCaseMetadata); + appendFormField(formData, 'case_fields', params.caseFields); + + for (let file of params.files ?? []) { + formData.append('files', toBlob(file), file.filename); + } + + return await this.requestData>('create case', () => + this.axios.post('/api/v1/upload-service/agents/casetype/case', formData) + ); + } + + async updateCase(params: UpdateCaseParams): Promise> { + let body: Record = {}; + if (params.stageId !== undefined) body.stage_id = params.stageId; + if (params.caseFields !== undefined) body.case_fields = params.caseFields; + if (params.assignedTo !== undefined) body.assigned_to = params.assignedTo; + if (params.approval !== undefined) { + body.approval = { + id: params.approval.id, + is_approved: params.approval.isApproved, + reason: params.approval.reason + }; + } + if (params.triggerWorkflow !== undefined) body.trigger_workflow = params.triggerWorkflow; + if (params.caseName !== undefined) body.case_name = params.caseName; + + if (Object.keys(body).length === 0) { + throw docsumoServiceError( + 'Provide at least one case update field: stageId, caseFields, assignedTo, approval, triggerWorkflow, or caseName.' + ); + } + + return await this.requestData>('update case', () => + this.axios.patch( + `/api/v1/external/agents/${params.casetypeId}/case/${params.caseId}`, + body + ) + ); + } + + async runCaseWorkflow( + casetypeId: string, + caseId: string + ): Promise<{ + status: string; + statusCode: number; + message?: string; }> { - let response = await this.axios.get('/api/v1/eevee/apikey/limit/'); - let data = response.data?.data || {}; + let body = await this.request('run case workflow', () => + this.axios.get(`/api/v1/external/agents/${casetypeId}/case/${caseId}/run`) + ); + return { - userId: data.user_id || '', - email: data.email || '', - fullName: data.full_name || '', - monthlyDocCurrent: data.monthly_doc_current || 0, - monthlyDocLimit: data.monthly_doc_limit || 0 + status: String(body.status ?? ''), + statusCode: Number(body.status_code) || 0, + message: body.message }; } } diff --git a/integrations/docsumo/src/lib/errors.ts b/integrations/docsumo/src/lib/errors.ts new file mode 100644 index 0000000000..58c74a4f05 --- /dev/null +++ b/integrations/docsumo/src/lib/errors.ts @@ -0,0 +1,113 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let message = String(value).trim(); + if (message && !messages.includes(message)) { + messages.push(message); + } +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectMessages(item, messages); + } + return; + } + + if (!isRecord(value)) { + pushMessage(messages, value); + return; + } + + pushMessage(messages, value.error); + pushMessage(messages, value.message); + pushMessage(messages, value.error_code); + pushMessage(messages, value.code); + pushMessage(messages, value.detail); + collectMessages(value.errors, messages); +}; + +let extractMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectMessages(response?.data, messages); + + if (isRecord(error)) { + collectMessages(error.data, messages); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + if (typeof response?.status === 'number') return response.status; + if (typeof error.status === 'number') return error.status; + + if (isRecord(error.data) && typeof error.data.status_code === 'number') { + return error.data.status_code; + } + + return undefined; +}; + +let getUpstreamCode = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + if (isRecord(response?.data) && typeof response.data.error_code === 'string') { + return response.data.error_code; + } + if (isRecord(response?.data) && typeof response.data.error === 'string') { + return response.data.error; + } + return undefined; +}; + +export let docsumoServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let docsumoApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = docsumoServiceError( + `Docsumo API ${operation} failed: ${statusLabel}${extractMessage(error)}` + ); + serviceError.data.reason = 'docsumo_api_error'; + serviceError.data.upstreamStatus = status; + serviceError.data.upstreamCode = getUpstreamCode(error); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/docsumo/src/tools.schema.test.ts b/integrations/docsumo/src/tools.schema.test.ts new file mode 100644 index 0000000000..8250536f3a --- /dev/null +++ b/integrations/docsumo/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Docsumo tool input schemas', provider.actions); diff --git a/integrations/docsumo/src/tools/create-case.ts b/integrations/docsumo/src/tools/create-case.ts new file mode 100644 index 0000000000..282b387c50 --- /dev/null +++ b/integrations/docsumo/src/tools/create-case.ts @@ -0,0 +1,111 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let fileSchema = z.object({ + filename: z.string().describe('Filename with extension'), + contentBase64: z.string().describe('Base64-encoded file bytes'), + contentType: z + .string() + .optional() + .describe('MIME type. Defaults to application/octet-stream.') +}); + +export let createCase = SlateTool.create(spec, { + name: 'Create Case', + key: 'create_case', + description: `Create a Docsumo case for a case type, or add documents to an existing case. Supports case metadata, initial case fields, assignment, stage selection, workflow triggering, and optional base64 file uploads.`, + instructions: [ + 'Use List Agents to find the casetypeId and Get Case Type Details to find stage and case field IDs.', + 'If files are provided, doctype must be configured on the case type.', + 'The workflow runs by default in Docsumo unless triggerWorkflow is set to false.' + ], + constraints: [ + 'Supported file extensions: jpg, jpeg, png, tiff, pdf, xlsx.', + 'Docsumo documents upload asynchronously after the case is created.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + casetypeId: z.string().describe('Case type ID. Get this from the List Agents tool.'), + caseId: z + .string() + .optional() + .describe('Existing case ID. If omitted, Docsumo creates a new case.'), + userCaseId: z.string().optional().describe('External reference ID for the case'), + caseName: z.string().optional().describe('Display name for the case'), + stageId: z + .string() + .optional() + .describe('Initial stage ID. Get stage IDs from Get Case Type Details.'), + assignedTo: z.string().optional().describe('User ID to assign the case to'), + doctype: z + .string() + .optional() + .describe('Document type for uploaded files, if files are provided'), + triggerWorkflow: z + .boolean() + .optional() + .describe('Whether to trigger the case workflow after creation/upload'), + userCaseMetadata: z + .record(z.string(), z.any()) + .optional() + .describe('Additional metadata for the case'), + caseFields: z + .record(z.string(), z.any()) + .optional() + .describe('Initial case field values keyed by case field ID'), + files: z + .array(fileSchema) + .optional() + .describe('Optional base64-encoded files to upload to the case') + }) + ) + .output( + z.object({ + caseMetadata: z + .record(z.string(), z.any()) + .optional() + .describe('Created or updated case metadata'), + documentsMetadata: z + .array(z.record(z.string(), z.any())) + .optional() + .describe('Uploaded document metadata returned by Docsumo'), + rawData: z.record(z.string(), z.any()).describe('Full Docsumo response data') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.createCase({ + casetypeId: ctx.input.casetypeId, + caseId: ctx.input.caseId, + userCaseId: ctx.input.userCaseId, + caseName: ctx.input.caseName, + stageId: ctx.input.stageId, + assignedTo: ctx.input.assignedTo, + doctype: ctx.input.doctype, + triggerWorkflow: ctx.input.triggerWorkflow, + userCaseMetadata: ctx.input.userCaseMetadata, + caseFields: ctx.input.caseFields, + files: ctx.input.files + }); + + let caseMetadata = result.case_metadata; + let documentsMetadata = result.documents_metadata; + let caseId = caseMetadata?.case_id || ctx.input.caseId || 'unknown'; + + return { + output: { + caseMetadata, + documentsMetadata, + rawData: result + }, + message: `Created or updated Docsumo case **${caseId}**.` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/get-account-info.ts b/integrations/docsumo/src/tools/get-account-info.ts new file mode 100644 index 0000000000..45628336ad --- /dev/null +++ b/integrations/docsumo/src/tools/get-account-info.ts @@ -0,0 +1,41 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let documentTypeSchema = z.object({ + docTypeId: z.string().describe('Unique ID or value for the document type'), + title: z.string().describe('Human-readable document type name'), + docType: z.string().describe('Document type identifier used in API calls') +}); + +export let getAccountInfo = SlateTool.create(spec, { + name: 'Get Account Info', + key: 'get_account_info', + description: `Retrieve Docsumo account details, monthly document usage, and active document types. Use this as a first call to verify the API key and discover valid document type identifiers.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + userId: z.string().describe('Docsumo user ID'), + email: z.string().describe('Account email address'), + fullName: z.string().describe('Account full name'), + monthlyDocCurrent: z.number().describe('Documents processed in the current cycle'), + monthlyDocLimit: z.number().describe('Monthly document processing limit'), + documentTypes: z.array(documentTypeSchema).describe('Active document types') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let account = await client.getAccountInfo(); + + return { + output: account, + message: `Docsumo account **${account.email}** has processed ${account.monthlyDocCurrent}/${account.monthlyDocLimit} monthly document(s).` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/get-bank-statement-analytics.ts b/integrations/docsumo/src/tools/get-bank-statement-analytics.ts new file mode 100644 index 0000000000..8e900caf47 --- /dev/null +++ b/integrations/docsumo/src/tools/get-bank-statement-analytics.ts @@ -0,0 +1,49 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getBankStatementAnalytics = SlateTool.create(spec, { + name: 'Get Bank Statement Analytics', + key: 'get_bank_statement_analytics', + description: `Retrieve Docsumo bank statement analytics for a processed bank statement document. Use "basic" for summary metrics or "all" for the full analytics report.`, + instructions: [ + 'The document must be a processed bank statement document.', + 'This tool requests JSON analytics only; downloadable CSV/XLSX formats are not exposed inline.' + ], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + docId: z.string().describe('Bank statement document ID'), + mode: z + .enum(['basic', 'all']) + .optional() + .describe('Analytics detail level. Defaults to "basic".') + }) + ) + .output( + z.object({ + docId: z.string().describe('Document ID'), + mode: z.string().describe('Analytics detail level requested'), + analytics: z.record(z.string(), z.any()).describe('Docsumo analytics payload') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let mode = ctx.input.mode || 'basic'; + let analytics = await client.getBankStatementAnalytics(ctx.input.docId, mode); + + return { + output: { + docId: ctx.input.docId, + mode, + analytics + }, + message: `Retrieved ${mode} bank statement analytics for document **${ctx.input.docId}**.` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/get-case-overview.ts b/integrations/docsumo/src/tools/get-case-overview.ts new file mode 100644 index 0000000000..e2b438309d --- /dev/null +++ b/integrations/docsumo/src/tools/get-case-overview.ts @@ -0,0 +1,51 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let includeSchema = z.enum(['doctypes', 'fields', 'approvals', 'exports', 'documents']); + +export let getCaseOverview = SlateTool.create(spec, { + name: 'Get Case Overview', + key: 'get_case_overview', + description: `Retrieve a Docsumo case overview. Optionally include related document types, fields, approvals, exports, and documents.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + casetypeId: z.string().describe('Case type ID. Get this from the List Agents tool.'), + caseId: z.string().describe('Case ID. Get this from the List Cases tool.'), + include: z + .array(includeSchema) + .optional() + .describe('Related sections to include in the case overview') + }) + ) + .output( + z.object({ + casetypeId: z.string().describe('Case type ID'), + caseId: z.string().describe('Case ID'), + overview: z.record(z.string(), z.any()).describe('Docsumo case overview') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let overview = await client.getCaseOverview({ + casetypeId: ctx.input.casetypeId, + caseId: ctx.input.caseId, + include: ctx.input.include + }); + + return { + output: { + casetypeId: ctx.input.casetypeId, + caseId: ctx.input.caseId, + overview + }, + message: `Retrieved overview for case **${ctx.input.caseId}**.` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/get-case-type-details.ts b/integrations/docsumo/src/tools/get-case-type-details.ts new file mode 100644 index 0000000000..e927c2e1a3 --- /dev/null +++ b/integrations/docsumo/src/tools/get-case-type-details.ts @@ -0,0 +1,38 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getCaseTypeDetails = SlateTool.create(spec, { + name: 'Get Case Type Details', + key: 'get_case_type_details', + description: `Retrieve the configuration for a Docsumo case type, including stages, case fields, associated document types, workflow settings, and stage-wise counts.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + casetypeId: z.string().describe('Case type ID. Get this from the List Agents tool.') + }) + ) + .output( + z.object({ + casetypeId: z.string().describe('Requested case type ID'), + caseType: z.record(z.string(), z.any()).describe('Docsumo case type details') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let caseType = await client.getCaseTypeDetails(ctx.input.casetypeId); + + return { + output: { + casetypeId: ctx.input.casetypeId, + caseType + }, + message: `Retrieved case type details for **${ctx.input.casetypeId}**.` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/get-documents-summary.ts b/integrations/docsumo/src/tools/get-documents-summary.ts new file mode 100644 index 0000000000..436871fc50 --- /dev/null +++ b/integrations/docsumo/src/tools/get-documents-summary.ts @@ -0,0 +1,51 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let summarySchema = z.object({ + docTypeId: z.string().describe('Unique ID of the document type'), + title: z.string().describe('Human-readable document type name'), + docType: z.string().describe('Document type identifier'), + canUpload: z.boolean().optional().describe('Whether uploads are enabled'), + isDefault: z.boolean().optional().describe('Whether this is a default type'), + category: z.string().optional().describe('Document type category'), + docCounts: z + .object({ + all: z.number().describe('Total documents'), + processed: z.number().describe('Processed documents'), + reviewing: z.number().describe('Documents currently in review') + }) + .optional() + .describe('Counts grouped by status'), + uploadEmail: z.string().optional().describe('Email upload address for this type') +}); + +export let getDocumentsSummary = SlateTool.create(spec, { + name: 'Get Documents Summary', + key: 'get_documents_summary', + description: `Retrieve a summary of documents grouped by document type, including per-status counts and disabled document types where Docsumo returns them.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + documentTypes: z.array(summarySchema).describe('Document summaries by type'), + disabledDocumentTypes: z + .array(z.string()) + .describe('Disabled document type identifiers returned by Docsumo') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let summary = await client.getDocumentsSummary(); + + return { + output: summary, + message: `Retrieved document summary for **${summary.documentTypes.length}** document type(s).` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/index.ts b/integrations/docsumo/src/tools/index.ts index 2981521268..2c14e78702 100644 --- a/integrations/docsumo/src/tools/index.ts +++ b/integrations/docsumo/src/tools/index.ts @@ -1,8 +1,18 @@ +export * from './create-case'; export * from './delete-document'; +export * from './get-account-info'; +export * from './get-bank-statement-analytics'; +export * from './get-case-overview'; +export * from './get-case-type-details'; export * from './get-document'; +export * from './get-documents-summary'; export * from './get-extracted-data'; export * from './get-review-url'; +export * from './list-agents'; +export * from './list-cases'; export * from './list-document-types'; export * from './list-documents'; +export * from './run-case-workflow'; +export * from './update-case'; export * from './update-review-status'; export * from './upload-document'; diff --git a/integrations/docsumo/src/tools/list-agents.ts b/integrations/docsumo/src/tools/list-agents.ts new file mode 100644 index 0000000000..695b08fc2f --- /dev/null +++ b/integrations/docsumo/src/tools/list-agents.ts @@ -0,0 +1,40 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let agentSchema = z.record(z.string(), z.any()); + +export let listAgents = SlateTool.create(spec, { + name: 'List Agents', + key: 'list_agents', + description: `List Docsumo agents configured on the account. Case-type agents return casetype_id values needed by the case tools; document-type agents return doc_type values for document workflows.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + agentType: z + .enum(['all', 'doctype', 'casetype']) + .optional() + .describe('Filter agents by type. Defaults to "all".') + }) + ) + .output( + z.object({ + agents: z.array(agentSchema).describe('Enabled Docsumo agents'), + disabledAgents: z.array(agentSchema).describe('Disabled Docsumo agents') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listAgents({ agentType: ctx.input.agentType }); + + return { + output: result, + message: `Found **${result.agents.length}** enabled Docsumo agent(s).` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/list-cases.ts b/integrations/docsumo/src/tools/list-cases.ts new file mode 100644 index 0000000000..313a983610 --- /dev/null +++ b/integrations/docsumo/src/tools/list-cases.ts @@ -0,0 +1,80 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listCases = SlateTool.create(spec, { + name: 'List Cases', + key: 'list_cases', + description: `Retrieve a paginated list of cases for a Docsumo case type. Supports filtering by stage, assignee, workflow state, and created or modified date ranges.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + casetypeId: z.string().describe('Case type ID. Get this from the List Agents tool.'), + limit: z + .number() + .optional() + .describe('Number of cases to return per page. Docsumo accepts 0-100.'), + offset: z.number().optional().describe('Number of cases to skip for pagination'), + sortBy: z + .enum([ + 'created_date.asc', + 'created_date.desc', + 'modified_date.asc', + 'modified_date.desc' + ]) + .optional() + .describe('Sort field and direction'), + stageIds: z.array(z.string()).optional().describe('Stage IDs to filter by'), + assignedTo: z.array(z.string()).optional().describe('User IDs to filter by assignee'), + workflowStates: z.array(z.string()).optional().describe('Workflow states to filter by'), + createdDateFrom: z + .string() + .optional() + .describe('Created date lower bound in DD/MM/YYYY format'), + createdDateTo: z + .string() + .optional() + .describe('Created date upper bound in DD/MM/YYYY format'), + modifiedDateFrom: z + .string() + .optional() + .describe('Modified date lower bound in DD/MM/YYYY format'), + modifiedDateTo: z + .string() + .optional() + .describe('Modified date upper bound in DD/MM/YYYY format') + }) + ) + .output( + z.object({ + cases: z.array(z.record(z.string(), z.any())).describe('Cases returned by Docsumo'), + pagination: z.record(z.string(), z.any()).describe('Pagination metadata') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listCases({ + casetypeId: ctx.input.casetypeId, + limit: ctx.input.limit, + offset: ctx.input.offset, + sortBy: ctx.input.sortBy, + stageIds: ctx.input.stageIds, + assignedTo: ctx.input.assignedTo, + workflowStates: ctx.input.workflowStates, + createdDateFrom: ctx.input.createdDateFrom, + createdDateTo: ctx.input.createdDateTo, + modifiedDateFrom: ctx.input.modifiedDateFrom, + modifiedDateTo: ctx.input.modifiedDateTo + }); + + return { + output: result, + message: `Found **${result.pagination.total ?? result.cases.length}** case(s). Returned ${result.cases.length}.` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/list-document-types.ts b/integrations/docsumo/src/tools/list-document-types.ts index 3a4851b8f4..56bb1c4d8e 100644 --- a/integrations/docsumo/src/tools/list-document-types.ts +++ b/integrations/docsumo/src/tools/list-document-types.ts @@ -27,14 +27,22 @@ export let listDocumentTypes = SlateTool.create(spec, { .describe( 'Type identifier used in API calls (e.g., "invoice", "bank_statements")' ), - canUpload: z.boolean().describe('Whether uploads are enabled for this type'), - isDefault: z.boolean().describe('Whether this is a default document type'), + canUpload: z + .boolean() + .optional() + .describe('Whether uploads are enabled for this type, when returned'), + isDefault: z + .boolean() + .optional() + .describe('Whether this is a default document type, when returned'), + category: z.string().optional().describe('Docsumo document type category'), docCounts: z .object({ all: z.number().describe('Total document count'), processed: z.number().describe('Number of processed documents'), reviewing: z.number().describe('Number of documents in review') }) + .optional() .describe('Document counts by status'), uploadEmail: z .string() @@ -47,7 +55,7 @@ export let listDocumentTypes = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); - let documentTypes = await client.getDocumentsSummary(); + let documentTypes = await client.listEnabledDocumentTypes(); return { output: { documentTypes }, diff --git a/integrations/docsumo/src/tools/run-case-workflow.ts b/integrations/docsumo/src/tools/run-case-workflow.ts new file mode 100644 index 0000000000..56194dc949 --- /dev/null +++ b/integrations/docsumo/src/tools/run-case-workflow.ts @@ -0,0 +1,45 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let runCaseWorkflow = SlateTool.create(spec, { + name: 'Run Case Workflow', + key: 'run_case_workflow', + description: `Trigger the workflow associated with a Docsumo case type for one case. The workflow may continue asynchronously after Docsumo accepts the request.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + casetypeId: z.string().describe('Case type ID. Get this from the List Agents tool.'), + caseId: z.string().describe('Case ID whose workflow should run') + }) + ) + .output( + z.object({ + casetypeId: z.string().describe('Case type ID'), + caseId: z.string().describe('Case ID'), + status: z.string().describe('Docsumo response status'), + statusCode: z.number().describe('Docsumo response status code'), + message: z.string().optional().describe('Docsumo response message') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.runCaseWorkflow(ctx.input.casetypeId, ctx.input.caseId); + + return { + output: { + casetypeId: ctx.input.casetypeId, + caseId: ctx.input.caseId, + status: result.status, + statusCode: result.statusCode, + message: result.message + }, + message: `Triggered workflow for Docsumo case **${ctx.input.caseId}**.` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/update-case.ts b/integrations/docsumo/src/tools/update-case.ts new file mode 100644 index 0000000000..7b6e22684c --- /dev/null +++ b/integrations/docsumo/src/tools/update-case.ts @@ -0,0 +1,85 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { docsumoServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let approvalSchema = z.object({ + id: z.string().describe('Approval block ID from the workflow'), + isApproved: z.boolean().describe('True to approve, false to reject'), + reason: z.string().optional().describe('Optional approval/rejection reason') +}); + +export let updateCase = SlateTool.create(spec, { + name: 'Update Case', + key: 'update_case', + description: `Partially update a Docsumo case. Supports renaming, changing stage, setting case fields, reassigning, acting on Human-in-the-Loop approvals, and optionally rerunning the workflow.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + casetypeId: z.string().describe('Case type ID. Get this from the List Agents tool.'), + caseId: z.string().describe('Case ID to update'), + stageId: z.string().optional().describe('Stage ID to move the case to'), + caseFields: z + .record(z.string(), z.any()) + .optional() + .describe('Case field values keyed by case field ID'), + assignedTo: z.string().optional().describe('User ID for the new assignee'), + approval: approvalSchema.optional().describe('Human-in-the-Loop approval action'), + triggerWorkflow: z + .boolean() + .optional() + .describe('Whether to execute the case type workflow after updates'), + caseName: z.string().optional().describe('New case name') + }) + ) + .output( + z.object({ + caseMetadata: z.record(z.string(), z.any()).optional().describe('Updated case metadata'), + caseFields: z + .array(z.record(z.string(), z.any())) + .optional() + .describe('Updated case fields returned by Docsumo'), + rawData: z.record(z.string(), z.any()).describe('Full Docsumo response data') + }) + ) + .handleInvocation(async ctx => { + if ( + ctx.input.stageId === undefined && + ctx.input.caseFields === undefined && + ctx.input.assignedTo === undefined && + ctx.input.approval === undefined && + ctx.input.triggerWorkflow === undefined && + ctx.input.caseName === undefined + ) { + throw docsumoServiceError( + 'Provide at least one case update field: stageId, caseFields, assignedTo, approval, triggerWorkflow, or caseName.' + ); + } + + let client = new Client({ token: ctx.auth.token }); + let result = await client.updateCase({ + casetypeId: ctx.input.casetypeId, + caseId: ctx.input.caseId, + stageId: ctx.input.stageId, + caseFields: ctx.input.caseFields, + assignedTo: ctx.input.assignedTo, + approval: ctx.input.approval, + triggerWorkflow: ctx.input.triggerWorkflow, + caseName: ctx.input.caseName + }); + + return { + output: { + caseMetadata: result.case_metadata, + caseFields: result.case_fields, + rawData: result + }, + message: `Updated Docsumo case **${ctx.input.caseId}**.` + }; + }) + .build(); diff --git a/integrations/docsumo/src/tools/update-review-status.ts b/integrations/docsumo/src/tools/update-review-status.ts index b3eb74454e..9852b43c08 100644 --- a/integrations/docsumo/src/tools/update-review-status.ts +++ b/integrations/docsumo/src/tools/update-review-status.ts @@ -1,15 +1,16 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { docsumoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateReviewStatus = SlateTool.create(spec, { name: 'Update Review Status', key: 'update_review_status', description: `Change the review status of a document. Available actions: -- **review**: Start reviewing the document (sets status to "reviewing") -- **skip**: Skip the review process (sets status to "review_skipped") -- **finish**: Complete the review and mark as processed (sets status to "processed")`, +- **start**: Start reviewing the document +- **skip**: Skip the review process +- **end**: Complete the review and mark the document as processed`, tags: { destructive: false, readOnly: false @@ -19,32 +20,54 @@ export let updateReviewStatus = SlateTool.create(spec, { z.object({ docId: z.string().describe('Unique identifier of the document'), action: z - .enum(['review', 'skip', 'finish']) + .enum(['start', 'skip', 'end']) .describe( - 'Review action to perform: "review" to start, "skip" to skip, "finish" to complete' - ) + 'Review action to perform: "start" to begin review, "skip" to skip review, "end" to complete review' + ), + forced: z + .boolean() + .optional() + .describe('Force the status change even if the document has validation errors'), + strict: z + .boolean() + .optional() + .describe('When action is "end", fail if validation errors remain in document fields') }) ) .output( z.object({ docId: z.string().describe('Document ID that was updated'), - action: z.string().describe('Action that was performed') + action: z.string().describe('Action that was performed'), + status: z.string().describe('Docsumo response status'), + statusCode: z.number().describe('Docsumo response status code') }) ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); - await client.updateReviewStatus(ctx.input.docId, ctx.input.action); + + if (ctx.input.strict !== undefined && ctx.input.action !== 'end') { + throw docsumoServiceError('strict can only be used when action is "end".'); + } + + let result = await client.updateReviewStatus({ + docId: ctx.input.docId, + action: ctx.input.action, + forced: ctx.input.forced, + strict: ctx.input.strict + }); let actionLabels: Record = { - review: 'started review', + start: 'started review', skip: 'skipped review', - finish: 'finished review (processed)' + end: 'finished review' }; return { output: { docId: ctx.input.docId, - action: ctx.input.action + action: ctx.input.action, + status: result.status, + statusCode: result.statusCode }, message: `Document **${ctx.input.docId}** — ${actionLabels[ctx.input.action]}.` }; diff --git a/integrations/docsumo/src/tools/upload-document.ts b/integrations/docsumo/src/tools/upload-document.ts index 050c9e2114..2a917df5c2 100644 --- a/integrations/docsumo/src/tools/upload-document.ts +++ b/integrations/docsumo/src/tools/upload-document.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { docsumoServiceError } from '../lib/errors'; import { spec } from '../spec'; let documentSchema = z.object({ @@ -78,7 +79,7 @@ export let uploadDocument = SlateTool.create(spec, { if (ctx.input.source === 'url') { if (!ctx.input.fileUrl) { - throw new Error('fileUrl is required when source is "url"'); + throw docsumoServiceError('fileUrl is required when source is "url".'); } documents = await client.uploadFromUrl({ fileUrl: ctx.input.fileUrl, @@ -91,10 +92,10 @@ export let uploadDocument = SlateTool.create(spec, { }); } else { if (!ctx.input.base64Content) { - throw new Error('base64Content is required when source is "base64"'); + throw docsumoServiceError('base64Content is required when source is "base64".'); } if (!ctx.input.filename) { - throw new Error('filename is required when source is "base64"'); + throw docsumoServiceError('filename is required when source is "base64".'); } documents = await client.uploadFromBase64({ base64Content: ctx.input.base64Content, diff --git a/integrations/docsumo/vitest.config.ts b/integrations/docsumo/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/docsumo/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/docusign/README.md b/integrations/docusign/README.md index b1ff4ddc81..eb0168eeb0 100644 --- a/integrations/docusign/README.md +++ b/integrations/docusign/README.md @@ -8,9 +8,21 @@ Send, sign, and manage documents and agreements electronically. Create and track Generates a URL for embedded signing, allowing a recipient to sign documents directly within your application. The signer must have been created with a **clientUserId** when the envelope was sent. The returned URL is valid for a limited time (typically 5 minutes). +### Create Sender View + +Generates a URL for DocuSign's embedded sender view so a user can prepare, tag, and send a draft envelope inside your application. Uses DocuSign's current sender-view request format with **viewAccess** set to **envelope**. + +### Delete Envelope + +Moves an envelope from its current folder to DocuSign Deleted Items. Useful for discarding draft envelopes and cleaning up test envelopes. Moving an in-process sent or delivered envelope to Deleted Items voids it in DocuSign. + ### Download Document -Downloads a document from a DocuSign envelope as base64-encoded content. Can download individual documents by ID, all documents combined, or list available documents in the envelope. +Downloads a document from a DocuSign envelope as a Slate attachment. Can download individual documents by ID, all documents combined, the archive ZIP, the completion certificate, or list available documents in the envelope. + +### Get Envelope Audit Events + +Retrieves audit events for an envelope, including lifecycle and recipient activity records used for compliance review. ### Get Envelope Recipients @@ -28,13 +40,17 @@ Searches and lists DocuSign envelopes with flexible filtering by date range, sta Lists and searches DocuSign templates available in the account. Returns template details including name, description, and recipient roles. Use search to find specific templates by name or keyword. +### Get Template + +Retrieves the definition of a specific DocuSign template, including subject, documents, and placeholder recipient roles. + ### Send Envelope from Template Creates and sends a DocuSign envelope using a pre-existing template. Assign recipients to template roles and optionally override the email subject, message, and tab values. Use **List Templates** first to find the templateId and available role names. ### Send Envelope -Creates and sends a DocuSign envelope with documents for electronic signature. Supports inline documents with signers, carbon copy recipients, sequential/parallel signing workflows, and embedded signing. Set **status** to \ +Creates and sends a DocuSign envelope with documents for electronic signature. Supports inline documents with signers, carbon copy recipients, sequential/parallel signing workflows, and embedded signing. Set **status** to **sent** to send immediately or **created** to save as a draft. ### Void Envelope diff --git a/integrations/docusign/package.json b/integrations/docusign/package.json index d12db9db32..2313c198c3 100644 --- a/integrations/docusign/package.json +++ b/integrations/docusign/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.8" } diff --git a/integrations/docusign/src/auth.ts b/integrations/docusign/src/auth.ts index acb9917b33..652454100c 100644 --- a/integrations/docusign/src/auth.ts +++ b/integrations/docusign/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { docusignApiError, docusignServiceError } from './lib/errors'; let getAuthBaseUrl = (environment: string) => environment === 'production' @@ -64,12 +65,12 @@ function createDocusignOauth(name: string, key: string, environment: 'demo' | 'p key, docs: [ { - type: 'docs.auth.oauth', + type: 'docs.auth.oauth' as const, name: 'OAuth documentation', url: 'https://developers.docusign.com/platform/auth/authcode/' }, { - type: 'docs.auth.oauth_scopes', + type: 'docs.auth.oauth_scopes' as const, name: 'OAuth scopes', url: 'https://developers.docusign.com/platform/auth/reference/scopes/' } @@ -91,103 +92,115 @@ function createDocusignOauth(name: string, key: string, environment: 'demo' | 'p let axiosInstance = createAxios({ baseURL: authBaseUrl }); let credentials = btoa(`${ctx.clientId}:${ctx.clientSecret}`); - let tokenResponse = await axiosInstance.post( - '/oauth/token', - new URLSearchParams({ - grant_type: 'authorization_code', - code: ctx.code, - redirect_uri: ctx.redirectUri - }).toString(), - { - headers: { - Authorization: `Basic ${credentials}`, - 'Content-Type': 'application/x-www-form-urlencoded' + try { + let tokenResponse = await axiosInstance.post( + '/oauth/token', + new URLSearchParams({ + grant_type: 'authorization_code', + code: ctx.code, + redirect_uri: ctx.redirectUri + }).toString(), + { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded' + } } - } - ); - - let tokenData = tokenResponse.data; - let accessToken = tokenData.access_token; - let refreshToken = tokenData.refresh_token; - let expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString(); + ); - let userInfoResponse = await axiosInstance.get('/oauth/userinfo', { - headers: { Authorization: `Bearer ${accessToken}` } - }); + let tokenData = tokenResponse.data; + let accessToken = tokenData.access_token; + let refreshToken = tokenData.refresh_token; + let expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString(); - let userInfo = userInfoResponse.data; - let defaultAccount = - userInfo.accounts?.find((a: any) => a.is_default) || userInfo.accounts?.[0]; + let userInfoResponse = await axiosInstance.get('/oauth/userinfo', { + headers: { Authorization: `Bearer ${accessToken}` } + }); - if (!defaultAccount) { - throw new Error('No DocuSign account found for this user'); - } + let userInfo = userInfoResponse.data; + let defaultAccount = + userInfo.accounts?.find((a: any) => a.is_default) || userInfo.accounts?.[0]; - return { - output: { - token: accessToken, - refreshToken, - expiresAt, - baseUri: defaultAccount.base_uri, - accountId: defaultAccount.account_id, - environment + if (!defaultAccount) { + throw docusignServiceError('No DocuSign account found for this user.'); } - }; + + return { + output: { + token: accessToken, + refreshToken, + expiresAt, + baseUri: defaultAccount.base_uri, + accountId: defaultAccount.account_id, + environment + } + }; + } catch (error) { + throw docusignApiError(error, 'OAuth callback'); + } }, handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - throw new Error('No refresh token available'); + throw docusignServiceError('No DocuSign refresh token is available.'); } let axiosInstance = createAxios({ baseURL: authBaseUrl }); let credentials = btoa(`${ctx.clientId}:${ctx.clientSecret}`); - let tokenResponse = await axiosInstance.post( - '/oauth/token', - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: ctx.output.refreshToken - }).toString(), - { - headers: { - Authorization: `Basic ${credentials}`, - 'Content-Type': 'application/x-www-form-urlencoded' + try { + let tokenResponse = await axiosInstance.post( + '/oauth/token', + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken + }).toString(), + { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded' + } } - } - ); - - let tokenData = tokenResponse.data; - let expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString(); - - return { - output: { - token: tokenData.access_token, - refreshToken: tokenData.refresh_token || ctx.output.refreshToken, - expiresAt, - baseUri: ctx.output.baseUri, - accountId: ctx.output.accountId, - environment - } - }; + ); + + let tokenData = tokenResponse.data; + let expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString(); + + return { + output: { + token: tokenData.access_token, + refreshToken: tokenData.refresh_token || ctx.output.refreshToken, + expiresAt, + baseUri: ctx.output.baseUri, + accountId: ctx.output.accountId, + environment + } + }; + } catch (error) { + throw docusignApiError(error, 'OAuth refresh'); + } }, getProfile: async (ctx: any) => { let axiosInstance = createAxios({ baseURL: authBaseUrl }); - let userInfoResponse = await axiosInstance.get('/oauth/userinfo', { - headers: { Authorization: `Bearer ${ctx.output.token}` } - }); - let userInfo = userInfoResponse.data; - return { - profile: { - id: userInfo.sub, - email: userInfo.email, - name: userInfo.name, - accountId: ctx.output.accountId, - accountName: userInfo.accounts?.find( - (a: any) => a.account_id === ctx.output.accountId - )?.account_name - } - }; + try { + let userInfoResponse = await axiosInstance.get('/oauth/userinfo', { + headers: { Authorization: `Bearer ${ctx.output.token}` } + }); + let userInfo = userInfoResponse.data; + return { + profile: { + id: userInfo.sub, + email: userInfo.email, + name: userInfo.name, + accountId: ctx.output.accountId, + accountName: userInfo.accounts?.find( + (a: any) => a.account_id === ctx.output.accountId + )?.account_name + } + }; + } catch (error) { + throw docusignApiError(error, 'profile request'); + } } }; } diff --git a/integrations/docusign/src/index.ts b/integrations/docusign/src/index.ts index 7dda8637c1..6e08302a7b 100644 --- a/integrations/docusign/src/index.ts +++ b/integrations/docusign/src/index.ts @@ -2,9 +2,13 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { createEmbeddedSigningUrl, + createSenderView, + deleteEnvelope, downloadDocument, getEnvelope, + getEnvelopeAuditEvents, getEnvelopeRecipients, + getTemplate, listEnvelopes, listTemplates, sendEnvelope, @@ -20,10 +24,14 @@ export let provider = Slate.create({ getEnvelope, listEnvelopes, downloadDocument, + getEnvelopeAuditEvents, listTemplates, + getTemplate, sendEnvelopeFromTemplate, voidEnvelope, + deleteEnvelope, createEmbeddedSigningUrl, + createSenderView, getEnvelopeRecipients ], triggers: [envelopeEvents] diff --git a/integrations/docusign/src/lib/client.ts b/integrations/docusign/src/lib/client.ts index 942d99b195..195a051bb2 100644 --- a/integrations/docusign/src/lib/client.ts +++ b/integrations/docusign/src/lib/client.ts @@ -1,4 +1,5 @@ -import { createAxios } from 'slates'; +import { createAxios, getResponseHeaderValue } from 'slates'; +import { docusignApiError } from './errors'; export interface ClientConfig { token: string; @@ -80,6 +81,7 @@ export interface ListEnvelopesParams { folderId?: string; userId?: string; envelopeIds?: string; + transactionIds?: string; } export interface ConnectConfiguration { @@ -100,6 +102,52 @@ export interface ConnectConfiguration { includeHMAC?: string; } +export interface DocumentDownload { + contentBase64: string; + mimeType: string; + byteLength: number; + fileName?: string; +} + +export interface SenderViewRequest { + returnUrl: string; + viewAccess: 'envelope'; + settings?: Record; +} + +let toBuffer = (data: unknown) => { + if (Buffer.isBuffer(data)) { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + if (typeof data === 'string') { + return Buffer.from(data, 'binary'); + } + return Buffer.from(data as any); +}; + +let getFileNameFromDisposition = (contentDisposition?: string) => { + if (!contentDisposition) { + return undefined; + } + + let utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1].trim().replace(/^"|"$/g, '')); + } + + let match = contentDisposition.match(/filename="?([^";]+)"?/i); + return match?.[1]?.trim(); +}; + +let fallbackDocumentMimeType = (documentId: string) => + documentId === 'archive' ? 'application/zip' : 'application/pdf'; + export class Client { private token: string; private baseUri: string; @@ -112,13 +160,20 @@ export class Client { } private getAxios() { - return createAxios({ + let ax = createAxios({ baseURL: `${this.baseUri}/restapi/v2.1/accounts/${this.accountId}`, headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' } }); + + ax.interceptors.response.use( + response => response, + error => Promise.reject(docusignApiError(error, 'request')) + ); + + return ax; } // ---- Envelopes ---- @@ -153,6 +208,7 @@ export class Client { if (queryParams.folderId) params.folder_ids = queryParams.folderId; if (queryParams.userId) params.user_id = queryParams.userId; if (queryParams.envelopeIds) params.envelope_ids = queryParams.envelopeIds; + if (queryParams.transactionIds) params.transaction_ids = queryParams.transactionIds; let response = await ax.get('/envelopes', { params }); return response.data; } @@ -194,20 +250,26 @@ export class Client { return response.data; } - async getDocument(envelopeId: string, documentId: string): Promise { + async getDocument(envelopeId: string, documentId: string): Promise { let ax = this.getAxios(); let response = await ax.get(`/envelopes/${envelopeId}/documents/${documentId}`, { responseType: 'arraybuffer' }); - let binary = ''; - let bytes = new Uint8Array(response.data as ArrayBuffer); - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]!); - } - return btoa(binary); + let content = toBuffer(response.data); + let headers = response.headers as Record; + let contentDisposition = getResponseHeaderValue(headers, 'content-disposition'); + let mimeType = + getResponseHeaderValue(headers, 'content-type') ?? fallbackDocumentMimeType(documentId); + + return { + contentBase64: content.toString('base64'), + mimeType, + byteLength: content.byteLength, + fileName: getFileNameFromDisposition(contentDisposition) + }; } - async getCombinedDocument(envelopeId: string): Promise { + async getCombinedDocument(envelopeId: string): Promise { return this.getDocument(envelopeId, 'combined'); } @@ -262,12 +324,35 @@ export class Client { return response.data; } - async createSenderView(envelopeId: string, returnUrl: string): Promise { + async createSenderView(envelopeId: string, viewRequest: SenderViewRequest): Promise { + let ax = this.getAxios(); + let response = await ax.post(`/envelopes/${envelopeId}/views/sender`, viewRequest); + return response.data; + } + + // ---- Folders ---- + + async moveEnvelopesToFolder(params: { + destinationFolderId: string; + sourceFolderId: string; + envelopeIds: string[]; + }): Promise { let ax = this.getAxios(); - let response = await ax.post(`/envelopes/${envelopeId}/views/sender`, { returnUrl }); + let response = await ax.put(`/folders/${params.destinationFolderId}`, { + fromFolderId: params.sourceFolderId, + envelopeIds: params.envelopeIds + }); return response.data; } + async deleteEnvelope(envelopeId: string, sourceFolderId: string): Promise { + return this.moveEnvelopesToFolder({ + destinationFolderId: 'recyclebin', + sourceFolderId, + envelopeIds: [envelopeId] + }); + } + // ---- Connect (Webhooks) ---- async createConnectConfiguration(config: ConnectConfiguration): Promise { diff --git a/integrations/docusign/src/lib/errors.ts b/integrations/docusign/src/lib/errors.ts new file mode 100644 index 0000000000..8c656f5f1e --- /dev/null +++ b/integrations/docusign/src/lib/errors.ts @@ -0,0 +1,100 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.errorCode); + addDetail(details, value.error_code); + addDetail(details, value.error); + addDetail(details, value.error_description); + addDetail(details, value.message); + addDetail(details, value.status); + collectDetails(value.errors, details); + collectDetails(value.errorDetails, details); +}; + +let extractDocusignMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data ?? (isRecord(error) ? error.data : undefined); + let details: string[] = []; + + collectDetails(data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getDocusignErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + let data = isRecord(error.data) ? error.data : undefined; + return response?.status ?? error.status ?? data?.status; +}; + +export let docusignServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let docusignApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getDocusignErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = docusignServiceError( + `DocuSign API ${operation} failed: ${statusLabel}${extractDocusignMessage(error)}` + ); + serviceError.data.reason = 'docusign_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/docusign/src/tools.schema.test.ts b/integrations/docusign/src/tools.schema.test.ts new file mode 100644 index 0000000000..a8e629c67f --- /dev/null +++ b/integrations/docusign/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('DocuSign tool input schemas', provider.actions); diff --git a/integrations/docusign/src/tools/create-sender-view.ts b/integrations/docusign/src/tools/create-sender-view.ts new file mode 100644 index 0000000000..64cbcb2d0c --- /dev/null +++ b/integrations/docusign/src/tools/create-sender-view.ts @@ -0,0 +1,76 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let createSenderView = SlateTool.create(spec, { + name: 'Create Sender View', + key: 'create_sender_view', + description: `Generates a URL for the embedded DocuSign sender view, allowing a user to prepare, tag, and send a draft envelope in your application.`, + instructions: [ + 'The envelope must be in the "created" draft state.', + 'DocuSign sender view URLs expire after about 10 minutes and should be used immediately.', + 'This tool uses DocuSign\'s current sender view request format with viewAccess set to "envelope".' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + envelopeId: z.string().describe('ID of the draft envelope to open in sender view'), + returnUrl: z + .string() + .describe('URL to redirect to when the sender view completes or exits'), + startingScreen: z + .enum(['Prepare', 'Tagger']) + .optional() + .describe('Optional initial sender view screen. Defaults to DocuSign behavior.'), + showBackButton: z + .boolean() + .optional() + .describe('Whether the embedded sender view should show the back button'), + showEditRecipients: z + .boolean() + .optional() + .describe('Whether the embedded sender view should allow editing recipients') + }) + ) + .output( + z.object({ + senderViewUrl: z.string().describe('URL to open for embedded envelope preparation'), + expiresInMinutes: z.number().describe('Approximate sender view URL lifetime') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUri: ctx.auth.baseUri, + accountId: ctx.auth.accountId + }); + + let settings: Record = {}; + if (ctx.input.startingScreen) settings.startingScreen = ctx.input.startingScreen; + if (ctx.input.showBackButton !== undefined) { + settings.showBackButton = String(ctx.input.showBackButton); + } + if (ctx.input.showEditRecipients !== undefined) { + settings.showEditRecipients = String(ctx.input.showEditRecipients); + } + + let result = await client.createSenderView(ctx.input.envelopeId, { + returnUrl: ctx.input.returnUrl, + viewAccess: 'envelope', + settings: Object.keys(settings).length > 0 ? settings : undefined + }); + + return { + output: { + senderViewUrl: result.url, + expiresInMinutes: 10 + }, + message: `Embedded sender view URL generated for draft envelope **${ctx.input.envelopeId}**.` + }; + }) + .build(); diff --git a/integrations/docusign/src/tools/delete-envelope.ts b/integrations/docusign/src/tools/delete-envelope.ts new file mode 100644 index 0000000000..4db8881130 --- /dev/null +++ b/integrations/docusign/src/tools/delete-envelope.ts @@ -0,0 +1,55 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let deleteEnvelope = SlateTool.create(spec, { + name: 'Delete Envelope', + key: 'delete_envelope', + description: `Moves a DocuSign envelope to Deleted Items by moving it from its current folder to the recyclebin folder. Useful for discarding drafts and cleaning up test envelopes.`, + constraints: [ + 'sourceFolderId must match the envelope current folder, such as "draft", "sentitems", "inbox", or another folder ID.', + 'Moving an in-process sent or delivered envelope to recyclebin voids it in DocuSign.' + ], + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + envelopeId: z.string().describe('ID of the envelope to move to Deleted Items'), + sourceFolderId: z + .string() + .default('draft') + .describe( + 'Current source folder ID or folder type. Common values include "draft", "sentitems", and "inbox".' + ) + }) + ) + .output( + z.object({ + envelopeId: z.string().describe('ID of the deleted envelope'), + sourceFolderId: z.string().describe('Folder the envelope was moved from'), + destinationFolderId: z.string().describe('Destination folder ID') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUri: ctx.auth.baseUri, + accountId: ctx.auth.accountId + }); + + await client.deleteEnvelope(ctx.input.envelopeId, ctx.input.sourceFolderId); + + return { + output: { + envelopeId: ctx.input.envelopeId, + sourceFolderId: ctx.input.sourceFolderId, + destinationFolderId: 'recyclebin' + }, + message: `Envelope **${ctx.input.envelopeId}** was moved from **${ctx.input.sourceFolderId}** to Deleted Items.` + }; + }) + .build(); diff --git a/integrations/docusign/src/tools/download-document.ts b/integrations/docusign/src/tools/download-document.ts index f5327b4657..85b47c5288 100644 --- a/integrations/docusign/src/tools/download-document.ts +++ b/integrations/docusign/src/tools/download-document.ts @@ -9,6 +9,7 @@ export let downloadDocument = SlateTool.create(spec, { description: `Downloads a document from a DocuSign envelope as an attachment. Can download individual documents by ID, all documents combined, or list available documents in the envelope.`, instructions: [ 'Use documentId "combined" to download all documents merged into a single PDF.', + 'Use documentId "archive" to download all documents as a ZIP archive.', 'Use documentId "certificate" to download the certificate of completion.', 'Use the listOnly option to see available documents before downloading.' ], @@ -24,7 +25,7 @@ export let downloadDocument = SlateTool.create(spec, { .string() .optional() .describe( - 'ID of the specific document to download. Use "combined" for all documents, "certificate" for the certificate of completion.' + 'ID of the specific document to download. Use "combined" for one merged PDF, "archive" for a ZIP archive, or "certificate" for the certificate of completion.' ), listOnly: z .boolean() @@ -47,7 +48,10 @@ export let downloadDocument = SlateTool.create(spec, { .optional() .describe('List of available documents in the envelope'), documentId: z.string().optional().describe('ID of the downloaded document'), - documentName: z.string().optional().describe('Name of the downloaded document') + documentName: z.string().optional().describe('Name of the downloaded document'), + mimeType: z.string().optional().describe('MIME type of the attachment'), + byteLength: z.number().optional().describe('Attachment size in bytes'), + attachmentCount: z.number().optional().describe('Number of attachments returned') }) ) .handleInvocation(async ctx => { @@ -73,24 +77,30 @@ export let downloadDocument = SlateTool.create(spec, { } let targetId = ctx.input.documentId || 'combined'; - let documentBase64 = await client.getDocument(ctx.input.envelopeId, targetId); + let document = await client.getDocument(ctx.input.envelopeId, targetId); let targetDoc = documents.find((d: any) => d.documentId === targetId); let docName = + document.fileName || targetDoc?.name || (targetId === 'combined' ? 'Combined Documents' - : targetId === 'certificate' - ? 'Certificate of Completion' - : `Document ${targetId}`); + : targetId === 'archive' + ? 'Document Archive' + : targetId === 'certificate' + ? 'Certificate of Completion' + : `Document ${targetId}`); return { output: { documents, documentId: targetId, - documentName: docName + documentName: docName, + mimeType: document.mimeType, + byteLength: document.byteLength, + attachmentCount: 1 }, - attachments: [createBase64Attachment(documentBase64, 'application/pdf')], + attachments: [createBase64Attachment(document.contentBase64, document.mimeType)], message: `Downloaded document "**${docName}**" from envelope ${ctx.input.envelopeId}.` }; }) diff --git a/integrations/docusign/src/tools/get-envelope-audit-events.ts b/integrations/docusign/src/tools/get-envelope-audit-events.ts new file mode 100644 index 0000000000..cec2616a8c --- /dev/null +++ b/integrations/docusign/src/tools/get-envelope-audit-events.ts @@ -0,0 +1,72 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let auditFieldSchema = z.object({ + name: z.string().optional(), + value: z.string().optional() +}); + +let auditEventSchema = z.object({ + eventName: z.string().optional(), + eventDateTime: z.string().optional(), + userName: z.string().optional(), + userId: z.string().optional(), + status: z.string().optional(), + envelopeId: z.string().optional(), + eventFields: z.array(auditFieldSchema).optional() +}); + +export let getEnvelopeAuditEvents = SlateTool.create(spec, { + name: 'Get Envelope Audit Events', + key: 'get_envelope_audit_events', + description: `Retrieves DocuSign audit events for a specific envelope, including envelope lifecycle and recipient activity records used for compliance review.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + envelopeId: z.string().describe('ID of the envelope whose audit events to retrieve') + }) + ) + .output( + z.object({ + envelopeId: z.string().describe('ID of the envelope'), + eventCount: z.number().describe('Number of audit events returned'), + auditEvents: z.array(auditEventSchema).describe('Audit events for the envelope') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUri: ctx.auth.baseUri, + accountId: ctx.auth.accountId + }); + + let result = await client.getAuditEvents(ctx.input.envelopeId); + let auditEvents = (result.auditEvents || result.events || []).map((event: any) => ({ + eventName: event.eventName, + eventDateTime: event.eventDateTime, + userName: event.userName, + userId: event.userId, + status: event.status, + envelopeId: event.envelopeId, + eventFields: event.eventFields?.map((field: any) => ({ + name: field.name, + value: field.value + })) + })); + + return { + output: { + envelopeId: ctx.input.envelopeId, + eventCount: auditEvents.length, + auditEvents + }, + message: `Retrieved **${auditEvents.length}** audit event(s) for envelope **${ctx.input.envelopeId}**.` + }; + }) + .build(); diff --git a/integrations/docusign/src/tools/get-template.ts b/integrations/docusign/src/tools/get-template.ts new file mode 100644 index 0000000000..7a0860a887 --- /dev/null +++ b/integrations/docusign/src/tools/get-template.ts @@ -0,0 +1,111 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let documentOutputSchema = z.object({ + documentId: z.string().optional(), + name: z.string().optional(), + order: z.string().optional(), + pages: z.string().optional() +}); + +let recipientOutputSchema = z.object({ + recipientId: z.string().optional(), + recipientType: z.string().optional(), + roleName: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + routingOrder: z.string().optional() +}); + +let mapRecipients = (recipients: any) => { + if (!recipients) { + return undefined; + } + + let groups = [ + ...(recipients.signers || []), + ...(recipients.carbonCopies || []), + ...(recipients.certifiedDeliveries || []), + ...(recipients.agents || []), + ...(recipients.editors || []), + ...(recipients.inPersonSigners || []) + ]; + + return groups.map((recipient: any) => ({ + recipientId: recipient.recipientId, + recipientType: recipient.recipientType, + roleName: recipient.roleName, + name: recipient.name, + email: recipient.email, + routingOrder: recipient.routingOrder + })); +}; + +export let getTemplate = SlateTool.create(spec, { + name: 'Get Template', + key: 'get_template', + description: `Retrieves the definition of a DocuSign template, including its subject, documents, and placeholder recipient roles for envelope creation.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + templateId: z.string().describe('ID of the template to retrieve') + }) + ) + .output( + z.object({ + templateId: z.string().describe('ID of the template'), + name: z.string().optional().describe('Template name'), + description: z.string().optional().describe('Template description'), + emailSubject: z.string().optional().describe('Default email subject'), + emailBlurb: z.string().optional().describe('Default email body'), + ownerName: z.string().optional().describe('Template owner name'), + shared: z.string().optional().describe('Whether the template is shared'), + createdDateTime: z.string().optional().describe('When the template was created'), + lastModified: z.string().optional().describe('When the template was last modified'), + documents: z.array(documentOutputSchema).optional().describe('Template documents'), + recipients: z + .array(recipientOutputSchema) + .optional() + .describe('Template placeholder recipients and roles') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUri: ctx.auth.baseUri, + accountId: ctx.auth.accountId + }); + + let template = await client.getTemplate(ctx.input.templateId); + let documents = template.documents?.map((document: any) => ({ + documentId: document.documentId, + name: document.name, + order: document.order, + pages: document.pages + })); + let recipients = mapRecipients(template.recipients); + + return { + output: { + templateId: template.templateId, + name: template.name, + description: template.description, + emailSubject: template.emailSubject, + emailBlurb: template.emailBlurb, + ownerName: template.owner?.userName, + shared: template.shared, + createdDateTime: template.createdDateTime, + lastModified: template.lastModified, + documents, + recipients + }, + message: `Retrieved template **${template.name || template.templateId}** with **${recipients?.length || 0}** recipient role(s).` + }; + }) + .build(); diff --git a/integrations/docusign/src/tools/index.ts b/integrations/docusign/src/tools/index.ts index 2333b6120c..226e41c727 100644 --- a/integrations/docusign/src/tools/index.ts +++ b/integrations/docusign/src/tools/index.ts @@ -1,7 +1,11 @@ export * from './create-embedded-signing-url'; +export * from './create-sender-view'; +export * from './delete-envelope'; export * from './download-document'; export * from './get-envelope'; +export * from './get-envelope-audit-events'; export * from './get-envelope-recipients'; +export * from './get-template'; export * from './list-envelopes'; export * from './list-templates'; export * from './send-envelope'; diff --git a/integrations/docusign/src/tools/list-envelopes.ts b/integrations/docusign/src/tools/list-envelopes.ts index 3eb4b4b6c9..88f5970b98 100644 --- a/integrations/docusign/src/tools/list-envelopes.ts +++ b/integrations/docusign/src/tools/list-envelopes.ts @@ -1,16 +1,17 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { docusignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listEnvelopes = SlateTool.create(spec, { name: 'List Envelopes', key: 'list_envelopes', description: `Searches and lists DocuSign envelopes with flexible filtering by date range, status, text, and more. Returns up to 1000 envelopes per call with pagination support. -Use **fromDate** to specify the start of the search window (required unless envelopeIds are provided).`, +Use **fromDate** to specify the start of the search window (required unless envelopeIds or transactionIds are provided).`, constraints: [ 'Maximum of 1000 envelopes returned per call. Use pagination (startPosition) for more.', - 'fromDate is required unless specific envelopeIds are provided.' + 'fromDate is required unless specific envelopeIds or transactionIds are provided.' ], tags: { destructive: false, @@ -43,12 +44,24 @@ Use **fromDate** to specify the start of the search window (required unless enve .string() .optional() .describe('Comma-separated list of specific envelope IDs to retrieve'), + transactionIds: z + .string() + .optional() + .describe('Comma-separated list of transaction IDs to retrieve'), count: z .number() + .int() + .min(1) + .max(1000) .optional() .default(25) .describe('Number of results to return (max 1000)'), - startPosition: z.number().optional().describe('Starting position for pagination'), + startPosition: z + .number() + .int() + .min(0) + .optional() + .describe('Starting position for pagination'), orderBy: z .enum(['created', 'last_modified', 'status']) .optional() @@ -84,6 +97,12 @@ Use **fromDate** to specify the start of the search window (required unless enve }) ) .handleInvocation(async ctx => { + if (!ctx.input.fromDate && !ctx.input.envelopeIds && !ctx.input.transactionIds) { + throw docusignServiceError( + 'fromDate, envelopeIds, or transactionIds is required to list DocuSign envelopes.' + ); + } + let client = new Client({ token: ctx.auth.token, baseUri: ctx.auth.baseUri, @@ -97,6 +116,7 @@ Use **fromDate** to specify the start of the search window (required unless enve fromToStatus: ctx.input.fromToStatus, searchText: ctx.input.searchText, envelopeIds: ctx.input.envelopeIds, + transactionIds: ctx.input.transactionIds, count: ctx.input.count?.toString(), startPosition: ctx.input.startPosition?.toString(), orderBy: ctx.input.orderBy, diff --git a/integrations/docusign/vitest.config.ts b/integrations/docusign/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/docusign/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/drift/README.md b/integrations/drift/README.md index 3406a7981c..f932cd774a 100644 --- a/integrations/drift/README.md +++ b/integrations/drift/README.md @@ -1,6 +1,6 @@ # Drift -Manage contacts, conversations, and messaging for conversational marketing and sales. Create, retrieve, update, and delete contacts with custom attributes. Start conversations, send bot messages, retrieve message history, and export transcripts. Manage accounts for account-based marketing and retrieve active playbooks. Schedule and track meetings booked by visitors. List teams and manage user availability. Handle GDPR data retrieval and deletion requests. Provision users via SCIM 2.0. Receive real-time webhooks for new conversations, messages, contact updates, meeting bookings, playbook goals, and user availability changes. +Manage contacts, conversations, and messaging for conversational marketing and sales. Create, retrieve, update, and delete contacts with custom attributes, list custom contact fields, and post timeline events. Start conversations, send bot messages, retrieve message history, export transcripts through Slate attachments, and inspect conversation status counts. Manage accounts for account-based marketing, retrieve active playbooks, track booked meetings, list teams, inspect token metadata, and update user availability. ## Tools @@ -28,6 +28,22 @@ Retrieve a Drift contact by their ID or email address. Returns contact attribute Retrieve detailed information about a specific Drift conversation, including participants, tags, status, and related playbook. Optionally include the message transcript. +### Get Conversation Stats + +Get Drift conversation counts grouped by status for the connected organization. + +### Get Conversation Transcript + +Export a Drift conversation transcript as a Slate attachment. The transcript content is returned as an attachment, not inline output. + +### Get Token Info + +Inspect Drift metadata for the current access token, including organization ID, scopes, app credential ID, and expiration metadata. + +### List Custom Attributes + +List custom contact attributes configured in Drift. Use this before creating or updating contacts when you need the internal field names for custom attributes. + ### List Conversations List conversations in Drift with optional status filtering and pagination. Returns conversations sorted by most recently updated. @@ -48,6 +64,10 @@ List all users (agents) in the Drift organization. Returns the full list of user Create, retrieve, update, or delete an account in Drift. Accounts are used for personal account tracking and ABM (account-based marketing) targeting in playbooks. +### Post Timeline Event + +Post an external activity event to a Drift contact timeline. Provide either a Drift contact ID or an external ID that Drift can match to a contact. + ### Send Message Send a message in an existing Drift conversation. Can send chat messages visible to the contact or private notes visible only to agents. Supports interactive buttons. @@ -56,6 +76,10 @@ Send a message in an existing Drift conversation. Can send chat messages visible Update an existing Drift contact's attributes. Supports updating standard fields like name, email, phone, as well as any custom attributes. +### Update User Availability + +Update a Drift user's availability. This is useful for external routing systems that need to mark an agent available or offline. + ## License This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE). diff --git a/integrations/drift/docs/SPEC.md b/integrations/drift/docs/SPEC.md index f63b9378d4..83516c2e0a 100644 --- a/integrations/drift/docs/SPEC.md +++ b/integrations/drift/docs/SPEC.md @@ -88,6 +88,18 @@ IT admins can provision and manage user accounts with the Drift SCIM 2.0 API, us Admins can trigger a remote app uninstall on behalf of a client, as well as retrieve token information and metadata including the org, scopes, and app for a token. +### Implemented Tool Surface + +The integration exposes practical day-to-day API coverage for contacts, conversations, messages, users, accounts, meetings, playbooks, teams, and app-token diagnostics: + +- Contacts: create, retrieve by ID or email, update, delete, list custom attributes, and post timeline events. +- Conversations and messages: create a conversation, list conversations, retrieve a conversation with optional messages, send chat messages or private notes, export formatted or JSON transcripts as Slate attachments, and read conversation status counts. +- Users and teams: list users, retrieve a user by ID, update user availability, list org teams, and list teams for a user. +- Accounts: create, retrieve, list, update, and delete accounts. +- Other read-only operations: get booked meetings, list playbooks, and inspect current token metadata. + +GDPR and SCIM APIs are documented by Drift, but they are admin-oriented and are not exposed as tools in this practical surface. + ## Events Drift supports webhooks that deliver real-time event notifications via HTTP POST requests to a configured URL. Webhooks are configured in the app settings on dev.drift.com, where you provide a request URL and choose the events to subscribe to. A Verification Token is provided under App Credentials to verify that data sent to your endpoint is actually from Drift. diff --git a/integrations/drift/package.json b/integrations/drift/package.json index 84de539b8d..a908e5d892 100644 --- a/integrations/drift/package.json +++ b/integrations/drift/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/drift/slate.json b/integrations/drift/slate.json index 64a34ca455..1836e2d28a 100644 --- a/integrations/drift/slate.json +++ b/integrations/drift/slate.json @@ -1,18 +1,20 @@ { "name": "@metorial/drift", - "description": "Manage contacts, conversations, and messaging for conversational marketing and sales. Create, retrieve, update, and delete contacts with custom attributes. Start conversations, send bot messages, retrieve message history, and export transcripts. Manage accounts for account-based marketing and retrieve active playbooks. Schedule and track meetings booked by visitors. List teams and manage user availability. Handle GDPR data retrieval and deletion requests. Provision users via SCIM 2.0. Receive real-time webhooks for new conversations, messages, contact updates, meeting bookings, playbook goals, and user availability changes.", + "description": "Manage contacts, conversations, and messaging for conversational marketing and sales. Create, retrieve, update, and delete contacts with custom attributes, list custom fields, post timeline events, start conversations, send bot messages, retrieve message history, export transcripts through attachments, inspect conversation status counts, manage accounts, retrieve playbooks, track booked meetings, list teams, inspect token metadata, and update user availability.", "categories": ["crm-and-sales-tools", "email-and-messaging"], "skills": [ "manage contacts", "send bot messages", "retrieve conversation history", "export chat transcripts", + "post contact timeline events", + "list custom contact attributes", + "inspect conversation status counts", "schedule and track meetings", "manage accounts for ABM", "retrieve active playbooks", "manage user availability", - "handle GDPR requests", - "provision users via SCIM" + "inspect token metadata" ], "logoUrl": "https://provider-logos.metorial-cdn.com/drift.png" } diff --git a/integrations/drift/src/auth.ts b/integrations/drift/src/auth.ts index baf1f364a6..5c6e7a5ded 100644 --- a/integrations/drift/src/auth.ts +++ b/integrations/drift/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { driftApiError, driftServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -21,6 +22,11 @@ export let auth = SlateAuth.create() description: 'Listen to changes to contacts and query contacts', scope: 'contact_read' }, + { + title: 'All Contact Read', + description: 'Read contact metadata such as custom attributes', + scope: 'all_contact_read' + }, { title: 'Contact Write', description: 'Create and update contacts', @@ -78,20 +84,25 @@ export let auth = SlateAuth.create() handleCallback: async ctx => { let axios = createAxios({ baseURL: 'https://driftapi.com' }); - let response = await axios.post( - '/oauth2/token', - new URLSearchParams({ - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - code: ctx.code, - grant_type: 'authorization_code' - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + let response: any; + try { + response = await axios.post( + '/oauth2/token', + new URLSearchParams({ + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + code: ctx.code, + grant_type: 'authorization_code' + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } } - } - ); + ); + } catch (error) { + throw driftApiError(error, 'exchange OAuth code'); + } let data = response.data; @@ -108,22 +119,31 @@ export let auth = SlateAuth.create() }, handleTokenRefresh: async (ctx: any) => { + if (!ctx.output.refreshToken) { + throw driftServiceError('Cannot refresh Drift OAuth token without a refresh token.'); + } + let axios = createAxios({ baseURL: 'https://driftapi.com' }); - let response = await axios.post( - '/oauth2/token', - new URLSearchParams({ - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - refresh_token: ctx.output.refreshToken || '', - grant_type: 'refresh_token' - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + let response: any; + try { + response = await axios.post( + '/oauth2/token', + new URLSearchParams({ + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + refresh_token: ctx.output.refreshToken, + grant_type: 'refresh_token' + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } } - } - ); + ); + } catch (error) { + throw driftApiError(error, 'refresh OAuth token'); + } let data = response.data; @@ -142,18 +162,25 @@ export let auth = SlateAuth.create() getProfile: async (ctx: { output: { token: string }; input: {}; scopes: string[] }) => { let axios = createAxios({ baseURL: 'https://driftapi.com' }); - let response = await axios.get('/app/token', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + let response: any; + try { + response = await axios.post('/app/token_info', { + access_token: ctx.output.token + }); + } catch (error) { + throw driftApiError(error, 'get OAuth profile'); + } let tokenInfo = response.data; + let orgId = + typeof tokenInfo.authenticated_userid === 'string' + ? tokenInfo.authenticated_userid.replace(/^orgId:/, '') + : undefined; return { profile: { - id: tokenInfo.orgId ? String(tokenInfo.orgId) : undefined, - name: tokenInfo.org?.name + id: orgId, + name: tokenInfo.credential_id } }; } @@ -178,18 +205,25 @@ export let auth = SlateAuth.create() getProfile: async (ctx: { output: { token: string }; input: { token: string } }) => { let axios = createAxios({ baseURL: 'https://driftapi.com' }); - let response = await axios.get('/app/token', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + let response: any; + try { + response = await axios.post('/app/token_info', { + access_token: ctx.output.token + }); + } catch (error) { + throw driftApiError(error, 'get token profile'); + } let tokenInfo = response.data; + let orgId = + typeof tokenInfo.authenticated_userid === 'string' + ? tokenInfo.authenticated_userid.replace(/^orgId:/, '') + : undefined; return { profile: { - id: tokenInfo.orgId ? String(tokenInfo.orgId) : undefined, - name: tokenInfo.org?.name + id: orgId, + name: tokenInfo.credential_id } }; } diff --git a/integrations/drift/src/index.ts b/integrations/drift/src/index.ts index 23f9045a7e..694b25161c 100644 --- a/integrations/drift/src/index.ts +++ b/integrations/drift/src/index.ts @@ -7,13 +7,19 @@ import { getBookedMeetings, getContact, getConversation, + getConversationStats, + getConversationTranscript, + getTokenInfo, listConversations, + listCustomAttributes, listPlaybooks, listTeams, listUsers, manageAccount, + postTimelineEvent, sendMessage, - updateContact + updateContact, + updateUserAvailability } from './tools'; import { contactEvent, @@ -32,13 +38,19 @@ export let provider = Slate.create({ deleteContact, listConversations, getConversation, + getConversationTranscript, + getConversationStats, createConversation, sendMessage, listUsers, + updateUserAvailability, manageAccount, + postTimelineEvent, + listCustomAttributes, getBookedMeetings, listPlaybooks, - listTeams + listTeams, + getTokenInfo ], triggers: [conversationEvent, contactEvent, meetingEvent, userEvent, playbookEvent] }); diff --git a/integrations/drift/src/lib/client.ts b/integrations/drift/src/lib/client.ts index fd6d4af1af..19d91f82e1 100644 --- a/integrations/drift/src/lib/client.ts +++ b/integrations/drift/src/lib/client.ts @@ -1,4 +1,20 @@ import { createAxios } from 'slates'; +import { driftApiError } from './errors'; + +type DriftPagination = { + more?: boolean; + next?: string; +}; + +type DriftConversationPage = { + conversations: any[]; + pagination?: DriftPagination; +}; + +type DriftMessagePage = { + messages: any[]; + pagination?: DriftPagination; +}; export class DriftClient { private axios: ReturnType; @@ -13,107 +29,141 @@ export class DriftClient { }); } - // ── Contacts ── + private async request(operation: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw driftApiError(error, operation); + } + } async createContact(attributes: Record): Promise { - let response = await this.axios.post('/contacts', { attributes }); - return response.data?.data; + return await this.request('create contact', async () => { + let response = await this.axios.post('/contacts', { attributes }); + return response.data?.data; + }); } async getContact(contactId: string): Promise { - let response = await this.axios.get(`/contacts/${contactId}`); - return response.data?.data; + return await this.request('get contact', async () => { + let response = await this.axios.get(`/contacts/${contactId}`); + return response.data?.data; + }); } async getContactsByEmail(email: string): Promise { - let response = await this.axios.get('/contacts', { - params: { email } + return await this.request('get contacts by email', async () => { + let response = await this.axios.get('/contacts', { + params: { email } + }); + return response.data?.data || []; }); - return response.data?.data || []; } async updateContact(contactId: string, attributes: Record): Promise { - let response = await this.axios.patch(`/contacts/${contactId}`, { attributes }); - return response.data?.data; + return await this.request('update contact', async () => { + let response = await this.axios.patch(`/contacts/${contactId}`, { attributes }); + return response.data?.data; + }); } async deleteContact(contactId: string): Promise { - await this.axios.delete(`/contacts/${contactId}`); - } - - async unsubscribeContact(contactId: string): Promise { - await this.axios.post(`/contacts/${contactId}/unsubscribe`); + await this.request('delete contact', async () => { + await this.axios.delete(`/contacts/${contactId}`); + }); } - async postTimelineEvent(contactId: string, event: Record): Promise { - let response = await this.axios.post(`/contacts/${contactId}/timeline`, event); - return response.data?.data; + async postTimelineEvent(event: { + contactId?: number; + externalId?: string; + event: string; + createdAt?: number; + attributes?: Record; + }): Promise { + return await this.request('post timeline event', async () => { + let response = await this.axios.post('/contacts/timeline', event); + return response.data?.data ?? response.data; + }); } async listCustomAttributes(): Promise { - let response = await this.axios.get('/contacts/attributes'); - return response.data?.data?.properties || []; + return await this.request('list custom attributes', async () => { + let response = await this.axios.get('/contacts/attributes'); + return response.data?.data?.properties || []; + }); } - // ── Conversations ── - async createConversation( email: string, message: { body: string; attributes?: Record } ): Promise { - let response = await this.axios.post('/conversations/new', { - email, - message + return await this.request('create conversation', async () => { + let response = await this.axios.post('/conversations/new', { + email, + message + }); + return response.data?.data || response.data; }); - return response.data?.data || response.data; } async getConversation(conversationId: string): Promise { - let response = await this.axios.get(`/conversations/${conversationId}`); - return response.data?.data; + return await this.request('get conversation', async () => { + let response = await this.axios.get(`/conversations/${conversationId}`); + return response.data?.data; + }); } async listConversations( params: { limit?: number; next?: string; statusId?: number[] } = {} - ): Promise<{ conversations: any[]; pagination?: { more: boolean; next: string } }> { - let queryParams: Record = {}; - if (params.limit) queryParams.limit = params.limit; - if (params.next) queryParams.next = params.next; - if (params.statusId && params.statusId.length > 0) { - queryParams.statusId = params.statusId; - } + ): Promise { + return await this.request('list conversations', async () => { + let queryParams: Record = {}; + if (params.limit) queryParams.limit = params.limit; + if (params.next) queryParams.next = params.next; + if (params.statusId && params.statusId.length > 0) { + queryParams.statusId = params.statusId; + } - let response = await this.axios.get('/conversations/list', { params: queryParams }); - return { - conversations: response.data?.data || [], - pagination: response.data?.pagination - }; + let response = await this.axios.get('/conversations/list', { + params: queryParams + }); + return { + conversations: response.data?.data || [], + pagination: response.data?.pagination + }; + }); } async getConversationMessages( conversationId: string, next?: string - ): Promise<{ messages: any[]; pagination?: { more: boolean; next: string } }> { - let params: Record = {}; - if (next) params.next = next; - - let response = await this.axios.get(`/conversations/${conversationId}/messages`, { - params + ): Promise { + return await this.request('get conversation messages', async () => { + let params: Record = {}; + if (next) params.next = next; + + let response = await this.axios.get(`/conversations/${conversationId}/messages`, { + params + }); + return { + messages: response.data?.data?.messages || response.data?.data || [], + pagination: response.data?.pagination + }; }); - return { - messages: response.data?.data?.messages || response.data?.data || [], - pagination: response.data?.pagination - }; - } - - async getConversationTranscript(conversationId: string): Promise { - let response = await this.axios.get(`/conversations/${conversationId}/transcript`); - return response.data?.data; } - async getConversationAttachments(conversationId: string): Promise { - let response = await this.axios.get(`/conversations/${conversationId}/attachments`); - return response.data?.data || []; + async getConversationTranscript( + conversationId: string, + format: 'formatted' | 'json' + ): Promise { + return await this.request('get conversation transcript', async () => { + let path = + format === 'json' + ? `/conversations/${conversationId}/json_transcript` + : `/conversations/${conversationId}/transcript`; + let response = await this.axios.get(path); + return response.data?.data ?? response.data; + }); } async sendMessage( @@ -131,39 +181,45 @@ export class DriftClient { userId?: number; } ): Promise { - let response = await this.axios.post(`/conversations/${conversationId}/messages`, message); - return response.data?.data; + return await this.request('send message', async () => { + let response = await this.axios.post( + `/conversations/${conversationId}/messages`, + message + ); + return response.data?.data; + }); } - async bulkConversationStatuses(conversationIds: number[]): Promise { - let response = await this.axios.post('/conversations/statuses', { - conversationIds + async getConversationStats(): Promise { + return await this.request('get conversation stats', async () => { + let response = await this.axios.get('/conversations/stats'); + return response.data?.data ?? response.data; }); - return response.data?.data; } - // ── Users ── - async getUser(userId: string): Promise { - let response = await this.axios.get(`/users/${userId}`); - return response.data?.data; + return await this.request('get user', async () => { + let response = await this.axios.get(`/users/${userId}`); + return response.data?.data; + }); } async listUsers(): Promise { - let response = await this.axios.get('/users/list'); - return response.data?.data || []; + return await this.request('list users', async () => { + let response = await this.axios.get('/users/list'); + return response.data?.data || []; + }); } async updateUser(userId: string, data: Record): Promise { - let response = await this.axios.patch(`/users/update`, { - userId: Number(userId), - ...data + return await this.request('update user', async () => { + let response = await this.axios.patch('/users/update', data, { + params: { userId } + }); + return response.data?.data; }); - return response.data?.data; } - // ── Accounts ── - async createAccount(account: { ownerId: number; name: string; @@ -171,72 +227,86 @@ export class DriftClient { accountId?: string; customProperties?: Array<{ label: string; name: string; value: any; type: string }>; }): Promise { - let response = await this.axios.post('/accounts/create', account); - return response.data?.data; + return await this.request('create account', async () => { + let response = await this.axios.post('/accounts/create', account); + return response.data?.data; + }); } async getAccount(accountId: string): Promise { - let response = await this.axios.get(`/accounts/${accountId}`); - return response.data?.data; + return await this.request('get account', async () => { + let response = await this.axios.get(`/accounts/${accountId}`); + return response.data?.data; + }); } async listAccounts(): Promise { - let response = await this.axios.get('/accounts'); - return response.data?.data || []; + return await this.request('list accounts', async () => { + let response = await this.axios.get('/accounts'); + return response.data?.data || []; + }); } async updateAccount(accountId: string, data: Record): Promise { - let response = await this.axios.patch(`/accounts/update`, { - accountId, - ...data + return await this.request('update account', async () => { + let response = await this.axios.patch('/accounts/update', { + accountId, + ...data + }); + return response.data?.data; }); - return response.data?.data; } async deleteAccount(accountId: string): Promise { - await this.axios.delete(`/accounts/${accountId}`); + await this.request('delete account', async () => { + await this.axios.delete(`/accounts/${accountId}`); + }); } - // ── Meetings ── - async getBookedMeetings(params: { minStartTime: number; maxStartTime: number; limit?: number; }): Promise { - let response = await this.axios.get('/users/meetings/org', { - params: { - min_start_time: params.minStartTime, - max_start_time: params.maxStartTime, - ...(params.limit ? { limit: params.limit } : {}) - } + return await this.request('get booked meetings', async () => { + let response = await this.axios.get('/users/meetings/org', { + params: { + min_start_time: params.minStartTime, + max_start_time: params.maxStartTime, + ...(params.limit ? { limit: params.limit } : {}) + } + }); + return response.data?.data || []; }); - return response.data?.data || []; } - // ── Playbooks ── - async listPlaybooks(): Promise { - let response = await this.axios.get('/playbooks'); - return response.data?.data || []; + return await this.request('list playbooks', async () => { + let response = await this.axios.get('/playbooks'); + return response.data?.data || []; + }); } - // ── Teams ── - async listTeams(): Promise { - let response = await this.axios.get('/teams'); - return response.data?.data || []; + return await this.request('list teams', async () => { + let response = await this.axios.get('/teams'); + return response.data?.data || []; + }); } async listUserTeams(userId: string): Promise { - let response = await this.axios.get(`/teams/user/${userId}`); - return response.data?.data || []; + return await this.request('list user teams', async () => { + let response = await this.axios.get(`/teams/user/${userId}`); + return response.data?.data || []; + }); } - // ── Token Info ── - - async getTokenInfo(): Promise { - let response = await this.axios.get('/app/token'); - return response.data; + async getTokenInfo(accessToken: string): Promise { + return await this.request('get token info', async () => { + let response = await this.axios.post('/app/token_info', { + access_token: accessToken + }); + return response.data?.data ?? response.data; + }); } } diff --git a/integrations/drift/src/lib/errors.ts b/integrations/drift/src/lib/errors.ts new file mode 100644 index 0000000000..4851391a05 --- /dev/null +++ b/integrations/drift/src/lib/errors.ts @@ -0,0 +1,99 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.type); + addDetail(details, value.error); + collectDetails(value.errors, details); +}; + +let extractDriftMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + let error = response.data.error; + if (isRecord(error)) { + let code = error.type ?? error.code; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; + } + + return typeof error === 'string' ? error : undefined; +}; + +export let driftServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let driftApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = driftServiceError( + `Drift API ${operation} failed: ${statusLabelFor(response)}${extractDriftMessage(error)}` + ); + serviceError.data.reason = 'drift_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/drift/src/tools.schema.test.ts b/integrations/drift/src/tools.schema.test.ts new file mode 100644 index 0000000000..07cfd196c4 --- /dev/null +++ b/integrations/drift/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Drift tool input schemas', provider.actions); diff --git a/integrations/drift/src/tools/get-booked-meetings.ts b/integrations/drift/src/tools/get-booked-meetings.ts index c57f9b25bc..4e0ec96ab7 100644 --- a/integrations/drift/src/tools/get-booked-meetings.ts +++ b/integrations/drift/src/tools/get-booked-meetings.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DriftClient } from '../lib/client'; +import { driftServiceError } from '../lib/errors'; import { spec } from '../spec'; export let getBookedMeetings = SlateTool.create(spec, { @@ -59,6 +60,16 @@ export let getBookedMeetings = SlateTool.create(spec, { let minMs = new Date(ctx.input.minStartTime).getTime(); let maxMs = new Date(ctx.input.maxStartTime).getTime(); + if (!Number.isFinite(minMs) || !Number.isFinite(maxMs)) { + throw driftServiceError( + 'minStartTime and maxStartTime must be valid ISO 8601 datetimes.' + ); + } + + if (minMs > maxMs) { + throw driftServiceError('minStartTime must be before maxStartTime.'); + } + let meetings = await client.getBookedMeetings({ minStartTime: minMs, maxStartTime: maxMs, diff --git a/integrations/drift/src/tools/get-contact.ts b/integrations/drift/src/tools/get-contact.ts index 425cc9633a..b9ba872376 100644 --- a/integrations/drift/src/tools/get-contact.ts +++ b/integrations/drift/src/tools/get-contact.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DriftClient } from '../lib/client'; +import { driftServiceError } from '../lib/errors'; import { spec } from '../spec'; export let getContact = SlateTool.create(spec, { @@ -44,7 +45,7 @@ export let getContact = SlateTool.create(spec, { } else if (ctx.input.email) { contacts = await client.getContactsByEmail(ctx.input.email); } else { - throw new Error('Either contactId or email must be provided'); + throw driftServiceError('Either contactId or email must be provided.'); } return { diff --git a/integrations/drift/src/tools/get-conversation-stats.ts b/integrations/drift/src/tools/get-conversation-stats.ts new file mode 100644 index 0000000000..3e58b7d655 --- /dev/null +++ b/integrations/drift/src/tools/get-conversation-stats.ts @@ -0,0 +1,41 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DriftClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getConversationStats = SlateTool.create(spec, { + name: 'Get Conversation Stats', + key: 'get_conversation_stats', + description: `Get Drift conversation counts grouped by status for the connected organization.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + open: z.number().describe('Open conversation count'), + closed: z.number().describe('Closed conversation count'), + pending: z.number().describe('Pending conversation count'), + total: z.number().describe('Total conversations across returned statuses') + }) + ) + .handleInvocation(async ctx => { + let client = new DriftClient(ctx.auth.token); + let result = await client.getConversationStats(); + let counts = result.conversationCount ?? result; + let open = Number(counts.OPEN ?? counts.open ?? 0); + let closed = Number(counts.CLOSED ?? counts.closed ?? 0); + let pending = Number(counts.PENDING ?? counts.pending ?? 0); + + return { + output: { + open, + closed, + pending, + total: open + closed + pending + }, + message: `Found **${open}** open, **${closed}** closed, and **${pending}** pending conversation(s).` + }; + }) + .build(); diff --git a/integrations/drift/src/tools/get-conversation-transcript.ts b/integrations/drift/src/tools/get-conversation-transcript.ts new file mode 100644 index 0000000000..359fa14313 --- /dev/null +++ b/integrations/drift/src/tools/get-conversation-transcript.ts @@ -0,0 +1,58 @@ +import { createTextAttachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { DriftClient } from '../lib/client'; +import { spec } from '../spec'; + +let transcriptFormatSchema = z + .enum(['formatted', 'json']) + .optional() + .describe('Transcript format. "formatted" returns text; "json" returns raw JSON.'); + +export let getConversationTranscript = SlateTool.create(spec, { + name: 'Get Conversation Transcript', + key: 'get_conversation_transcript', + description: `Export a Drift conversation transcript as a Slate attachment. The transcript content is returned as an attachment, not inline output.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + conversationId: z.string().describe('Drift conversation ID'), + format: transcriptFormatSchema + }) + ) + .output( + z.object({ + conversationId: z.string().describe('Drift conversation ID'), + format: z.string().describe('Returned transcript format'), + mimeType: z.string().describe('Attachment MIME type'), + byteSize: z.number().describe('UTF-8 byte size of the transcript attachment'), + attachmentCount: z.number().describe('Number of transcript attachments returned') + }) + ) + .handleInvocation(async ctx => { + let client = new DriftClient(ctx.auth.token); + let format = ctx.input.format ?? 'formatted'; + let transcript = await client.getConversationTranscript(ctx.input.conversationId, format); + let content = + format === 'json' + ? JSON.stringify(transcript, null, 2) + : typeof transcript === 'string' + ? transcript + : JSON.stringify(transcript, null, 2); + let mimeType = format === 'json' ? 'application/json' : 'text/plain'; + + return { + output: { + conversationId: ctx.input.conversationId, + format, + mimeType, + byteSize: Buffer.byteLength(content, 'utf8'), + attachmentCount: 1 + }, + attachments: [createTextAttachment(content, mimeType)], + message: `Exported ${format} transcript for conversation \`${ctx.input.conversationId}\`.` + }; + }) + .build(); diff --git a/integrations/drift/src/tools/get-token-info.ts b/integrations/drift/src/tools/get-token-info.ts new file mode 100644 index 0000000000..ddf5e3fde6 --- /dev/null +++ b/integrations/drift/src/tools/get-token-info.ts @@ -0,0 +1,61 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DriftClient } from '../lib/client'; +import { spec } from '../spec'; + +let parseOrgId = (authenticatedUserId: unknown) => + typeof authenticatedUserId === 'string' + ? authenticatedUserId.replace(/^orgId:/, '') + : undefined; + +let parseScopes = (scope: unknown) => + typeof scope === 'string' && scope.trim() ? scope.split(/\s+/).filter(Boolean) : []; + +export let getTokenInfo = SlateTool.create(spec, { + name: 'Get Token Info', + key: 'get_token_info', + description: `Inspect Drift metadata for the current access token, including organization ID, scopes, app credential ID, and expiration metadata.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + tokenId: z.string().optional().describe('Unique Drift token identifier'), + orgId: z.string().optional().describe('Drift organization ID'), + credentialId: z + .string() + .optional() + .describe('App credential ID associated with the token'), + tokenType: z.string().optional().describe('Token type'), + expiresIn: z + .number() + .optional() + .describe('Milliseconds until expiration, or 0 for non-expiring tokens'), + createdAt: z + .number() + .optional() + .describe('Token creation timestamp in epoch milliseconds'), + scopes: z.array(z.string()).describe('Scopes granted to the current token') + }) + ) + .handleInvocation(async ctx => { + let client = new DriftClient(ctx.auth.token); + let tokenInfo = await client.getTokenInfo(ctx.auth.token); + let scopes = parseScopes(tokenInfo.scope); + + return { + output: { + tokenId: tokenInfo.id, + orgId: parseOrgId(tokenInfo.authenticated_userid), + credentialId: tokenInfo.credential_id, + tokenType: tokenInfo.token_type, + expiresIn: tokenInfo.expires_in, + createdAt: tokenInfo.created_at, + scopes + }, + message: `Current Drift token has **${scopes.length}** scope(s).` + }; + }) + .build(); diff --git a/integrations/drift/src/tools/index.ts b/integrations/drift/src/tools/index.ts index 7edbd8e43f..eee0615d29 100644 --- a/integrations/drift/src/tools/index.ts +++ b/integrations/drift/src/tools/index.ts @@ -4,10 +4,16 @@ export * from './delete-contact'; export * from './get-booked-meetings'; export * from './get-contact'; export * from './get-conversation'; +export * from './get-conversation-stats'; +export * from './get-conversation-transcript'; +export * from './get-token-info'; export * from './list-conversations'; +export * from './list-custom-attributes'; export * from './list-playbooks'; export * from './list-teams'; export * from './list-users'; export * from './manage-account'; +export * from './post-timeline-event'; export * from './send-message'; export * from './update-contact'; +export * from './update-user-availability'; diff --git a/integrations/drift/src/tools/list-custom-attributes.ts b/integrations/drift/src/tools/list-custom-attributes.ts new file mode 100644 index 0000000000..b9d816a570 --- /dev/null +++ b/integrations/drift/src/tools/list-custom-attributes.ts @@ -0,0 +1,48 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DriftClient } from '../lib/client'; +import { spec } from '../spec'; + +export let listCustomAttributes = SlateTool.create(spec, { + name: 'List Custom Attributes', + key: 'list_custom_attributes', + description: `List custom contact attributes configured in Drift. Use this before creating or updating contacts when you need the internal field names for custom attributes.`, + constraints: ['Requires Drift all_contact_read scope.'], + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + customAttributes: z + .array( + z.object({ + name: z.string().optional().describe('Internal custom attribute name'), + displayName: z.string().optional().describe('Display name shown in Drift'), + type: z.string().optional().describe('Attribute type') + }) + ) + .describe('Custom contact attributes configured in Drift'), + attributeCount: z.number().describe('Number of custom attributes returned') + }) + ) + .handleInvocation(async ctx => { + let client = new DriftClient(ctx.auth.token); + let attributes = await client.listCustomAttributes(); + + let customAttributes = attributes.map((attribute: any) => ({ + name: attribute.name, + displayName: attribute.displayName, + type: attribute.type + })); + + return { + output: { + customAttributes, + attributeCount: customAttributes.length + }, + message: `Retrieved **${customAttributes.length}** custom contact attribute(s).` + }; + }) + .build(); diff --git a/integrations/drift/src/tools/manage-account.ts b/integrations/drift/src/tools/manage-account.ts index 41aede8936..c04324214e 100644 --- a/integrations/drift/src/tools/manage-account.ts +++ b/integrations/drift/src/tools/manage-account.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DriftClient } from '../lib/client'; +import { driftServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageAccount = SlateTool.create(spec, { @@ -77,7 +78,7 @@ export let manageAccount = SlateTool.create(spec, { switch (ctx.input.action) { case 'create': { if (!ctx.input.ownerId || !ctx.input.name) { - throw new Error('ownerId and name are required to create an account'); + throw driftServiceError('ownerId and name are required to create an account.'); } let account = await client.createAccount({ ownerId: ctx.input.ownerId, @@ -92,7 +93,7 @@ export let manageAccount = SlateTool.create(spec, { }; } case 'get': { - if (!ctx.input.accountId) throw new Error('accountId is required'); + if (!ctx.input.accountId) throw driftServiceError('accountId is required.'); let account = await client.getAccount(ctx.input.accountId); return { output: { accounts: [mapAccount(account)] }, @@ -107,13 +108,18 @@ export let manageAccount = SlateTool.create(spec, { }; } case 'update': { - if (!ctx.input.accountId) throw new Error('accountId is required'); + if (!ctx.input.accountId) throw driftServiceError('accountId is required.'); let updateData: Record = {}; if (ctx.input.name) updateData.name = ctx.input.name; if (ctx.input.domain) updateData.domain = ctx.input.domain; if (ctx.input.ownerId) updateData.ownerId = ctx.input.ownerId; if (ctx.input.customProperties) updateData.customProperties = ctx.input.customProperties; + if (Object.keys(updateData).length === 0) { + throw driftServiceError( + 'At least one account field is required to update an account.' + ); + } let account = await client.updateAccount(ctx.input.accountId, updateData); return { output: { accounts: [mapAccount(account)] }, @@ -121,7 +127,7 @@ export let manageAccount = SlateTool.create(spec, { }; } case 'delete': { - if (!ctx.input.accountId) throw new Error('accountId is required'); + if (!ctx.input.accountId) throw driftServiceError('accountId is required.'); await client.deleteAccount(ctx.input.accountId); return { output: { deleted: true }, diff --git a/integrations/drift/src/tools/post-timeline-event.ts b/integrations/drift/src/tools/post-timeline-event.ts new file mode 100644 index 0000000000..c14a4839f2 --- /dev/null +++ b/integrations/drift/src/tools/post-timeline-event.ts @@ -0,0 +1,70 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DriftClient } from '../lib/client'; +import { driftServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let postTimelineEvent = SlateTool.create(spec, { + name: 'Post Timeline Event', + key: 'post_timeline_event', + description: `Post an external activity event to a Drift contact timeline. Provide either a Drift contact ID or an external ID that Drift can match to a contact.`, + instructions: [ + 'Provide contactId for a Drift contact lookup, or externalId for an external identifier lookup.', + 'The event name should include the source system or activity context.' + ] +}) + .input( + z.object({ + contactId: z.number().optional().describe('Drift contact ID to post the event to'), + externalId: z + .string() + .optional() + .describe('External contact identifier to use instead of contactId'), + event: z.string().describe('Timeline event name'), + createdAt: z + .number() + .optional() + .describe('Event timestamp in epoch milliseconds. Defaults to Drift receive time.'), + attributes: z + .record(z.string(), z.any()) + .optional() + .describe('Additional event attributes. Drift stringifies non-string values.') + }) + ) + .output( + z.object({ + accepted: z.boolean().describe('Whether Drift accepted the timeline event'), + contactId: z.number().optional().describe('Contact ID used for the event'), + externalId: z.string().optional().describe('External ID used for the event'), + event: z.string().describe('Timeline event name'), + createdAt: z.number().optional().describe('Event timestamp in epoch milliseconds'), + timelineEvent: z.any().optional().describe('Raw timeline event returned by Drift') + }) + ) + .handleInvocation(async ctx => { + if (!ctx.input.contactId && !ctx.input.externalId) { + throw driftServiceError('contactId or externalId is required to post a timeline event.'); + } + + let client = new DriftClient(ctx.auth.token); + let timelineEvent = await client.postTimelineEvent({ + contactId: ctx.input.contactId, + externalId: ctx.input.externalId, + event: ctx.input.event, + createdAt: ctx.input.createdAt, + attributes: ctx.input.attributes + }); + + return { + output: { + accepted: true, + contactId: ctx.input.contactId, + externalId: ctx.input.externalId, + event: ctx.input.event, + createdAt: ctx.input.createdAt, + timelineEvent + }, + message: `Posted timeline event **${ctx.input.event}**.` + }; + }) + .build(); diff --git a/integrations/drift/src/tools/send-message.ts b/integrations/drift/src/tools/send-message.ts index a94a0ce475..eb975acdec 100644 --- a/integrations/drift/src/tools/send-message.ts +++ b/integrations/drift/src/tools/send-message.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DriftClient } from '../lib/client'; +import { driftServiceError } from '../lib/errors'; import { spec } from '../spec'; export let sendMessage = SlateTool.create(spec, { @@ -47,6 +48,10 @@ export let sendMessage = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new DriftClient(ctx.auth.token); + if (!ctx.input.body && !ctx.input.buttons?.length) { + throw driftServiceError('body or buttons is required to send a Drift message.'); + } + let result = await client.sendMessage(ctx.input.conversationId, { type: ctx.input.type, body: ctx.input.body, diff --git a/integrations/drift/src/tools/update-contact.ts b/integrations/drift/src/tools/update-contact.ts index 5cc63e2310..cb6db0e1e6 100644 --- a/integrations/drift/src/tools/update-contact.ts +++ b/integrations/drift/src/tools/update-contact.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DriftClient } from '../lib/client'; +import { driftServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateContact = SlateTool.create(spec, { @@ -40,6 +41,12 @@ export let updateContact = SlateTool.create(spec, { if (ctx.input.name) attributes.name = ctx.input.name; if (ctx.input.phone) attributes.phone = ctx.input.phone; + if (Object.keys(attributes).length === 0) { + throw driftServiceError( + 'At least one contact attribute is required to update a contact.' + ); + } + let contact = await client.updateContact(ctx.input.contactId, attributes); return { diff --git a/integrations/drift/src/tools/update-user-availability.ts b/integrations/drift/src/tools/update-user-availability.ts new file mode 100644 index 0000000000..f923653dd5 --- /dev/null +++ b/integrations/drift/src/tools/update-user-availability.ts @@ -0,0 +1,47 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DriftClient } from '../lib/client'; +import { spec } from '../spec'; + +export let updateUserAvailability = SlateTool.create(spec, { + name: 'Update User Availability', + key: 'update_user_availability', + description: `Update a Drift user's availability. This is useful for external routing systems that need to mark an agent available or offline.`, + instructions: ['User roles cannot be updated through the Drift API.'], + tags: { + destructive: true + } +}) + .input( + z.object({ + userId: z.string().describe('Drift user ID to update'), + availability: z + .enum(['AVAILABLE', 'OFFLINE']) + .describe('New availability value for the Drift user') + }) + ) + .output( + z.object({ + userId: z.number().describe('Drift user ID'), + availability: z.string().optional().describe('Updated availability'), + name: z.string().optional().describe('User name'), + email: z.string().optional().describe('User email address') + }) + ) + .handleInvocation(async ctx => { + let client = new DriftClient(ctx.auth.token); + let user = await client.updateUser(ctx.input.userId, { + availability: ctx.input.availability + }); + + return { + output: { + userId: user.id, + availability: user.availability, + name: user.name, + email: user.email + }, + message: `Updated user \`${ctx.input.userId}\` availability to **${ctx.input.availability}**.` + }; + }) + .build(); diff --git a/integrations/drift/vitest.config.ts b/integrations/drift/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/drift/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/dropbox/README.md b/integrations/dropbox/README.md index bfc580c5f7..43247cd10f 100644 --- a/integrations/dropbox/README.md +++ b/integrations/dropbox/README.md @@ -1,6 +1,6 @@ # Dropbox -Upload, download, and manage files and folders in Dropbox cloud storage. Create, move, copy, and delete files and folders. Search files by name or content. Share files and folders via shared links with configurable access settings. Manage shared folder members and permissions. Create and manage file requests for document collection. Assign custom metadata properties to files and folders. View and restore file revisions. Retrieve user account information including storage quota. Administer Dropbox Business teams: add, remove, suspend, and update team members and groups. Create and manage team folders. Access team audit logs for change tracking. Receive webhook notifications for file changes. +Upload, download, and manage files and folders in Dropbox cloud storage. Create, move, copy, and delete files and folders. Search files by name or content. Share files and folders via shared links with configurable access settings. Manage shared folder membership, create file requests for document collection, generate temporary links and thumbnails, use upload sessions for chunked uploads, view and restore file revisions, retrieve account quota information, and receive file-change notifications. ## Tools @@ -14,7 +14,7 @@ Permanently delete a file or folder at the specified path. This action cannot be ### Download File -Download a file's content from Dropbox. Returns the file content as text along with its metadata. Suitable for text-based files. +Download a file's content from Dropbox. Returns the file content through a Slate attachment along with metadata. ### File Revisions @@ -28,10 +28,22 @@ Retrieve the current user's Dropbox account information including name, email, s Retrieve detailed metadata for a file or folder at a given path or by ID. Returns type, size, modification dates, revision, sharing status, and media info where available. +### Get Temporary Link + +Create a temporary streaming URL for a Dropbox file. Dropbox temporary links expire after about four hours. + +### Get Thumbnail + +Generate an image thumbnail and return it through a Slate attachment. + ### List Folder List files and folders in a Dropbox directory. Supports recursive listing and pagination via cursor. Use path "/" or "" for the root directory. +### Manage Upload Session + +Start, append to, or finish a Dropbox upload session for larger or chunked uploads. + ### Manage File Request Create, list, update, or delete Dropbox file requests. File requests allow others to upload files to your Dropbox. Use action "create" to make a new request, "list" to see all requests, "get" for a specific request, "update" to modify, or "delete" to remove requests. @@ -54,7 +66,7 @@ Share a folder with other users or manage shared folder membership. Use action " ### Upload File -Upload a text file to Dropbox at the specified path. Supports creating new files, overwriting existing files, or appending with autorename. Best for small text-based files (up to 150 MB). +Upload a file to Dropbox at the specified path. Supports text or base64 content, creating new files, overwriting existing files, or updating a specific revision. ## License diff --git a/integrations/dropbox/docs/SPEC.md b/integrations/dropbox/docs/SPEC.md index 47e1db0b81..d0be1cf264 100644 --- a/integrations/dropbox/docs/SPEC.md +++ b/integrations/dropbox/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Dropbox is a cloud storage platform that provides file storage, synchronization, and sharing capabilities. Its API (v2) allows programmatic access to files, folders, sharing, team management, and user account information. Dropbox also offers a Business API for team administration, auditing, and content management. +Dropbox is a cloud storage platform that provides file storage, synchronization, and sharing capabilities. Its API (v2) allows this integration to access user files and folders, sharing, file requests, temporary file links, thumbnails, upload sessions, revisions, account information, and file-change notifications. ## Authentication @@ -21,11 +21,11 @@ Dropbox uses **OAuth 2.0** as its sole authentication method. Apps are created i **PKCE Support:** Dropbox supports PKCE, an extension to OAuth that enables dynamic client secrets, designed for public clients that cannot guarantee safety of the client secret. PKCE is recommended for desktop, mobile, single-page JavaScript, and open source apps. -**OpenID Connect:** Dropbox supports OIDC scopes (`openid`, `email`, `profile`) which must be explicitly requested via the `scope` parameter. OIDC is only supported with the code grant flow (`response_type=code`). +**OpenID Connect:** Dropbox supports OIDC scopes (`openid`, `email`, `profile`) when explicitly requested via the `scope` parameter. This integration does not require OIDC scopes because it retrieves profile information from the Dropbox account endpoint. **Content Access Levels:** When creating an app, you select either "App folder" (scoped access to a dedicated folder within the user's Apps folder) or "Full Dropbox" (access to the user's entire Dropbox). -**Scopes:** Dropbox uses OAuth scopes to determine the actions the application is allowed to perform. Scopes are configured in the Permissions tab of the App Console. The selected scopes are applied to the access token and determine which API calls the application can execute. Scopes are generally organized into read and write actions on major objects, including: `account_info.read`, `files.metadata.read`, `files.metadata.write`, `files.content.read`, `files.content.write`, `sharing.read`, `sharing.write`, `file_requests.read`, `file_requests.write`, `contacts.read`, `contacts.write`, and various `team_*` scopes for Business API access (e.g., `team_data.member`, `team_info.read`, `members.read`, `events.read`). +**Scopes:** Dropbox uses OAuth scopes to determine the actions the application is allowed to perform. Scopes are configured in the Permissions tab of the App Console. This integration requests user-account, file metadata/content, sharing, and file request scopes needed for the exposed user-file workflows. It does not expose Dropbox Business team administration tools. **Authentication Types:** @@ -40,14 +40,16 @@ Dropbox uses **OAuth 2.0** as its sole authentication method. Apps are created i Create, read, edit, move, and delete files and folders using the Files API. Supports uploading and downloading files, creating folders, copying, moving, and deleting content. Files have unique IDs that remain constant even when moved or renamed, so you can reference files by path or by ID. -- Supports upload sessions for large files. +- Supports text and base64 file uploads plus upload sessions for larger or chunked files. +- Downloads and thumbnails return file bytes through Slate attachments. +- Temporary file links can be generated for streaming content. - File search by name or content. - File revisions: view and restore previous versions. -- Thumbnails can be generated for image and document files. +- Thumbnails can be generated for supported image files. ### Sharing -Create, list, and revoke shared links. Programmatically share folders and manage folder policies and membership. +Create, list, and revoke shared links. Programmatically share folders and manage basic folder membership. - Manage shared folder members (add, remove, update permissions). - Create and manage shared links with configurable access settings (password, expiration, audience). @@ -56,28 +58,10 @@ Create, list, and revoke shared links. Programmatically share folders and manage Automate document collection with the File Requests API. Create, list, update, and manage file requests that allow others to upload files to your Dropbox. -### File Properties (Custom Metadata) - -Assign custom metadata labels to Dropbox content with the File Properties API. Define property templates and apply custom key-value metadata to files and folders. - ### User Account Information Retrieve information about the authenticated user's account, including name, email, storage quota, and account type. Retrieve information about other connected accounts by account ID. -### Team Administration (Business API) - -Gain access to admin functionality with user and team management using the Dropbox Business APIs. - -- Manage team members: add, remove, suspend, and update team members. -- Manage groups: create, delete, and update group membership. -- Manage team folders: create, archive, and configure team folders. -- Retrieve team information and feature settings. -- The team audit log (Events API) enables viewing all changes to team files, sharing, team membership, settings changes, logins and devices, and more. - -### Contacts - -Read and manage the authenticated user's contacts. - ## Events Dropbox supports webhooks for real-time notifications of file changes. @@ -92,7 +76,3 @@ Webhooks notify web apps in real time when users' files change in Dropbox. Once - Requires the `files.metadata.read` scope to be authorized. - Webhook verification uses a GET request with a `challenge` parameter that must be echoed back. - Notifications are signed with HMAC-SHA256 using the app secret, provided in the `X-Dropbox-Signature` header. - -### Business Team File Change Notifications - -For Dropbox Business API apps, webhook notifications are available for all members of a connected team, similar to user-level notifications. The payload includes team IDs and member IDs with changes. Business API webhooks can also deliver notifications on team membership changes. diff --git a/integrations/dropbox/package.json b/integrations/dropbox/package.json index 17030d7428..70e9a126ac 100644 --- a/integrations/dropbox/package.json +++ b/integrations/dropbox/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.8" } diff --git a/integrations/dropbox/slate.json b/integrations/dropbox/slate.json index 0f5c2ce4e0..1f7ecc8cff 100644 --- a/integrations/dropbox/slate.json +++ b/integrations/dropbox/slate.json @@ -1,6 +1,6 @@ { "name": "@dropbox/dropbox", - "description": "Upload, download, and manage files and folders in Dropbox cloud storage. Create, move, copy, and delete files and folders. Search files by name or content. Share files and folders via shared links with configurable access settings. Manage shared folder members and permissions. Create and manage file requests for document collection. Assign custom metadata properties to files and folders. View and restore file revisions. Retrieve user account information including storage quota. Administer Dropbox Business teams: add, remove, suspend, and update team members and groups. Create and manage team folders. Access team audit logs for change tracking. Receive webhook notifications for file changes.", + "description": "Upload, download, and manage files and folders in Dropbox cloud storage. Create, move, copy, and delete files and folders. Search files by name or content. Share files and folders via shared links with configurable access settings. Manage shared folder membership, create file requests for document collection, generate temporary links and thumbnails, use upload sessions for chunked uploads, view and restore file revisions, retrieve account quota information, and receive file-change notifications.", "categories": ["apis-and-http-requests", "document-processing", "email-and-messaging"], "skills": [ "upload and download files", @@ -9,10 +9,11 @@ "share files and folders", "manage shared link permissions", "create file requests", + "create temporary file links", + "generate thumbnails", + "use upload sessions", "view and restore file revisions", - "manage team members", - "administer team folders", - "access team audit logs" + "read account quota" ], "logoUrl": "https://provider-logos.metorial-cdn.com/dropbox.svg" } diff --git a/integrations/dropbox/src/auth.ts b/integrations/dropbox/src/auth.ts index b54d7f014b..8b2b24417a 100644 --- a/integrations/dropbox/src/auth.ts +++ b/integrations/dropbox/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { dropboxApiError, dropboxServiceError } from './lib/errors'; let apiAxios = createAxios({ baseURL: 'https://api.dropboxapi.com' @@ -75,19 +76,6 @@ export let auth = SlateAuth.create() title: 'File Requests Write', description: 'Manage file requests', scope: 'file_requests.write' - }, - { title: 'Contacts Read', description: 'Read contacts', scope: 'contacts.read' }, - { title: 'Contacts Write', description: 'Manage contacts', scope: 'contacts.write' }, - { title: 'OpenID', description: 'OpenID Connect authentication', scope: 'openid' }, - { - title: 'Email', - description: 'Access email address via OpenID Connect', - scope: 'email' - }, - { - title: 'Profile', - description: 'Access profile information via OpenID Connect', - scope: 'profile' } ], @@ -110,19 +98,24 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let params = new URLSearchParams({ - code: ctx.code, - grant_type: 'authorization_code', - redirect_uri: ctx.redirectUri, - client_id: ctx.clientId, - client_secret: ctx.clientSecret - }); - - let response = await apiAxios.post('/oauth2/token', params.toString(), { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - }); + let response: any; + try { + let params = new URLSearchParams({ + code: ctx.code, + grant_type: 'authorization_code', + redirect_uri: ctx.redirectUri, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }); + + response = await apiAxios.post('/oauth2/token', params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + } catch (error) { + throw dropboxApiError(error, 'exchange OAuth code'); + } let data = response.data; let expiresAt = data.expires_in @@ -140,21 +133,28 @@ export let auth = SlateAuth.create() handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - return { output: ctx.output }; + throw dropboxServiceError( + 'Dropbox refresh token is missing. Reconnect the account to restore offline access.' + ); } - let params = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: ctx.output.refreshToken, - client_id: ctx.clientId, - client_secret: ctx.clientSecret - }); - - let response = await apiAxios.post('/oauth2/token', params.toString(), { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - }); + let response: any; + try { + let params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }); + + response = await apiAxios.post('/oauth2/token', params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + } catch (error) { + throw dropboxApiError(error, 'refresh OAuth token'); + } let data = response.data; let expiresAt = data.expires_in @@ -175,11 +175,16 @@ export let auth = SlateAuth.create() input: {}; scopes: string[]; }) => { - let response = await apiAxios.post('/2/users/get_current_account', null, { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + let response: any; + try { + response = await apiAxios.post('/2/users/get_current_account', null, { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); + } catch (error) { + throw dropboxApiError(error, 'load OAuth profile'); + } let account = response.data; diff --git a/integrations/dropbox/src/index.ts b/integrations/dropbox/src/index.ts index 3122617edd..11c3385cc2 100644 --- a/integrations/dropbox/src/index.ts +++ b/integrations/dropbox/src/index.ts @@ -7,9 +7,12 @@ import { fileRevisions, getAccountInfo, getFileMetadata, + getTemporaryLink, + getThumbnail, listFolder, manageFileRequest, manageSharedLink, + manageUploadSession, moveOrCopy, searchFiles, shareFolder, @@ -28,6 +31,9 @@ export let provider = Slate.create({ uploadFile, downloadFile, searchFiles, + getTemporaryLink, + getThumbnail, + manageUploadSession, manageSharedLink, shareFolder, manageFileRequest, diff --git a/integrations/dropbox/src/lib/client.ts b/integrations/dropbox/src/lib/client.ts index f82e3d375f..9114fd8e78 100644 --- a/integrations/dropbox/src/lib/client.ts +++ b/integrations/dropbox/src/lib/client.ts @@ -1,4 +1,42 @@ -import { createAxios } from 'slates'; +import { createAxios, getResponseHeaderValue } from 'slates'; +import { dropboxApiError } from './errors'; + +type UploadMode = 'add' | 'overwrite' | 'update'; +type UploadContent = string | Uint8Array | Buffer; +type TaggedValue = { '.tag': T }; +type WriteModeValue = UploadMode | (TaggedValue<'update'> & { update: string }); + +type SharedLinkSettings = { + requestedVisibility?: string; + audience?: string; + access?: string; + allowDownload?: boolean; + password?: string; + expires?: string; +}; + +let tag = (value: T): TaggedValue => ({ '.tag': value }); + +let normalizeRootPath = (path: string) => (path === '/' ? '' : path); + +let toWriteMode = (mode: UploadMode, rev?: string): WriteModeValue => { + if (mode !== 'update') return mode; + + return { + '.tag': 'update', + update: rev ?? '' + }; +}; + +let toBase64 = (data: unknown) => { + if (Buffer.isBuffer(data)) return data.toString('base64'); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('base64'); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('base64'); + } + if (typeof data === 'string') return Buffer.from(data, 'binary').toString('base64'); + return Buffer.from(String(data)).toString('base64'); +}; export class DropboxClient { private api: ReturnType; @@ -21,44 +59,77 @@ export class DropboxClient { }); } - // ── Files & Folders ────────────────────────────────────────── + private async apiPost(path: string, data: unknown, operation: string) { + try { + let response = await this.api.post(path, data); + return response.data; + } catch (error) { + throw dropboxApiError(error, operation); + } + } + + private async contentPost( + path: string, + data: unknown, + config: Record, + operation: string + ) { + try { + return await this.content.post(path, data, config); + } catch (error) { + throw dropboxApiError(error, operation); + } + } + + // Files & folders async listFolder(path: string, recursive: boolean = false, limit?: number) { - let response = await this.api.post('/files/list_folder', { - path: path === '/' ? '' : path, - recursive, - include_mounted_folders: true, - include_non_downloadable_files: true, - ...(limit ? { limit } : {}) - }); - return response.data; + return await this.apiPost( + '/files/list_folder', + { + path: normalizeRootPath(path), + recursive, + include_mounted_folders: true, + include_non_downloadable_files: true, + ...(limit ? { limit } : {}) + }, + 'list folder' + ); } async listFolderContinue(cursor: string) { - let response = await this.api.post('/files/list_folder/continue', { cursor }); - return response.data; + return await this.apiPost( + '/files/list_folder/continue', + { cursor }, + 'continue folder listing' + ); } async getMetadata(path: string) { - let response = await this.api.post('/files/get_metadata', { - path, - include_media_info: true, - include_has_explicit_shared_members: true - }); - return response.data; + return await this.apiPost( + '/files/get_metadata', + { + path, + include_media_info: true, + include_has_explicit_shared_members: true + }, + 'get metadata' + ); } async createFolder(path: string, autorename: boolean = false) { - let response = await this.api.post('/files/create_folder_v2', { - path, - autorename - }); - return response.data; + return await this.apiPost( + '/files/create_folder_v2', + { + path, + autorename + }, + 'create folder' + ); } async deleteFile(path: string) { - let response = await this.api.post('/files/delete_v2', { path }); - return response.data; + return await this.apiPost('/files/delete_v2', { path }, 'delete file or folder'); } async moveFile( @@ -67,13 +138,16 @@ export class DropboxClient { autorename: boolean = false, allowOwnershipTransfer: boolean = false ) { - let response = await this.api.post('/files/move_v2', { - from_path: fromPath, - to_path: toPath, - autorename, - allow_ownership_transfer: allowOwnershipTransfer - }); - return response.data; + return await this.apiPost( + '/files/move_v2', + { + from_path: fromPath, + to_path: toPath, + autorename, + allow_ownership_transfer: allowOwnershipTransfer + }, + 'move file or folder' + ); } async copyFile( @@ -82,53 +156,213 @@ export class DropboxClient { autorename: boolean = false, allowOwnershipTransfer: boolean = false ) { - let response = await this.api.post('/files/copy_v2', { - from_path: fromPath, - to_path: toPath, - autorename, - allow_ownership_transfer: allowOwnershipTransfer - }); - return response.data; + return await this.apiPost( + '/files/copy_v2', + { + from_path: fromPath, + to_path: toPath, + autorename, + allow_ownership_transfer: allowOwnershipTransfer + }, + 'copy file or folder' + ); } async uploadFile( path: string, - content: string, - mode: string = 'add', + content: UploadContent, + mode: UploadMode = 'add', autorename: boolean = false, - mute: boolean = false + mute: boolean = false, + options?: { + rev?: string; + contentHash?: string; + clientModified?: string; + } ) { - let response = await this.content.post('/files/upload', content, { - headers: { - 'Content-Type': 'application/octet-stream', - 'Dropbox-API-Arg': JSON.stringify({ - path, - mode, - autorename, - mute, - strict_conflict: false - }) - } - }); + let response = await this.contentPost( + '/files/upload', + content, + { + headers: { + 'Content-Type': 'application/octet-stream', + 'Dropbox-API-Arg': JSON.stringify({ + path, + mode: toWriteMode(mode, options?.rev), + autorename, + mute, + strict_conflict: false, + ...(options?.clientModified ? { client_modified: options.clientModified } : {}), + ...(options?.contentHash ? { content_hash: options.contentHash } : {}) + }) + } + }, + 'upload file' + ); return response.data; } async downloadFile(path: string) { - let response = await this.content.post('/files/download', null, { - headers: { - 'Dropbox-API-Arg': JSON.stringify({ path }) + let response = await this.contentPost( + '/files/download', + null, + { + headers: { + 'Dropbox-API-Arg': JSON.stringify({ path }) + }, + responseType: 'arraybuffer' }, - responseType: 'text' - }); - let metadata = response.headers['dropbox-api-result']; + 'download file' + ); + let headers = response.headers as Record; + let metadata = getResponseHeaderValue(headers, 'dropbox-api-result'); let parsedMetadata = metadata ? JSON.parse(metadata) : {}; return { metadata: parsedMetadata, - content: response.data + contentBase64: toBase64(response.data), + contentType: getResponseHeaderValue(headers, 'content-type') }; } - // ── Search ─────────────────────────────────────────────────── + async getTemporaryLink(path: string) { + return await this.apiPost('/files/get_temporary_link', { path }, 'get temporary link'); + } + + async getThumbnail( + path: string, + options?: { + format?: string; + size?: string; + mode?: string; + quality?: string; + excludeMediaInfo?: boolean; + } + ) { + let response = await this.contentPost( + '/files/get_thumbnail_v2', + null, + { + headers: { + 'Dropbox-API-Arg': JSON.stringify({ + resource: { + '.tag': 'path', + path + }, + format: tag(options?.format ?? 'jpeg'), + size: tag(options?.size ?? 'w64h64'), + mode: tag(options?.mode ?? 'strict'), + quality: tag(options?.quality ?? 'quality_80'), + ...(options?.excludeMediaInfo !== undefined + ? { exclude_media_info: options.excludeMediaInfo } + : {}) + }) + }, + responseType: 'arraybuffer' + }, + 'get thumbnail' + ); + let headers = response.headers as Record; + let metadata = getResponseHeaderValue(headers, 'dropbox-api-result'); + let parsedMetadata = metadata ? JSON.parse(metadata) : {}; + return { + metadata: parsedMetadata.file_metadata ?? parsedMetadata.link_metadata ?? {}, + contentBase64: toBase64(response.data), + contentType: getResponseHeaderValue(headers, 'content-type') + }; + } + + async startUploadSession( + content: UploadContent, + close: boolean = false, + contentHash?: string + ) { + let response = await this.contentPost( + '/files/upload_session/start', + content, + { + headers: { + 'Content-Type': 'application/octet-stream', + 'Dropbox-API-Arg': JSON.stringify({ + close, + ...(contentHash ? { content_hash: contentHash } : {}) + }) + } + }, + 'start upload session' + ); + return response.data; + } + + async appendUploadSession( + sessionId: string, + offset: number, + content: UploadContent, + close: boolean = false, + contentHash?: string + ) { + let response = await this.contentPost( + '/files/upload_session/append_v2', + content, + { + headers: { + 'Content-Type': 'application/octet-stream', + 'Dropbox-API-Arg': JSON.stringify({ + cursor: { + session_id: sessionId, + offset + }, + close, + ...(contentHash ? { content_hash: contentHash } : {}) + }) + } + }, + 'append upload session' + ); + return response.data; + } + + async finishUploadSession( + sessionId: string, + offset: number, + path: string, + content: UploadContent, + options?: { + mode?: UploadMode; + rev?: string; + autorename?: boolean; + mute?: boolean; + contentHash?: string; + clientModified?: string; + } + ) { + let response = await this.contentPost( + '/files/upload_session/finish', + content, + { + headers: { + 'Content-Type': 'application/octet-stream', + 'Dropbox-API-Arg': JSON.stringify({ + cursor: { + session_id: sessionId, + offset + }, + commit: { + path, + mode: toWriteMode(options?.mode ?? 'add', options?.rev), + autorename: options?.autorename ?? false, + mute: options?.mute ?? false, + ...(options?.clientModified ? { client_modified: options.clientModified } : {}) + }, + ...(options?.contentHash ? { content_hash: options.contentHash } : {}) + }) + } + }, + 'finish upload session' + ); + return response.data; + } + + // Search async searchFiles( query: string, @@ -137,77 +371,68 @@ export class DropboxClient { fileCategories?: string[] ) { let options: Record = {}; - if (path) { - options.path = path; - } - if (maxResults) { - options.max_results = maxResults; - } - if (fileCategories && fileCategories.length > 0) { - options.file_categories = fileCategories; - } + if (path) options.path = path; + if (maxResults) options.max_results = maxResults; + if (fileCategories && fileCategories.length > 0) options.file_categories = fileCategories; + + return await this.apiPost( + '/files/search_v2', + { + query, + options: Object.keys(options).length > 0 ? options : undefined + }, + 'search files' + ); + } - let response = await this.api.post('/files/search_v2', { - query, - options: Object.keys(options).length > 0 ? options : undefined - }); - return response.data; + async searchFilesContinue(cursor: string) { + return await this.apiPost('/files/search/continue_v2', { cursor }, 'continue file search'); } - // ── File Revisions ─────────────────────────────────────────── + // File revisions - async listRevisions(path: string, limit?: number) { - let response = await this.api.post('/files/list_revisions', { - path, - mode: { '.tag': 'path' }, - ...(limit ? { limit } : {}) - }); - return response.data; + async listRevisions(path: string, limit?: number, mode: 'path' | 'id' = 'path') { + return await this.apiPost( + '/files/list_revisions', + { + path, + mode: tag(mode), + ...(limit ? { limit } : {}) + }, + 'list file revisions' + ); } async restoreRevision(path: string, rev: string) { - let response = await this.api.post('/files/restore', { path, rev }); - return response.data; + return await this.apiPost('/files/restore', { path, rev }, 'restore file revision'); } - // ── Sharing ────────────────────────────────────────────────── + // Sharing - async createSharedLink( - path: string, - settings?: { - requestedVisibility?: string; - audience?: string; - access?: string; - allowDownload?: boolean; - password?: string; - expires?: string; - } - ) { + async createSharedLink(path: string, settings?: SharedLinkSettings) { let requestSettings: Record = {}; if (settings?.requestedVisibility) { - requestSettings.requested_visibility = { '.tag': settings.requestedVisibility }; - } - if (settings?.audience) { - requestSettings.audience = { '.tag': settings.audience }; - } - if (settings?.access) { - requestSettings.access = { '.tag': settings.access }; + requestSettings.requested_visibility = tag(settings.requestedVisibility); } + if (settings?.audience) requestSettings.audience = tag(settings.audience); + if (settings?.access) requestSettings.access = tag(settings.access); if (settings?.allowDownload !== undefined) { requestSettings.allow_download = settings.allowDownload; } if (settings?.password) { + requestSettings.require_password = true; requestSettings.link_password = settings.password; } - if (settings?.expires) { - requestSettings.expires = settings.expires; - } + if (settings?.expires) requestSettings.expires = settings.expires; - let response = await this.api.post('/sharing/create_shared_link_with_settings', { - path, - settings: Object.keys(requestSettings).length > 0 ? requestSettings : undefined - }); - return response.data; + return await this.apiPost( + '/sharing/create_shared_link_with_settings', + { + path, + settings: Object.keys(requestSettings).length > 0 ? requestSettings : undefined + }, + 'create shared link' + ); } async listSharedLinks(path?: string, cursor?: string, directOnly?: boolean) { @@ -216,12 +441,11 @@ export class DropboxClient { if (cursor) body.cursor = cursor; if (directOnly !== undefined) body.direct_only = directOnly; - let response = await this.api.post('/sharing/list_shared_links', body); - return response.data; + return await this.apiPost('/sharing/list_shared_links', body, 'list shared links'); } async revokeSharedLink(url: string) { - await this.api.post('/sharing/revoke_shared_link', { url }); + await this.apiPost('/sharing/revoke_shared_link', { url }, 'revoke shared link'); } async shareFolder( @@ -232,12 +456,11 @@ export class DropboxClient { forceAsync: boolean = false ) { let body: Record = { path, force_async: forceAsync }; - if (memberPolicy) body.member_policy = { '.tag': memberPolicy }; - if (aclUpdatePolicy) body.acl_update_policy = { '.tag': aclUpdatePolicy }; - if (sharedLinkPolicy) body.shared_link_policy = { '.tag': sharedLinkPolicy }; + if (memberPolicy) body.member_policy = tag(memberPolicy); + if (aclUpdatePolicy) body.acl_update_policy = tag(aclUpdatePolicy); + if (sharedLinkPolicy) body.shared_link_policy = tag(sharedLinkPolicy); - let response = await this.api.post('/sharing/share_folder', body); - return response.data; + return await this.apiPost('/sharing/share_folder', body, 'share folder'); } async addFolderMember( @@ -246,16 +469,19 @@ export class DropboxClient { quiet: boolean = false, customMessage?: string ) { - let response = await this.api.post('/sharing/add_folder_member', { - shared_folder_id: sharedFolderId, - members: members.map(m => ({ - member: { '.tag': 'email', email: m.email }, - access_level: { '.tag': m.accessLevel || 'viewer' } - })), - quiet, - custom_message: customMessage - }); - return response.data; + return await this.apiPost( + '/sharing/add_folder_member', + { + shared_folder_id: sharedFolderId, + members: members.map(m => ({ + member: { '.tag': 'email', email: m.email }, + access_level: tag(m.accessLevel || 'viewer') + })), + quiet, + custom_message: customMessage + }, + 'add shared folder member' + ); } async removeFolderMember( @@ -263,103 +489,129 @@ export class DropboxClient { memberEmail: string, leaveACopy: boolean = false ) { - let response = await this.api.post('/sharing/remove_folder_member', { - shared_folder_id: sharedFolderId, - member: { '.tag': 'email', email: memberEmail }, - leave_a_copy: leaveACopy - }); - return response.data; + return await this.apiPost( + '/sharing/remove_folder_member', + { + shared_folder_id: sharedFolderId, + member: { '.tag': 'email', email: memberEmail }, + leave_a_copy: leaveACopy + }, + 'remove shared folder member' + ); } async listFolderMembers(sharedFolderId: string, limit?: number) { let body: Record = { shared_folder_id: sharedFolderId }; if (limit) body.limit = limit; - let response = await this.api.post('/sharing/list_folder_members', body); - return response.data; + return await this.apiPost( + '/sharing/list_folder_members', + body, + 'list shared folder members' + ); } - // ── File Requests ──────────────────────────────────────────── + // File requests async createFileRequest( title: string, destination: string, deadline?: string, - open: boolean = true + open: boolean = true, + description?: string ) { let body: Record = { title, destination, open }; - if (deadline) { - body.deadline = { deadline }; - } + if (deadline) body.deadline = { deadline }; + if (description) body.description = description; - let response = await this.api.post('/file_requests/create', body); - return response.data; + return await this.apiPost('/file_requests/create', body, 'create file request'); } - async listFileRequests() { - let response = await this.api.post('/file_requests/list_v2', { - limit: 1000 - }); - return response.data; + async listFileRequests(limit?: number) { + return await this.apiPost( + '/file_requests/list_v2', + { + limit: limit ?? 1000 + }, + 'list file requests' + ); + } + + async listFileRequestsContinue(cursor: string) { + return await this.apiPost( + '/file_requests/list/continue', + { cursor }, + 'continue file request listing' + ); } async getFileRequest(fileRequestId: string) { - let response = await this.api.post('/file_requests/get', { id: fileRequestId }); - return response.data; + return await this.apiPost('/file_requests/get', { id: fileRequestId }, 'get file request'); } async updateFileRequest( fileRequestId: string, - updates: { title?: string; destination?: string; deadline?: string | null; open?: boolean } + updates: { + title?: string; + destination?: string; + deadline?: string | null; + open?: boolean; + description?: string; + } ) { let body: Record = { id: fileRequestId }; if (updates.title !== undefined) body.title = updates.title; if (updates.destination !== undefined) body.destination = updates.destination; if (updates.open !== undefined) body.open = updates.open; + if (updates.description !== undefined) body.description = updates.description; if (updates.deadline !== undefined) { body.deadline = - updates.deadline === null ? { '.tag': 'no_deadline' } : { deadline: updates.deadline }; + updates.deadline === null + ? { '.tag': 'update', update: null } + : { '.tag': 'update', update: { deadline: updates.deadline } }; } - let response = await this.api.post('/file_requests/update', body); - return response.data; + return await this.apiPost('/file_requests/update', body, 'update file request'); } async deleteFileRequests(fileRequestIds: string[]) { - let response = await this.api.post('/file_requests/delete', { ids: fileRequestIds }); - return response.data; + return await this.apiPost( + '/file_requests/delete', + { ids: fileRequestIds }, + 'delete file requests' + ); } - // ── Account ────────────────────────────────────────────────── + // Account async getCurrentAccount() { - let response = await this.api.post('/users/get_current_account', null); - return response.data; + return await this.apiPost('/users/get_current_account', null, 'get current account'); } async getSpaceUsage() { - let response = await this.api.post('/users/get_space_usage', null); - return response.data; + return await this.apiPost('/users/get_space_usage', null, 'get space usage'); } async getAccount(accountId: string) { - let response = await this.api.post('/users/get_account', { account_id: accountId }); - return response.data; + return await this.apiPost('/users/get_account', { account_id: accountId }, 'get account'); } - // ── Polling / List folder longpoll ─────────────────────────── + // Polling / list folder cursors async listFolderGetLatestCursor(path: string, recursive: boolean = false) { - let response = await this.api.post('/files/list_folder/get_latest_cursor', { - path: path === '/' ? '' : path, - recursive, - include_mounted_folders: true, - include_non_downloadable_files: true - }); - return response.data; + return await this.apiPost( + '/files/list_folder/get_latest_cursor', + { + path: normalizeRootPath(path), + recursive, + include_mounted_folders: true, + include_non_downloadable_files: true + }, + 'get latest folder cursor' + ); } } diff --git a/integrations/dropbox/src/lib/content.ts b/integrations/dropbox/src/lib/content.ts new file mode 100644 index 0000000000..fd00a4564a --- /dev/null +++ b/integrations/dropbox/src/lib/content.ts @@ -0,0 +1,31 @@ +import { dropboxServiceError } from './errors'; + +export type DropboxContentEncoding = 'text' | 'base64'; + +let base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/; + +export let decodeDropboxContent = ( + content: string, + encoding: DropboxContentEncoding = 'text' +) => { + if (encoding === 'text') { + return content; + } + + let normalized = content.replace(/\s+/g, ''); + let firstPadding = normalized.indexOf('='); + if ( + normalized.length % 4 !== 0 || + !base64Pattern.test(normalized) || + (firstPadding !== -1 && firstPadding < normalized.length - 2) + ) { + throw dropboxServiceError( + 'content must be valid base64 when contentEncoding is "base64".' + ); + } + + return Buffer.from(normalized, 'base64'); +}; + +export let getDropboxContentLength = (content: string | Uint8Array | Buffer) => + typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength; diff --git a/integrations/dropbox/src/lib/errors.ts b/integrations/dropbox/src/lib/errors.ts new file mode 100644 index 0000000000..4fb40ae354 --- /dev/null +++ b/integrations/dropbox/src/lib/errors.ts @@ -0,0 +1,93 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed.length > 0 && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let addJsonDetail = (details: string[], value: unknown) => { + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value['.tag']); + addDetail(details, value.tag); + addDetail(details, value.reason); + + try { + let serialized = JSON.stringify(value); + if (serialized && serialized !== '{}') { + addDetail(details, serialized); + } + } catch { + // Ignore non-serializable upstream payloads. + } +}; + +let extractDropboxMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + addDetail(details, data.error_summary); + addDetail(details, data.error_description); + addDetail(details, data.message); + addJsonDetail(details, data.error); + } else { + addDetail(details, data); + } + + if (details.length > 0) return details.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let getStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let dropboxServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let dropboxApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = dropboxServiceError( + `Dropbox API ${operation} failed: ${statusLabel}${extractDropboxMessage(error)}` + ); + serviceError.data.reason = 'dropbox_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/dropbox/src/tools.schema.test.ts b/integrations/dropbox/src/tools.schema.test.ts new file mode 100644 index 0000000000..87b09e3e3d --- /dev/null +++ b/integrations/dropbox/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Dropbox tool input schemas', provider.actions); diff --git a/integrations/dropbox/src/tools/download-file.ts b/integrations/dropbox/src/tools/download-file.ts index e16265fd41..135dc42d58 100644 --- a/integrations/dropbox/src/tools/download-file.ts +++ b/integrations/dropbox/src/tools/download-file.ts @@ -1,4 +1,4 @@ -import { createTextAttachment, SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { DropboxClient } from '../lib/client'; import { spec } from '../spec'; @@ -6,10 +6,7 @@ import { spec } from '../spec'; export let downloadFile = SlateTool.create(spec, { name: 'Download File', key: 'download_file', - description: `Download a file from Dropbox. Returns the file as an attachment along with its metadata. Suitable for text-based files.`, - constraints: [ - 'Only text-based file content is returned. Binary files will return raw data that may not be usable as text.' - ], + description: `Download a file from Dropbox. Returns the file content as a Slate attachment along with its metadata.`, tags: { readOnly: true } @@ -20,7 +17,11 @@ export let downloadFile = SlateTool.create(spec, { .string() .describe( 'Path or ID of the file to download (e.g., "/Documents/report.txt" or "id:abc123")' - ) + ), + mimeType: z + .string() + .optional() + .describe('Optional MIME type to use for the returned attachment') }) ) .output( @@ -29,7 +30,8 @@ export let downloadFile = SlateTool.create(spec, { pathDisplay: z.string().optional().describe('Display path'), fileId: z.string().optional().describe('Unique file ID'), size: z.number().optional().describe('File size in bytes'), - rev: z.string().optional().describe('File revision') + rev: z.string().optional().describe('File revision'), + mimeType: z.string().optional().describe('Detected or requested attachment MIME type') }) ) .handleInvocation(async ctx => { @@ -42,9 +44,15 @@ export let downloadFile = SlateTool.create(spec, { pathDisplay: result.metadata.path_display, fileId: result.metadata.id, size: result.metadata.size, - rev: result.metadata.rev + rev: result.metadata.rev, + mimeType: ctx.input.mimeType ?? result.contentType }, - attachments: [createTextAttachment(result.content)], + attachments: [ + createBase64Attachment( + result.contentBase64, + ctx.input.mimeType ?? result.contentType ?? 'application/octet-stream' + ) + ], message: `Downloaded **${result.metadata.name || ctx.input.path}** (${result.metadata.size ?? '?'} bytes).` }; }) diff --git a/integrations/dropbox/src/tools/file-revisions.ts b/integrations/dropbox/src/tools/file-revisions.ts index 75ad64fb06..cd4b5324c5 100644 --- a/integrations/dropbox/src/tools/file-revisions.ts +++ b/integrations/dropbox/src/tools/file-revisions.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DropboxClient } from '../lib/client'; +import { dropboxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let fileRevisions = SlateTool.create(spec, { @@ -21,6 +22,12 @@ export let fileRevisions = SlateTool.create(spec, { .string() .optional() .describe('Revision ID to restore (required for "restore" action)'), + mode: z + .enum(['path', 'id']) + .optional() + .describe( + 'For "list", whether to return revisions for the current path or stable file ID' + ), limit: z .number() .optional() @@ -60,7 +67,11 @@ export let fileRevisions = SlateTool.create(spec, { let client = new DropboxClient(ctx.auth.token); if (ctx.input.action === 'list') { - let result = await client.listRevisions(ctx.input.path, ctx.input.limit); + let result = await client.listRevisions( + ctx.input.path, + ctx.input.limit, + ctx.input.mode ?? 'path' + ); let revisions = (result.entries || []).map((entry: any) => ({ rev: entry.rev, name: entry.name, @@ -77,7 +88,9 @@ export let fileRevisions = SlateTool.create(spec, { } // restore - if (!ctx.input.rev) throw new Error('Revision ID is required for restore'); + if (!ctx.input.rev) { + throw dropboxServiceError('rev is required for restore.'); + } let result = await client.restoreRevision(ctx.input.path, ctx.input.rev); return { diff --git a/integrations/dropbox/src/tools/get-account-info.ts b/integrations/dropbox/src/tools/get-account-info.ts index ca4897617a..1344ca0abc 100644 --- a/integrations/dropbox/src/tools/get-account-info.ts +++ b/integrations/dropbox/src/tools/get-account-info.ts @@ -63,10 +63,10 @@ export let getAccountInfo = SlateTool.create(spec, { client.getSpaceUsage() ]); - let allocated = spaceUsage.allocation?.allocated; - if (!allocated && spaceUsage.allocation?.['.tag'] === 'individual') { - allocated = spaceUsage.allocation?.allocated; - } + let allocated = + spaceUsage.allocation?.allocated ?? + spaceUsage.allocation?.user_within_team_space_allocated ?? + spaceUsage.allocation?.team?.allocated; return { output: { diff --git a/integrations/dropbox/src/tools/get-temporary-link.ts b/integrations/dropbox/src/tools/get-temporary-link.ts new file mode 100644 index 0000000000..76f76e804d --- /dev/null +++ b/integrations/dropbox/src/tools/get-temporary-link.ts @@ -0,0 +1,51 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DropboxClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getTemporaryLink = SlateTool.create(spec, { + name: 'Get Temporary Link', + key: 'get_temporary_link', + description: `Create a temporary Dropbox streaming link for a file. Dropbox temporary links expire after about four hours.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + path: z + .string() + .describe('Path or ID of the file (e.g., "/Documents/video.mp4" or "id:abc123")') + }) + ) + .output( + z.object({ + link: z.string().describe('Temporary URL for streaming the file content'), + expiresInSeconds: z + .number() + .describe('Approximate Dropbox temporary link lifetime in seconds'), + name: z.string().optional().describe('File name'), + pathDisplay: z.string().optional().describe('Display path'), + fileId: z.string().optional().describe('Unique file ID'), + size: z.number().optional().describe('File size in bytes'), + rev: z.string().optional().describe('File revision') + }) + ) + .handleInvocation(async ctx => { + let client = new DropboxClient(ctx.auth.token); + let result = await client.getTemporaryLink(ctx.input.path); + + return { + output: { + link: result.link, + expiresInSeconds: 4 * 60 * 60, + name: result.metadata?.name, + pathDisplay: result.metadata?.path_display, + fileId: result.metadata?.id, + size: result.metadata?.size, + rev: result.metadata?.rev + }, + message: `Created a temporary link for **${result.metadata?.name || ctx.input.path}**.` + }; + }) + .build(); diff --git a/integrations/dropbox/src/tools/get-thumbnail.ts b/integrations/dropbox/src/tools/get-thumbnail.ts new file mode 100644 index 0000000000..f2b3475d7d --- /dev/null +++ b/integrations/dropbox/src/tools/get-thumbnail.ts @@ -0,0 +1,94 @@ +import { createBase64Attachment, getBase64ByteLength, SlateTool } from 'slates'; +import { z } from 'zod'; +import { DropboxClient } from '../lib/client'; +import { spec } from '../spec'; + +let thumbnailFormatSchema = z.enum(['jpeg', 'png', 'webp']); + +export let getThumbnail = SlateTool.create(spec, { + name: 'Get Thumbnail', + key: 'get_thumbnail', + description: `Generate a thumbnail for a Dropbox image file and return it as a Slate attachment.`, + constraints: [ + 'Dropbox thumbnails are supported for common image formats such as jpg, jpeg, png, tiff, gif, webp, ppm, and bmp.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + path: z + .string() + .describe('Path or ID of the image file to thumbnail (e.g., "/Photos/image.png")'), + format: thumbnailFormatSchema + .optional() + .describe('Thumbnail image format. Defaults to "jpeg".'), + size: z + .enum([ + 'w32h32', + 'w64h64', + 'w128h128', + 'w256h256', + 'w480h320', + 'w640h480', + 'w960h640', + 'w1024h768', + 'w2048h1536', + 'w3200h2400' + ]) + .optional() + .describe('Requested thumbnail size. Defaults to "w64h64".'), + mode: z + .enum(['strict', 'bestfit', 'fitone_bestfit', 'original']) + .optional() + .describe('Thumbnail resize mode. Defaults to "strict".'), + quality: z + .enum(['quality_80', 'quality_90']) + .optional() + .describe('Thumbnail quality. Defaults to "quality_80".'), + excludeMediaInfo: z + .boolean() + .optional() + .describe('If true, ask Dropbox not to populate media_info metadata') + }) + ) + .output( + z.object({ + name: z.string().optional().describe('File name'), + pathDisplay: z.string().optional().describe('Display path'), + fileId: z.string().optional().describe('Unique file ID'), + size: z.number().optional().describe('Source file size in bytes'), + rev: z.string().optional().describe('File revision'), + thumbnailMimeType: z.string().describe('MIME type of the returned thumbnail'), + thumbnailSizeBytes: z.number().describe('Size of the returned thumbnail in bytes') + }) + ) + .handleInvocation(async ctx => { + let format = ctx.input.format ?? 'jpeg'; + let client = new DropboxClient(ctx.auth.token); + let result = await client.getThumbnail(ctx.input.path, { + format, + size: ctx.input.size, + mode: ctx.input.mode, + quality: ctx.input.quality, + excludeMediaInfo: ctx.input.excludeMediaInfo + }); + let mimeType = result.contentType ?? `image/${format === 'jpeg' ? 'jpeg' : format}`; + let thumbnailSizeBytes = getBase64ByteLength(result.contentBase64); + + return { + output: { + name: result.metadata?.name, + pathDisplay: result.metadata?.path_display, + fileId: result.metadata?.id, + size: result.metadata?.size, + rev: result.metadata?.rev, + thumbnailMimeType: mimeType, + thumbnailSizeBytes + }, + attachments: [createBase64Attachment(result.contentBase64, mimeType)], + message: `Generated a thumbnail for **${result.metadata?.name || ctx.input.path}**.` + }; + }) + .build(); diff --git a/integrations/dropbox/src/tools/index.ts b/integrations/dropbox/src/tools/index.ts index 3a908c2f48..83decd5434 100644 --- a/integrations/dropbox/src/tools/index.ts +++ b/integrations/dropbox/src/tools/index.ts @@ -4,9 +4,12 @@ export * from './download-file'; export * from './file-revisions'; export * from './get-account-info'; export * from './get-file-metadata'; +export * from './get-temporary-link'; +export * from './get-thumbnail'; export * from './list-folder'; export * from './manage-file-request'; export * from './manage-shared-link'; +export * from './manage-upload-session'; export * from './move-or-copy'; export * from './search-files'; export * from './share-folder'; diff --git a/integrations/dropbox/src/tools/manage-file-request.ts b/integrations/dropbox/src/tools/manage-file-request.ts index 0d5f6f38a1..44b33ccaf0 100644 --- a/integrations/dropbox/src/tools/manage-file-request.ts +++ b/integrations/dropbox/src/tools/manage-file-request.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DropboxClient } from '../lib/client'; +import { dropboxServiceError } from '../lib/errors'; import { spec } from '../spec'; let fileRequestSchema = z.object({ @@ -11,7 +12,8 @@ let fileRequestSchema = z.object({ isOpen: z.boolean().describe('Whether the file request is currently accepting submissions'), fileCount: z.number().optional().describe('Number of files submitted'), created: z.string().optional().describe('Creation timestamp'), - deadline: z.string().optional().describe('Deadline for submissions') + deadline: z.string().optional().describe('Deadline for submissions'), + description: z.string().optional().describe('Description shown on the file request') }); export let manageFileRequest = SlateTool.create(spec, { @@ -35,6 +37,14 @@ export let manageFileRequest = SlateTool.create(spec, { .array(z.string()) .optional() .describe('File request IDs to delete (for "delete" with multiple)'), + cursor: z + .string() + .optional() + .describe('Cursor from a previous list action to continue pagination'), + limit: z + .number() + .optional() + .describe('Maximum number of file requests to return for "list"'), title: z .string() .optional() @@ -53,7 +63,11 @@ export let manageFileRequest = SlateTool.create(spec, { open: z .boolean() .optional() - .describe('Whether the file request accepts submissions (for "create" and "update")') + .describe('Whether the file request accepts submissions (for "create" and "update")'), + description: z + .string() + .optional() + .describe('Description for the file request (for "create" and "update")') }) ) .output( @@ -65,6 +79,8 @@ export let manageFileRequest = SlateTool.create(spec, { .array(fileRequestSchema) .optional() .describe('List of file requests (for "list")'), + cursor: z.string().optional().describe('Cursor for fetching more file requests'), + hasMore: z.boolean().optional().describe('Whether more file requests are available'), deleted: z.boolean().optional().describe('Whether deletion was successful') }) ) @@ -79,19 +95,24 @@ export let manageFileRequest = SlateTool.create(spec, { isOpen: fr.is_open, fileCount: fr.file_count, created: fr.created, - deadline: fr.deadline?.deadline + deadline: fr.deadline?.deadline, + description: fr.description }); if (ctx.input.action === 'create') { - if (!ctx.input.title) throw new Error('Title is required to create a file request'); - if (!ctx.input.destination) - throw new Error('Destination is required to create a file request'); + if (!ctx.input.title) { + throw dropboxServiceError('title is required to create a file request.'); + } + if (!ctx.input.destination) { + throw dropboxServiceError('destination is required to create a file request.'); + } let result = await client.createFileRequest( ctx.input.title, ctx.input.destination, ctx.input.deadline ?? undefined, - ctx.input.open ?? true + ctx.input.open ?? true, + ctx.input.description ); return { @@ -101,17 +122,25 @@ export let manageFileRequest = SlateTool.create(spec, { } if (ctx.input.action === 'list') { - let result = await client.listFileRequests(); + let result = ctx.input.cursor + ? await client.listFileRequestsContinue(ctx.input.cursor) + : await client.listFileRequests(ctx.input.limit); let fileRequests = (result.file_requests || []).map(mapRequest); return { - output: { fileRequests }, + output: { + fileRequests, + cursor: result.cursor, + hasMore: result.has_more + }, message: `Found **${fileRequests.length}** file requests.` }; } if (ctx.input.action === 'get') { - if (!ctx.input.fileRequestId) throw new Error('File request ID is required'); + if (!ctx.input.fileRequestId) { + throw dropboxServiceError('fileRequestId is required.'); + } let result = await client.getFileRequest(ctx.input.fileRequestId); return { @@ -121,13 +150,16 @@ export let manageFileRequest = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.fileRequestId) throw new Error('File request ID is required'); + if (!ctx.input.fileRequestId) { + throw dropboxServiceError('fileRequestId is required.'); + } let result = await client.updateFileRequest(ctx.input.fileRequestId, { title: ctx.input.title, destination: ctx.input.destination, deadline: ctx.input.deadline, - open: ctx.input.open + open: ctx.input.open, + description: ctx.input.description }); return { @@ -140,7 +172,7 @@ export let manageFileRequest = SlateTool.create(spec, { let ids = ctx.input.fileRequestIds || (ctx.input.fileRequestId ? [ctx.input.fileRequestId] : []); if (ids.length === 0) - throw new Error('At least one file request ID is required for deletion'); + throw dropboxServiceError('At least one file request ID is required for deletion.'); await client.deleteFileRequests(ids); return { diff --git a/integrations/dropbox/src/tools/manage-shared-link.ts b/integrations/dropbox/src/tools/manage-shared-link.ts index 6ca6c81157..2c85987fda 100644 --- a/integrations/dropbox/src/tools/manage-shared-link.ts +++ b/integrations/dropbox/src/tools/manage-shared-link.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DropboxClient } from '../lib/client'; +import { dropboxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageSharedLink = SlateTool.create(spec, { @@ -24,10 +25,28 @@ export let manageSharedLink = SlateTool.create(spec, { .string() .optional() .describe('Shared link URL to revoke (required for "revoke")'), + cursor: z + .string() + .optional() + .describe('Cursor from a previous list action to continue pagination'), + directOnly: z + .boolean() + .optional() + .describe('For "list", return only links directly on path and not parent links'), visibility: z .enum(['public', 'team_only', 'password']) .optional() - .describe('Link visibility for "create" action'), + .describe( + 'Deprecated Dropbox visibility setting for "create"; prefer audience when possible' + ), + audience: z + .enum(['public', 'team', 'no_one', 'password']) + .optional() + .describe('Audience for a newly created shared link'), + access: z + .enum(['viewer', 'viewer_no_comment', 'editor', 'max', 'default']) + .optional() + .describe('Requested access level for a newly created shared link'), password: z.string().optional().describe('Password to protect the shared link'), expires: z .string() @@ -46,14 +65,26 @@ export let manageSharedLink = SlateTool.create(spec, { z.object({ url: z.string().describe('Shared link URL'), name: z.string().optional().describe('File or folder name'), + id: z.string().optional().describe('Linked file or folder ID'), pathLower: z.string().optional().describe('Lowercased path'), visibility: z.string().optional().describe('Link visibility setting'), + audience: z.string().optional().describe('Effective link audience'), expires: z.string().optional().describe('Link expiration time'), - linkAccessLevel: z.string().optional().describe('Access level of the link') + linkAccessLevel: z.string().optional().describe('Access level of the link'), + allowDownload: z + .boolean() + .optional() + .describe('Whether the shared link permits downloads'), + passwordProtected: z + .boolean() + .optional() + .describe('Whether the shared link is password protected') }) ) .optional() .describe('List of shared links (for "list" and "create" actions)'), + cursor: z.string().optional().describe('Cursor for fetching more shared links'), + hasMore: z.boolean().optional().describe('Whether more shared links are available'), revoked: z.boolean().optional().describe('Whether the link was successfully revoked') }) ) @@ -61,9 +92,13 @@ export let manageSharedLink = SlateTool.create(spec, { let client = new DropboxClient(ctx.auth.token); if (ctx.input.action === 'create') { - if (!ctx.input.path) throw new Error('Path is required to create a shared link'); + if (!ctx.input.path) { + throw dropboxServiceError('path is required to create a shared link.'); + } let link = await client.createSharedLink(ctx.input.path, { requestedVisibility: ctx.input.visibility, + audience: ctx.input.audience, + access: ctx.input.access, password: ctx.input.password, expires: ctx.input.expires, allowDownload: ctx.input.allowDownload @@ -75,10 +110,14 @@ export let manageSharedLink = SlateTool.create(spec, { { url: link.url, name: link.name, + id: link.id, pathLower: link.path_lower, visibility: link.link_permissions?.resolved_visibility?.['.tag'], + audience: link.link_permissions?.effective_audience?.['.tag'], expires: link.expires, - linkAccessLevel: link.link_permissions?.link_access_level?.['.tag'] + linkAccessLevel: link.link_permissions?.link_access_level?.['.tag'], + allowDownload: link.link_permissions?.allow_download, + passwordProtected: link.link_permissions?.require_password } ] }, @@ -87,24 +126,38 @@ export let manageSharedLink = SlateTool.create(spec, { } if (ctx.input.action === 'list') { - let result = await client.listSharedLinks(ctx.input.path); + let result = await client.listSharedLinks( + ctx.input.path, + ctx.input.cursor, + ctx.input.directOnly + ); let links = (result.links || []).map((link: any) => ({ url: link.url, name: link.name, + id: link.id, pathLower: link.path_lower, visibility: link.link_permissions?.resolved_visibility?.['.tag'], + audience: link.link_permissions?.effective_audience?.['.tag'], expires: link.expires, - linkAccessLevel: link.link_permissions?.link_access_level?.['.tag'] + linkAccessLevel: link.link_permissions?.link_access_level?.['.tag'], + allowDownload: link.link_permissions?.allow_download, + passwordProtected: link.link_permissions?.require_password })); return { - output: { links }, + output: { + links, + cursor: result.cursor, + hasMore: result.has_more + }, message: `Found **${links.length}** shared links${ctx.input.path ? ` for **${ctx.input.path}**` : ''}.` }; } // revoke - if (!ctx.input.linkUrl) throw new Error('Link URL is required to revoke a shared link'); + if (!ctx.input.linkUrl) { + throw dropboxServiceError('linkUrl is required to revoke a shared link.'); + } await client.revokeSharedLink(ctx.input.linkUrl); return { output: { revoked: true }, diff --git a/integrations/dropbox/src/tools/manage-upload-session.ts b/integrations/dropbox/src/tools/manage-upload-session.ts new file mode 100644 index 0000000000..519a86f679 --- /dev/null +++ b/integrations/dropbox/src/tools/manage-upload-session.ts @@ -0,0 +1,176 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { DropboxClient } from '../lib/client'; +import { decodeDropboxContent, getDropboxContentLength } from '../lib/content'; +import { dropboxServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let manageUploadSession = SlateTool.create(spec, { + name: 'Manage Upload Session', + key: 'manage_upload_session', + description: `Start, append to, or finish a Dropbox upload session for larger or chunked uploads.`, + constraints: [ + 'Each Dropbox upload session request should upload at most 150 MiB.', + 'Upload sessions expire after 7 days.' + ], + tags: { + destructive: false + } +}) + .input( + z.object({ + action: z + .enum(['start', 'append', 'finish']) + .describe('Upload session action to perform'), + sessionId: z + .string() + .optional() + .describe('Upload session ID. Required for "append" and "finish".'), + offset: z + .number() + .optional() + .describe('Current byte offset. Required for "append" and "finish".'), + path: z + .string() + .optional() + .describe('Destination path including filename. Required for "finish".'), + content: z + .string() + .optional() + .describe('Chunk content for this request. Defaults to empty content.'), + contentEncoding: z + .enum(['text', 'base64']) + .optional() + .describe( + 'How to decode content. Use "base64" for binary chunks. Defaults to "text".' + ), + close: z + .boolean() + .optional() + .describe('For "start" or "append", close the session after this chunk'), + mode: z + .enum(['add', 'overwrite', 'update']) + .optional() + .describe('Commit mode for "finish". Defaults to "add".'), + rev: z.string().optional().describe('Existing file revision required for update mode'), + autorename: z.boolean().optional().describe('For "finish", autorename on conflicts'), + mute: z.boolean().optional().describe('For "finish", suppress Dropbox notifications'), + contentHash: z + .string() + .optional() + .describe('Optional Dropbox content hash for this call'), + clientModified: z + .string() + .optional() + .describe('For "finish", optional ISO timestamp to store as client modified time') + }) + ) + .output( + z.object({ + sessionId: z.string().optional().describe('Upload session ID'), + offset: z.number().optional().describe('Next byte offset after this chunk'), + closed: z.boolean().optional().describe('Whether the session was closed by this action'), + file: z + .object({ + name: z.string().describe('Committed file name'), + pathDisplay: z.string().optional().describe('Display path of the committed file'), + fileId: z.string().optional().describe('Unique file ID'), + size: z.number().describe('Committed file size in bytes'), + rev: z.string().describe('Committed file revision'), + contentHash: z.string().optional().describe('Dropbox content hash') + }) + .optional() + .describe('Committed file metadata returned by "finish"') + }) + ) + .handleInvocation(async ctx => { + let content = decodeDropboxContent( + ctx.input.content ?? '', + ctx.input.contentEncoding ?? 'text' + ); + let contentLength = getDropboxContentLength(content); + let client = new DropboxClient(ctx.auth.token); + + if (ctx.input.action === 'start') { + let result = await client.startUploadSession( + content, + ctx.input.close ?? false, + ctx.input.contentHash + ); + + return { + output: { + sessionId: result.session_id, + offset: contentLength, + closed: ctx.input.close ?? false + }, + message: `Started Dropbox upload session **${result.session_id}**.` + }; + } + + if (!ctx.input.sessionId) { + throw dropboxServiceError('sessionId is required for append and finish actions.'); + } + if (ctx.input.offset === undefined) { + throw dropboxServiceError('offset is required for append and finish actions.'); + } + + if (ctx.input.action === 'append') { + await client.appendUploadSession( + ctx.input.sessionId, + ctx.input.offset, + content, + ctx.input.close ?? false, + ctx.input.contentHash + ); + + return { + output: { + sessionId: ctx.input.sessionId, + offset: ctx.input.offset + contentLength, + closed: ctx.input.close ?? false + }, + message: `Appended **${contentLength}** bytes to Dropbox upload session.` + }; + } + + if (!ctx.input.path) { + throw dropboxServiceError('path is required to finish an upload session.'); + } + if (ctx.input.mode === 'update' && !ctx.input.rev) { + throw dropboxServiceError('rev is required when finish mode is "update".'); + } + + let result = await client.finishUploadSession( + ctx.input.sessionId, + ctx.input.offset, + ctx.input.path, + content, + { + mode: ctx.input.mode ?? 'add', + rev: ctx.input.rev, + autorename: ctx.input.autorename, + mute: ctx.input.mute, + contentHash: ctx.input.contentHash, + clientModified: ctx.input.clientModified + } + ); + + return { + output: { + sessionId: ctx.input.sessionId, + offset: ctx.input.offset + contentLength, + closed: true, + file: { + name: result.name, + pathDisplay: result.path_display, + fileId: result.id, + size: result.size, + rev: result.rev, + contentHash: result.content_hash + } + }, + message: `Finished Dropbox upload session and committed **${result.name}**.` + }; + }) + .build(); diff --git a/integrations/dropbox/src/tools/search-files.ts b/integrations/dropbox/src/tools/search-files.ts index 3ffd7891ad..34523d7c48 100644 --- a/integrations/dropbox/src/tools/search-files.ts +++ b/integrations/dropbox/src/tools/search-files.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DropboxClient } from '../lib/client'; +import { dropboxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let searchFiles = SlateTool.create(spec, { @@ -13,7 +14,14 @@ export let searchFiles = SlateTool.create(spec, { }) .input( z.object({ - query: z.string().describe('Search query string'), + query: z + .string() + .optional() + .describe('Search query string. Required unless cursor is provided.'), + cursor: z + .string() + .optional() + .describe('Cursor from a previous search_files call to continue pagination'), path: z.string().optional().describe('Scope search to a specific folder path'), maxResults: z.number().optional().describe('Maximum number of results to return'), fileCategories: z @@ -50,17 +58,24 @@ export let searchFiles = SlateTool.create(spec, { }) ) .describe('Search result matches'), + cursor: z.string().optional().describe('Cursor for fetching the next page of results'), hasMore: z.boolean().describe('Whether more results are available') }) ) .handleInvocation(async ctx => { + if (!ctx.input.cursor && !ctx.input.query) { + throw dropboxServiceError('query is required unless cursor is provided.'); + } + let client = new DropboxClient(ctx.auth.token); - let result = await client.searchFiles( - ctx.input.query, - ctx.input.path, - ctx.input.maxResults, - ctx.input.fileCategories - ); + let result = ctx.input.cursor + ? await client.searchFilesContinue(ctx.input.cursor) + : await client.searchFiles( + ctx.input.query!, + ctx.input.path, + ctx.input.maxResults, + ctx.input.fileCategories + ); let matches = (result.matches || []).map((match: any) => { let metadata = match.metadata?.metadata || match.metadata; @@ -78,9 +93,10 @@ export let searchFiles = SlateTool.create(spec, { return { output: { matches, + cursor: result.cursor, hasMore: result.has_more ?? false }, - message: `Found **${matches.length}** results for "${ctx.input.query}"${result.has_more ? ' (more available)' : ''}.` + message: `Found **${matches.length}** results${ctx.input.query ? ` for "${ctx.input.query}"` : ''}${result.has_more ? ' (more available)' : ''}.` }; }) .build(); diff --git a/integrations/dropbox/src/tools/share-folder.ts b/integrations/dropbox/src/tools/share-folder.ts index e8fdc91a8b..ee3f1173e1 100644 --- a/integrations/dropbox/src/tools/share-folder.ts +++ b/integrations/dropbox/src/tools/share-folder.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DropboxClient } from '../lib/client'; +import { dropboxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let shareFolder = SlateTool.create(spec, { @@ -69,14 +70,13 @@ export let shareFolder = SlateTool.create(spec, { let client = new DropboxClient(ctx.auth.token); if (ctx.input.action === 'share') { - if (!ctx.input.path) throw new Error('Path is required to share a folder'); + if (!ctx.input.path) { + throw dropboxServiceError('path is required to share a folder.'); + } let result = await client.shareFolder(ctx.input.path); return { output: { - sharedFolderId: - result.shared_folder_id || result['.tag'] === 'complete' - ? result.shared_folder_id - : undefined, + sharedFolderId: result.shared_folder_id ?? result.metadata?.shared_folder_id, success: true }, message: `Shared folder **${ctx.input.path}**.` @@ -84,9 +84,11 @@ export let shareFolder = SlateTool.create(spec, { } if (ctx.input.action === 'add_member') { - if (!ctx.input.sharedFolderId) throw new Error('Shared folder ID is required'); + if (!ctx.input.sharedFolderId) { + throw dropboxServiceError('sharedFolderId is required.'); + } if (!ctx.input.members || ctx.input.members.length === 0) - throw new Error('At least one member is required'); + throw dropboxServiceError('At least one member is required.'); await client.addFolderMember( ctx.input.sharedFolderId, @@ -102,8 +104,12 @@ export let shareFolder = SlateTool.create(spec, { } if (ctx.input.action === 'remove_member') { - if (!ctx.input.sharedFolderId) throw new Error('Shared folder ID is required'); - if (!ctx.input.memberEmail) throw new Error('Member email is required'); + if (!ctx.input.sharedFolderId) { + throw dropboxServiceError('sharedFolderId is required.'); + } + if (!ctx.input.memberEmail) { + throw dropboxServiceError('memberEmail is required.'); + } await client.removeFolderMember(ctx.input.sharedFolderId, ctx.input.memberEmail); @@ -114,7 +120,9 @@ export let shareFolder = SlateTool.create(spec, { } // list_members - if (!ctx.input.sharedFolderId) throw new Error('Shared folder ID is required'); + if (!ctx.input.sharedFolderId) { + throw dropboxServiceError('sharedFolderId is required.'); + } let result = await client.listFolderMembers(ctx.input.sharedFolderId); let members = (result.users || []).map((user: any) => ({ diff --git a/integrations/dropbox/src/tools/upload-file.ts b/integrations/dropbox/src/tools/upload-file.ts index f68440ffad..cdde306d82 100644 --- a/integrations/dropbox/src/tools/upload-file.ts +++ b/integrations/dropbox/src/tools/upload-file.ts @@ -1,12 +1,14 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DropboxClient } from '../lib/client'; +import { decodeDropboxContent } from '../lib/content'; +import { dropboxServiceError } from '../lib/errors'; import { spec } from '../spec'; export let uploadFile = SlateTool.create(spec, { name: 'Upload File', key: 'upload_file', - description: `Upload a text file to Dropbox at the specified path. Supports creating new files, overwriting existing files, or appending with autorename. Best for small text-based files (up to 150 MB).`, + description: `Upload a file to Dropbox at the specified path. Supports text or base64 content, creating new files, overwriting existing files, or updating a specific revision.`, instructions: [ 'Use mode "add" to create a new file (fails if it exists), "overwrite" to replace existing content, or "update" to update a specific revision.' ], @@ -24,15 +26,33 @@ export let uploadFile = SlateTool.create(spec, { .string() .describe('Destination path including filename (e.g., "/Documents/notes.txt")'), content: z.string().describe('Text content to upload'), + contentEncoding: z + .enum(['text', 'base64']) + .optional() + .describe('How to decode content. Use "base64" for binary files. Defaults to "text".'), mode: z - .enum(['add', 'overwrite']) + .enum(['add', 'overwrite', 'update']) + .optional() + .describe( + 'Upload mode: "add" creates new file, "overwrite" replaces existing, "update" overwrites only if rev matches' + ), + rev: z + .string() .optional() - .describe('Upload mode: "add" creates new file, "overwrite" replaces existing'), + .describe('Existing file revision required when mode is "update"'), autorename: z .boolean() .optional() .describe('If true, rename the file if a conflict exists'), - mute: z.boolean().optional().describe('If true, suppress notifications for this upload') + mute: z.boolean().optional().describe('If true, suppress notifications for this upload'), + contentHash: z + .string() + .optional() + .describe('Optional Dropbox content hash to verify the uploaded bytes'), + clientModified: z + .string() + .optional() + .describe('Optional ISO timestamp to store as the client modified time') }) ) .output( @@ -46,13 +66,23 @@ export let uploadFile = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.mode === 'update' && !ctx.input.rev) { + throw dropboxServiceError('rev is required when upload mode is "update".'); + } + let client = new DropboxClient(ctx.auth.token); + let content = decodeDropboxContent(ctx.input.content, ctx.input.contentEncoding ?? 'text'); let result = await client.uploadFile( ctx.input.path, - ctx.input.content, + content, ctx.input.mode ?? 'add', ctx.input.autorename ?? false, - ctx.input.mute ?? false + ctx.input.mute ?? false, + { + rev: ctx.input.rev, + contentHash: ctx.input.contentHash, + clientModified: ctx.input.clientModified + } ); return { diff --git a/integrations/dropbox/vitest.config.ts b/integrations/dropbox/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/dropbox/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/eleven-labs/README.md b/integrations/eleven-labs/README.md index 4a2a5f1995..3dfb673b7a 100644 --- a/integrations/eleven-labs/README.md +++ b/integrations/eleven-labs/README.md @@ -6,12 +6,24 @@ Convert text to lifelike speech with customizable voices, intonation, and emotio ### Compose Music -Generate music from a text prompt describing genre, mood, style, and optionally lyrics. Returns base64-encoded audio. This is a batch operation and may take longer for longer compositions. +Generate music from a text prompt describing genre, mood, style, and optionally lyrics. Returns audio as a Slate attachment. This is a batch operation and may take longer for longer compositions. ### Create Dubbing Start a dubbing job to translate and voice-over audio/video content into another language. Provide a source URL and target language to begin. Returns the dubbing project ID for tracking progress. +### Create Dialogue + +Generate multi-turn dialogue audio from text and voice ID pairs. Returns audio as a Slate attachment. + +### Create Forced Alignment + +Align an audio file to a transcript and return character-level and word-level timing information. + +### Delete Dubbing + +Delete a dubbing project by ID. + ### Delete Voice Permanently delete a voice by its ID. Only voices you own can be deleted. This action cannot be undone. @@ -22,7 +34,7 @@ Update the default settings for a specific voice. These settings control how the ### Generate Sound Effect -Create sound effects from text descriptions. Describe the desired sound using natural language or audio terminology to generate cinematic sound effects, Foley, ambient sounds, and more. Returns base64-encoded audio. +Create sound effects from text descriptions. Describe the desired sound using natural language or audio terminology to generate cinematic sound effects, Foley, ambient sounds, and more. Returns audio as a Slate attachment. ### Get Account @@ -32,13 +44,17 @@ Retrieve current user profile and subscription details including character usage Check the status and details of a dubbing project. Use this to monitor progress of a dubbing job created with the "Create Dubbing" tool. +### Get History Audio + +Download the audio for a generated history item. Returns audio as a Slate attachment. + ### Get Voice Retrieve detailed metadata and settings for a specific voice by its ID. Includes voice properties, labels, and current settings like stability and similarity. ### Isolate Audio -Remove background noise from audio and isolate vocal tracks. Takes a base64-encoded audio file and returns cleaned audio with background noise, music, and ambient sounds removed. +Remove background noise from audio and isolate vocal tracks. Takes a base64-encoded audio file and returns cleaned audio as a Slate attachment. ### List History @@ -54,11 +70,15 @@ Search and browse available voices with filtering, sorting, and pagination. Retu ### Speech to Text -Transcribe audio into text with high accuracy. Supports speaker diarization, word-level timestamps, and 99+ languages. Provide audio as a base64-encoded file or a publicly accessible cloud storage URL. +Transcribe audio into text with high accuracy. Supports speaker diarization, word-level timestamps, and 99+ languages. Provide audio as a base64-encoded file or a publicly accessible source URL. ### Text to Speech -Convert text into lifelike speech audio using ElevenLabs voices and models. Returns base64-encoded audio that can be saved or played back. Supports multiple languages, voice customization, and various output formats. +Convert text into lifelike speech audio using ElevenLabs voices and models. Returns audio as a Slate attachment. Supports multiple languages, voice customization, pronunciation dictionaries, continuity hints, and various output formats. + +### Voice Changer + +Transform an existing audio file to sound like a selected ElevenLabs voice while preserving timing and delivery. Returns audio as a Slate attachment. ## License diff --git a/integrations/eleven-labs/package.json b/integrations/eleven-labs/package.json index d86dbfec92..139fbeec77 100644 --- a/integrations/eleven-labs/package.json +++ b/integrations/eleven-labs/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/eleven-labs/src/auth.ts b/integrations/eleven-labs/src/auth.ts index c873c04326..4e5413bc8a 100644 --- a/integrations/eleven-labs/src/auth.ts +++ b/integrations/eleven-labs/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { elevenLabsApiError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -33,8 +34,7 @@ export let auth = SlateAuth.create() } }); - let response = await client.get('/v1/user'); - let user = response.data as { + let user: { user_id?: string; first_name?: string; subscription?: { @@ -42,6 +42,13 @@ export let auth = SlateAuth.create() }; }; + try { + let response = await client.get('/v1/user'); + user = response.data as typeof user; + } catch (error) { + throw elevenLabsApiError(error, 'get auth profile'); + } + return { profile: { id: user.user_id, diff --git a/integrations/eleven-labs/src/index.ts b/integrations/eleven-labs/src/index.ts index 41c884f133..afde869059 100644 --- a/integrations/eleven-labs/src/index.ts +++ b/integrations/eleven-labs/src/index.ts @@ -2,19 +2,24 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { composeMusic, + createDialogue, createDubbing, + createForcedAlignment, + deleteDubbing, deleteVoice, editVoiceSettings, generateSoundEffect, getAccount, getDubbing, + getHistoryAudio, getVoice, isolateAudio, listHistory, listModels, listVoices, speechToText, - textToSpeech + textToSpeech, + voiceChanger } from './tools'; import { speechToTextCompletion, voiceAgentCall, voiceRemoval } from './triggers'; @@ -27,13 +32,18 @@ export let provider = Slate.create({ getVoice, deleteVoice, editVoiceSettings, + voiceChanger, + createDialogue, generateSoundEffect, composeMusic, createDubbing, getDubbing, + deleteDubbing, isolateAudio, + createForcedAlignment, listModels, listHistory, + getHistoryAudio, getAccount ], triggers: [voiceAgentCall, speechToTextCompletion, voiceRemoval] diff --git a/integrations/eleven-labs/src/lib/client.ts b/integrations/eleven-labs/src/lib/client.ts index da19902c7d..a534f647d3 100644 --- a/integrations/eleven-labs/src/lib/client.ts +++ b/integrations/eleven-labs/src/lib/client.ts @@ -1,4 +1,136 @@ +import { Buffer } from 'node:buffer'; import { createAxios } from 'slates'; +import { elevenLabsApiError, elevenLabsServiceError } from './errors'; + +export type AudioResult = { + contentBase64: string; + contentType: string; + byteLength: number; +}; + +type VoiceSettings = { + stability?: number; + similarityBoost?: number; + style?: number; + useSpeakerBoost?: boolean; + speed?: number; +}; + +type PronunciationDictionaryLocator = { + pronunciationDictionaryId: string; + versionId: string; +}; + +let appendFormField = (formData: FormData, name: string, value: unknown) => { + if (value === undefined || value === null) { + return; + } + + if (Array.isArray(value) || typeof value === 'object') { + formData.append(name, JSON.stringify(value)); + return; + } + + formData.append(name, String(value)); +}; + +let responseDataToBuffer = (data: unknown) => { + if (Buffer.isBuffer(data)) { + return data; + } + + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + + if (typeof data === 'string') { + return Buffer.from(data, 'binary'); + } + + throw elevenLabsServiceError('ElevenLabs returned file content in an unsupported format.'); +}; + +let responseHeader = (headers: unknown, name: string) => { + if (!headers || typeof headers !== 'object') { + return undefined; + } + + let record = headers as Record & { + get?: (key: string) => unknown; + }; + let value = record[name] ?? record[name.toLowerCase()] ?? record.get?.(name); + + return typeof value === 'string' ? value : undefined; +}; + +let responseToAudio = ( + response: { data: unknown; headers?: unknown }, + fallbackType: string +) => { + let content = responseDataToBuffer(response.data); + return { + contentBase64: content.toString('base64'), + contentType: responseHeader(response.headers, 'content-type') ?? fallbackType, + byteLength: content.byteLength + }; +}; + +let decodeBase64File = (label: string, contentBase64: string) => { + let normalized = contentBase64.replace(/\s+/g, ''); + let buffer = Buffer.from(normalized, 'base64'); + let encoded = buffer.toString('base64').replace(/=+$/u, ''); + let input = normalized.replace(/=+$/u, ''); + + if (!normalized || encoded !== input) { + throw elevenLabsServiceError(`${label} must be valid non-empty base64 data.`); + } + + return buffer; +}; + +let appendBase64File = (params: { + formData: FormData; + fieldName: string; + contentBase64: string; + fileName?: string; + contentType?: string; +}) => { + let fileBytes = decodeBase64File(params.fieldName, params.contentBase64); + let blob = new Blob([fileBytes], { + type: params.contentType ?? 'application/octet-stream' + }); + + params.formData.append(params.fieldName, blob, params.fileName ?? 'audio'); +}; + +let mapVoiceSettings = (settings?: VoiceSettings) => { + if (!settings) { + return undefined; + } + + let body: Record = {}; + if (settings.stability !== undefined) body.stability = settings.stability; + if (settings.similarityBoost !== undefined) { + body.similarity_boost = settings.similarityBoost; + } + if (settings.style !== undefined) body.style = settings.style; + if (settings.useSpeakerBoost !== undefined) { + body.use_speaker_boost = settings.useSpeakerBoost; + } + if (settings.speed !== undefined) body.speed = settings.speed; + + return body; +}; + +let mapPronunciationLocators = (locators?: PronunciationDictionaryLocator[]) => + locators?.map(locator => ({ + pronunciation_dictionary_id: locator.pronunciationDictionaryId, + version_id: locator.versionId + })); export class ElevenLabsClient { private axios; @@ -12,87 +144,100 @@ export class ElevenLabsClient { }); } - // ── User & Account ── + private async request(operation: string, run: () => Promise) { + try { + return await run(); + } catch (error) { + throw elevenLabsApiError(error, operation); + } + } async getUser() { - let response = await this.axios.get('/v1/user'); - return response.data; + return this.request('get user', async () => { + let response = await this.axios.get('/v1/user'); + return response.data; + }); } async getSubscription() { - let response = await this.axios.get('/v1/user/subscription'); - return response.data; + return this.request('get subscription', async () => { + let response = await this.axios.get('/v1/user/subscription'); + return response.data; + }); } - // ── Models ── - async listModels() { - let response = await this.axios.get('/v1/models'); - return response.data; + return this.request('list models', async () => { + let response = await this.axios.get('/v1/models'); + return response.data; + }); } - // ── Voices ── - async listVoices(params?: { search?: string; voiceType?: string; category?: string; + fineTuningState?: string; + collectionId?: string; + includeTotalCount?: boolean; + voiceIds?: string[]; pageSize?: number; nextPageToken?: string; sort?: string; sortDirection?: string; }) { - let query: Record = {}; + let query: Record = {}; if (params?.search) query.search = params.search; if (params?.voiceType) query.voice_type = params.voiceType; if (params?.category) query.category = params.category; + if (params?.fineTuningState) query.fine_tuning_state = params.fineTuningState; + if (params?.collectionId) query.collection_id = params.collectionId; + if (params?.includeTotalCount !== undefined) { + query.include_total_count = params.includeTotalCount; + } + if (params?.voiceIds?.length) query.voice_ids = params.voiceIds; if (params?.pageSize) query.page_size = params.pageSize; if (params?.nextPageToken) query.next_page_token = params.nextPageToken; if (params?.sort) query.sort = params.sort; if (params?.sortDirection) query.sort_direction = params.sortDirection; - let response = await this.axios.get('/v2/voices', { params: query }); - return response.data; + return this.request('list voices', async () => { + let response = await this.axios.get('/v2/voices', { params: query }); + return response.data; + }); } async getVoice(voiceId: string) { - let response = await this.axios.get(`/v1/voices/${voiceId}`); - return response.data; + return this.request('get voice', async () => { + let response = await this.axios.get(`/v1/voices/${voiceId}`); + return response.data; + }); } async deleteVoice(voiceId: string) { - let response = await this.axios.delete(`/v1/voices/${voiceId}`); - return response.data; + return this.request('delete voice', async () => { + let response = await this.axios.delete(`/v1/voices/${voiceId}`); + return response.data; + }); } async getVoiceSettings(voiceId: string) { - let response = await this.axios.get(`/v1/voices/${voiceId}/settings`); - return response.data; + return this.request('get voice settings', async () => { + let response = await this.axios.get(`/v1/voices/${voiceId}/settings`); + return response.data; + }); } - async editVoiceSettings( - voiceId: string, - settings: { - stability?: number; - similarityBoost?: number; - style?: number; - useSpeakerBoost?: boolean; - } - ) { - let body: Record = {}; - if (settings.stability !== undefined) body.stability = settings.stability; - if (settings.similarityBoost !== undefined) - body.similarity_boost = settings.similarityBoost; - if (settings.style !== undefined) body.style = settings.style; - if (settings.useSpeakerBoost !== undefined) - body.use_speaker_boost = settings.useSpeakerBoost; - - let response = await this.axios.patch(`/v1/voices/${voiceId}/settings`, body); - return response.data; + async editVoiceSettings(voiceId: string, settings: VoiceSettings) { + return this.request('edit voice settings', async () => { + let response = await this.axios.patch( + `/v1/voices/${voiceId}/settings`, + mapVoiceSettings(settings) ?? {} + ); + return response.data; + }); } - // ── Text to Speech ── - async textToSpeech( voiceId: string, params: { @@ -100,353 +245,373 @@ export class ElevenLabsClient { modelId?: string; languageCode?: string; outputFormat?: string; - voiceSettings?: { - stability?: number; - similarityBoost?: number; - style?: number; - useSpeakerBoost?: boolean; - speed?: number; - }; - pronunciationDictionaryLocators?: Array<{ - pronunciationDictionaryId: string; - versionId: string; - }>; + voiceSettings?: VoiceSettings; + pronunciationDictionaryLocators?: PronunciationDictionaryLocator[]; seed?: number; + previousText?: string; + nextText?: string; applyTextNormalization?: string; } - ) { + ): Promise { let body: Record = { text: params.text }; if (params.modelId) body.model_id = params.modelId; if (params.languageCode) body.language_code = params.languageCode; if (params.seed !== undefined) body.seed = params.seed; - if (params.applyTextNormalization) + if (params.previousText) body.previous_text = params.previousText; + if (params.nextText) body.next_text = params.nextText; + if (params.applyTextNormalization) { body.apply_text_normalization = params.applyTextNormalization; + } if (params.pronunciationDictionaryLocators) { - body.pronunciation_dictionary_locators = params.pronunciationDictionaryLocators.map( - l => ({ - pronunciation_dictionary_id: l.pronunciationDictionaryId, - version_id: l.versionId - }) + body.pronunciation_dictionary_locators = mapPronunciationLocators( + params.pronunciationDictionaryLocators ); } - if (params.voiceSettings) { - let vs: Record = {}; - if (params.voiceSettings.stability !== undefined) - vs.stability = params.voiceSettings.stability; - if (params.voiceSettings.similarityBoost !== undefined) - vs.similarity_boost = params.voiceSettings.similarityBoost; - if (params.voiceSettings.style !== undefined) vs.style = params.voiceSettings.style; - if (params.voiceSettings.useSpeakerBoost !== undefined) - vs.use_speaker_boost = params.voiceSettings.useSpeakerBoost; - if (params.voiceSettings.speed !== undefined) vs.speed = params.voiceSettings.speed; - body.voice_settings = vs; - } + let voiceSettings = mapVoiceSettings(params.voiceSettings); + if (voiceSettings) body.voice_settings = voiceSettings; let query: Record = {}; if (params.outputFormat) query.output_format = params.outputFormat; - let response = await this.axios.post(`/v1/text-to-speech/${voiceId}`, body, { - params: query, - responseType: 'arraybuffer' - }); + return this.request('create speech', async () => { + let response = await this.axios.post(`/v1/text-to-speech/${voiceId}`, body, { + params: query, + responseType: 'arraybuffer' + }); - let audioData = response.data as ArrayBuffer; - let bytes = new Uint8Array(audioData); - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]!); - } - let base64Audio = btoa(binary); + return responseToAudio(response, 'audio/mpeg'); + }); + } - return { - audioBase64: base64Audio, - contentType: String(response.headers?.['content-type'] ?? 'audio/mpeg') + async createDialogue(params: { + inputs: Array<{ text: string; voiceId: string }>; + modelId?: string; + languageCode?: string; + outputFormat?: string; + settings?: VoiceSettings; + }): Promise { + let body: Record = { + inputs: params.inputs.map(input => ({ + text: input.text, + voice_id: input.voiceId + })) }; + if (params.modelId) body.model_id = params.modelId; + if (params.languageCode) body.language_code = params.languageCode; + let settings = mapVoiceSettings(params.settings); + if (settings) body.settings = settings; + + let query: Record = {}; + if (params.outputFormat) query.output_format = params.outputFormat; + + return this.request('create dialogue', async () => { + let response = await this.axios.post('/v1/text-to-dialogue', body, { + params: query, + responseType: 'arraybuffer' + }); + + return responseToAudio(response, 'audio/mpeg'); + }); } - // ── Speech to Text ── + async voiceChanger( + voiceId: string, + params: { + fileBase64: string; + fileName?: string; + modelId?: string; + outputFormat?: string; + voiceSettings?: VoiceSettings; + seed?: number; + removeBackgroundNoise?: boolean; + fileFormat?: 'pcm_s16le_16' | 'other'; + } + ): Promise { + let formData = new FormData(); + appendBase64File({ + formData, + fieldName: 'audio', + contentBase64: params.fileBase64, + fileName: params.fileName + }); + appendFormField(formData, 'model_id', params.modelId); + appendFormField(formData, 'seed', params.seed); + appendFormField(formData, 'remove_background_noise', params.removeBackgroundNoise); + appendFormField(formData, 'file_format', params.fileFormat); + let voiceSettings = mapVoiceSettings(params.voiceSettings); + if (voiceSettings) appendFormField(formData, 'voice_settings', voiceSettings); + + let query: Record = {}; + if (params.outputFormat) query.output_format = params.outputFormat; + + return this.request('voice changer', async () => { + let response = await this.axios.post(`/v1/speech-to-speech/${voiceId}`, formData, { + params: query, + responseType: 'arraybuffer' + }); + + return responseToAudio(response, 'audio/mpeg'); + }); + } async speechToText(params: { modelId: string; fileBase64?: string; fileName?: string; + sourceUrl?: string; cloudStorageUrl?: string; languageCode?: string; diarize?: boolean; numSpeakers?: number; timestampsGranularity?: string; tagAudioEvents?: boolean; + diarizationThreshold?: number; + fileFormat?: 'pcm_s16le_16' | 'other'; + temperature?: number; + seed?: number; + useMultiChannel?: boolean; }) { - let boundary = `----SlatesBoundary${Date.now().toString(36)}`; - let parts: string[] = []; - - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="model_id"\r\n\r\n${params.modelId}` - ); - - if (params.cloudStorageUrl) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="cloud_storage_url"\r\n\r\n${params.cloudStorageUrl}` - ); - } - - if (params.languageCode) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="language_code"\r\n\r\n${params.languageCode}` - ); - } + let formData = new FormData(); + appendFormField(formData, 'model_id', params.modelId); + appendFormField(formData, 'language_code', params.languageCode); + appendFormField(formData, 'diarize', params.diarize); + appendFormField(formData, 'num_speakers', params.numSpeakers); + appendFormField(formData, 'timestamps_granularity', params.timestampsGranularity); + appendFormField(formData, 'tag_audio_events', params.tagAudioEvents); + appendFormField(formData, 'diarization_threshold', params.diarizationThreshold); + appendFormField(formData, 'file_format', params.fileFormat); + appendFormField(formData, 'temperature', params.temperature); + appendFormField(formData, 'seed', params.seed); + appendFormField(formData, 'use_multi_channel', params.useMultiChannel); - if (params.diarize !== undefined) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="diarize"\r\n\r\n${params.diarize}` - ); - } - - if (params.numSpeakers !== undefined) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="num_speakers"\r\n\r\n${params.numSpeakers}` - ); - } - - if (params.timestampsGranularity) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="timestamps_granularity"\r\n\r\n${params.timestampsGranularity}` - ); - } - - if (params.tagAudioEvents !== undefined) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="tag_audio_events"\r\n\r\n${params.tagAudioEvents}` - ); - } - - if (params.fileBase64) { - let binaryStr = atob(params.fileBase64); - let fileName = params.fileName || 'audio.mp3'; - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: binary\r\n\r\n${binaryStr}` - ); + if (params.sourceUrl) { + appendFormField(formData, 'source_url', params.sourceUrl); + } else if (params.cloudStorageUrl) { + appendFormField(formData, 'cloud_storage_url', params.cloudStorageUrl); + } else if (params.fileBase64) { + appendBase64File({ + formData, + fieldName: 'file', + contentBase64: params.fileBase64, + fileName: params.fileName + }); } - let bodyStr = `${parts.join('\r\n')}\r\n--${boundary}--`; - - let response = await this.axios.post('/v1/speech-to-text', bodyStr, { - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}` - } + return this.request('create transcript', async () => { + let response = await this.axios.post('/v1/speech-to-text', formData); + return response.data; }); - - return response.data; } - // ── Sound Effects ── - async generateSoundEffect(params: { text: string; durationSeconds?: number; loop?: boolean; + promptInfluence?: number; + modelId?: string; outputFormat?: string; - }) { + }): Promise { let body: Record = { text: params.text }; - if (params.durationSeconds !== undefined) body.duration_seconds = params.durationSeconds; + if (params.durationSeconds !== undefined) { + body.duration_seconds = params.durationSeconds; + } if (params.loop !== undefined) body.loop = params.loop; + if (params.promptInfluence !== undefined) body.prompt_influence = params.promptInfluence; + if (params.modelId) body.model_id = params.modelId; let query: Record = {}; if (params.outputFormat) query.output_format = params.outputFormat; - let response = await this.axios.post('/v1/sound-generation', body, { - params: query, - responseType: 'arraybuffer' - }); + return this.request('create sound effect', async () => { + let response = await this.axios.post('/v1/sound-generation', body, { + params: query, + responseType: 'arraybuffer' + }); - let audioData = response.data as ArrayBuffer; - let bytes = new Uint8Array(audioData); - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]!); - } - let base64Audio = btoa(binary); - - return { - audioBase64: base64Audio, - contentType: String(response.headers?.['content-type'] ?? 'audio/mpeg') - }; + return responseToAudio(response, 'audio/mpeg'); + }); } - // ── Music Generation ── - async composeMusic(params: { - prompt?: string; + prompt: string; musicLengthMs?: number; + modelId?: string; + forceInstrumental?: boolean; outputFormat?: string; - }) { - let body: Record = {}; - if (params.prompt) body.prompt = params.prompt; + }): Promise { + let body: Record = { + prompt: params.prompt + }; if (params.musicLengthMs !== undefined) body.music_length_ms = params.musicLengthMs; + if (params.modelId) body.model_id = params.modelId; + if (params.forceInstrumental !== undefined) { + body.force_instrumental = params.forceInstrumental; + } let query: Record = {}; if (params.outputFormat) query.output_format = params.outputFormat; - let response = await this.axios.post('/v1/music/compose', body, { - params: query, - responseType: 'arraybuffer', - timeout: 300000 - }); - - let audioData = response.data as ArrayBuffer; - let bytes = new Uint8Array(audioData); - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]!); - } - let base64Audio = btoa(binary); + return this.request('compose music', async () => { + let response = await this.axios.post('/v1/music', body, { + params: query, + responseType: 'arraybuffer', + timeout: 300000 + }); - return { - audioBase64: base64Audio, - contentType: String(response.headers?.['content-type'] ?? 'audio/mpeg') - }; + return responseToAudio(response, 'audio/mpeg'); + }); } - // ── Dubbing ── - async createDubbing(params: { - sourceUrl?: string; + sourceUrl: string; sourceLang?: string; targetLang: string; + targetAccent?: string; numSpeakers?: number; watermark?: boolean; name?: string; + startTime?: number; + endTime?: number; highestResolution?: boolean; }) { - let boundary = `----SlatesBoundary${Date.now().toString(36)}`; - let parts: string[] = []; - - if (params.sourceUrl) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="source_url"\r\n\r\n${params.sourceUrl}` - ); - } - if (params.sourceLang) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="source_lang"\r\n\r\n${params.sourceLang}` - ); - } - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="target_lang"\r\n\r\n${params.targetLang}` - ); - if (params.numSpeakers !== undefined) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="num_speakers"\r\n\r\n${params.numSpeakers}` - ); - } - if (params.watermark !== undefined) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="watermark"\r\n\r\n${params.watermark}` - ); - } - if (params.name) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="name"\r\n\r\n${params.name}` - ); - } - if (params.highestResolution !== undefined) { - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="highest_resolution"\r\n\r\n${params.highestResolution}` - ); - } - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="mode"\r\n\r\nautomatic` - ); - - let bodyStr = `${parts.join('\r\n')}\r\n--${boundary}--`; - - let response = await this.axios.post('/v1/dubbing', bodyStr, { - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}` - } + let formData = new FormData(); + appendFormField(formData, 'source_url', params.sourceUrl); + appendFormField(formData, 'source_lang', params.sourceLang); + appendFormField(formData, 'target_lang', params.targetLang); + appendFormField(formData, 'target_accent', params.targetAccent); + appendFormField(formData, 'num_speakers', params.numSpeakers); + appendFormField(formData, 'watermark', params.watermark); + appendFormField(formData, 'name', params.name); + appendFormField(formData, 'start_time', params.startTime); + appendFormField(formData, 'end_time', params.endTime); + appendFormField(formData, 'highest_resolution', params.highestResolution); + appendFormField(formData, 'mode', 'automatic'); + + return this.request('create dubbing', async () => { + let response = await this.axios.post('/v1/dubbing', formData); + return response.data; }); - - return response.data; } async getDubbing(dubbingId: string) { - let response = await this.axios.get(`/v1/dubbing/${dubbingId}`); - return response.data; + return this.request('get dubbing', async () => { + let response = await this.axios.get(`/v1/dubbing/${dubbingId}`); + return response.data; + }); } - // ── Audio Isolation ── + async deleteDubbing(dubbingId: string) { + return this.request('delete dubbing', async () => { + let response = await this.axios.delete(`/v1/dubbing/${dubbingId}`); + return response.data; + }); + } - async isolateAudio(fileBase64: string, fileName?: string) { - let boundary = `----SlatesBoundary${Date.now().toString(36)}`; - let binaryStr = atob(fileBase64); - let fName = fileName || 'audio.mp3'; + async isolateAudio(params: { + fileBase64: string; + fileName?: string; + fileFormat?: 'pcm_s16le_16' | 'other'; + previewBase64?: string; + }): Promise { + let formData = new FormData(); + appendBase64File({ + formData, + fieldName: 'audio', + contentBase64: params.fileBase64, + fileName: params.fileName + }); + appendFormField(formData, 'file_format', params.fileFormat); + appendFormField(formData, 'preview_b64', params.previewBase64); - let bodyStr = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fName}"\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: binary\r\n\r\n${binaryStr}\r\n--${boundary}--`; + return this.request('audio isolation', async () => { + let response = await this.axios.post('/v1/audio-isolation', formData, { + responseType: 'arraybuffer' + }); - let response = await this.axios.post('/v1/audio-isolation', bodyStr, { - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}` - }, - responseType: 'arraybuffer' + return responseToAudio(response, 'audio/mpeg'); }); + } - let audioData = response.data as ArrayBuffer; - let bytes = new Uint8Array(audioData); - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]!); - } - let base64Audio = btoa(binary); + async createForcedAlignment(params: { + fileBase64: string; + fileName?: string; + text: string; + }) { + let formData = new FormData(); + appendBase64File({ + formData, + fieldName: 'file', + contentBase64: params.fileBase64, + fileName: params.fileName + }); + appendFormField(formData, 'text', params.text); - return { - audioBase64: base64Audio, - contentType: String(response.headers?.['content-type'] ?? 'audio/mpeg') - }; + return this.request('create forced alignment', async () => { + let response = await this.axios.post('/v1/forced-alignment', formData); + return response.data; + }); } - // ── History ── - async listHistory(params?: { pageSize?: number; startAfterHistoryItemId?: string }) { let query: Record = {}; if (params?.pageSize) query.page_size = params.pageSize; - if (params?.startAfterHistoryItemId) + if (params?.startAfterHistoryItemId) { query.start_after_history_item_id = params.startAfterHistoryItemId; + } - let response = await this.axios.get('/v1/history', { params: query }); - return response.data; + return this.request('list history', async () => { + let response = await this.axios.get('/v1/history', { params: query }); + return response.data; + }); } async getHistoryItem(historyItemId: string) { - let response = await this.axios.get(`/v1/history/${historyItemId}`); - return response.data; + return this.request('get history item', async () => { + let response = await this.axios.get(`/v1/history/${historyItemId}`); + return response.data; + }); } - // ── Webhooks ── + async getHistoryAudio(historyItemId: string): Promise { + return this.request('get history audio', async () => { + let response = await this.axios.get(`/v1/history/${historyItemId}/audio`, { + responseType: 'arraybuffer' + }); + + return responseToAudio(response, 'audio/mpeg'); + }); + } async createWebhook(params: { name: string; webhookUrl: string }) { - let response = await this.axios.post('/v1/workspace/webhooks', { - settings: { - auth_type: 'hmac', - name: params.name, - webhook_url: params.webhookUrl - } + return this.request('create webhook', async () => { + let response = await this.axios.post('/v1/workspace/webhooks', { + settings: { + auth_type: 'hmac', + name: params.name, + webhook_url: params.webhookUrl + } + }); + return response.data; }); - return response.data; } async listWebhooks() { - let response = await this.axios.get('/v1/workspace/webhooks', { - params: { include_usages: true } + return this.request('list webhooks', async () => { + let response = await this.axios.get('/v1/workspace/webhooks', { + params: { include_usages: true } + }); + return response.data; }); - return response.data; } async deleteWebhook(webhookId: string) { - let response = await this.axios.delete(`/v1/workspace/webhooks/${webhookId}`); - return response.data; + return this.request('delete webhook', async () => { + let response = await this.axios.delete(`/v1/workspace/webhooks/${webhookId}`); + return response.data; + }); } } diff --git a/integrations/eleven-labs/src/lib/errors.ts b/integrations/eleven-labs/src/lib/errors.ts new file mode 100644 index 0000000000..cbcfe25c97 --- /dev/null +++ b/integrations/eleven-labs/src/lib/errors.ts @@ -0,0 +1,96 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.detail); + pushDetail(details, value.title); + pushDetail(details, value.error); + pushDetail(details, value.reason); + pushDetail(details, value.code); + collectDetails(value.errors, details); +}; + +let extractElevenLabsMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + return typeof response.data.code === 'string' ? response.data.code : undefined; +}; + +export let elevenLabsServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let elevenLabsApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = elevenLabsServiceError( + `ElevenLabs API ${operation} failed: ${statusLabelFor(response)}${extractElevenLabsMessage(error)}` + ); + serviceError.data.reason = 'elevenlabs_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/eleven-labs/src/tools.schema.test.ts b/integrations/eleven-labs/src/tools.schema.test.ts new file mode 100644 index 0000000000..f764c4a160 --- /dev/null +++ b/integrations/eleven-labs/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('ElevenLabs tool input schemas', provider.actions); diff --git a/integrations/eleven-labs/src/tools/compose-music.ts b/integrations/eleven-labs/src/tools/compose-music.ts index b721c02287..1e669f16cf 100644 --- a/integrations/eleven-labs/src/tools/compose-music.ts +++ b/integrations/eleven-labs/src/tools/compose-music.ts @@ -2,11 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { ElevenLabsClient } from '../lib/client'; import { spec } from '../spec'; +import { audioAttachment, audioOutput, audioOutputSchema } from './shared'; export let composeMusic = SlateTool.create(spec, { name: 'Compose Music', key: 'compose_music', - description: `Generate music from a text prompt describing genre, mood, style, and optionally lyrics. Returns base64-encoded audio. This is a batch operation and may take longer for longer compositions.`, + description: `Generate music from a text prompt describing genre, mood, style, and optionally lyrics. Returns generated audio as a Slate attachment. This is a batch operation and may take longer for longer compositions.`, instructions: [ 'Describe the genre, mood, instruments, and style in the prompt for best results.', 'Include lyrics with structure markers (e.g. [Verse], [Chorus]) to generate vocal tracks.', @@ -35,21 +36,23 @@ export let composeMusic = SlateTool.create(spec, { .max(600000) .optional() .describe('Desired length of the music in milliseconds (3000-600000)'), + modelId: z.string().optional().describe('Music model ID. Defaults to music_v1.'), + forceInstrumental: z + .boolean() + .optional() + .describe('Force the generated song to be instrumental'), outputFormat: z.string().optional().describe('Audio output format, e.g. "mp3_44100_128"') }) ) - .output( - z.object({ - audioBase64: z.string().describe('Base64-encoded audio data'), - contentType: z.string().describe('MIME type of the audio') - }) - ) + .output(audioOutputSchema) .handleInvocation(async ctx => { let client = new ElevenLabsClient(ctx.auth.token); let result = await client.composeMusic({ prompt: ctx.input.prompt, musicLengthMs: ctx.input.musicLengthMs, + modelId: ctx.input.modelId, + forceInstrumental: ctx.input.forceInstrumental, outputFormat: ctx.input.outputFormat }); @@ -60,7 +63,8 @@ export let composeMusic = SlateTool.create(spec, { : ''; return { - output: result, + output: audioOutput(result), + attachments: [audioAttachment(result)], message: `Composed music: "${promptPreview}"${durationInfo}.` }; }) diff --git a/integrations/eleven-labs/src/tools/create-dialogue.ts b/integrations/eleven-labs/src/tools/create-dialogue.ts new file mode 100644 index 0000000000..aa34bea476 --- /dev/null +++ b/integrations/eleven-labs/src/tools/create-dialogue.ts @@ -0,0 +1,78 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { ElevenLabsClient } from '../lib/client'; +import { elevenLabsServiceError } from '../lib/errors'; +import { spec } from '../spec'; +import { + audioAttachment, + audioOutput, + audioOutputSchema, + voiceSettingsSchema +} from './shared'; + +export let createDialogue = SlateTool.create(spec, { + name: 'Create Dialogue', + key: 'create_dialogue', + description: `Convert multiple text and voice pairs into a single natural-sounding dialogue audio file. Returns generated audio as a Slate attachment.`, + instructions: [ + 'Use list_voices first when you need voice IDs.', + 'Keep total dialogue text at or below 2,000 characters for reliable generation.' + ], + constraints: ['Maximum 10 unique voice IDs per request.'], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + inputs: z + .array( + z.object({ + text: z.string().describe('Dialogue text for this turn'), + voiceId: z.string().describe('Voice ID for this turn') + }) + ) + .min(1) + .describe('Dialogue turns to synthesize in order'), + modelId: z.string().optional().describe('Dialogue model ID. Defaults to eleven_v3.'), + languageCode: z + .string() + .optional() + .describe('ISO 639-1 language code to enforce for the model'), + outputFormat: z.string().optional().describe('Audio output format, e.g. mp3_44100_128'), + settings: voiceSettingsSchema + .optional() + .describe('Dialogue generation settings for this request') + }) + ) + .output(audioOutputSchema) + .handleInvocation(async ctx => { + let uniqueVoiceIds = new Set(ctx.input.inputs.map(input => input.voiceId)); + if (uniqueVoiceIds.size > 10) { + throw elevenLabsServiceError('create_dialogue supports at most 10 unique voice IDs.'); + } + + let totalCharacters = ctx.input.inputs.reduce((sum, input) => sum + input.text.length, 0); + if (totalCharacters > 2000) { + throw elevenLabsServiceError( + 'create_dialogue input text must be 2,000 characters or fewer across all turns.' + ); + } + + let client = new ElevenLabsClient(ctx.auth.token); + let result = await client.createDialogue({ + inputs: ctx.input.inputs, + modelId: ctx.input.modelId, + languageCode: ctx.input.languageCode, + outputFormat: ctx.input.outputFormat, + settings: ctx.input.settings + }); + + return { + output: audioOutput(result), + attachments: [audioAttachment(result)], + message: `Generated dialogue audio with ${ctx.input.inputs.length} turn(s) and ${uniqueVoiceIds.size} voice(s).` + }; + }) + .build(); diff --git a/integrations/eleven-labs/src/tools/create-dubbing.ts b/integrations/eleven-labs/src/tools/create-dubbing.ts index a335f8b9a2..5a1457a305 100644 --- a/integrations/eleven-labs/src/tools/create-dubbing.ts +++ b/integrations/eleven-labs/src/tools/create-dubbing.ts @@ -29,12 +29,18 @@ export let createDubbing = SlateTool.create(spec, { .string() .optional() .describe('Source language code. Defaults to auto-detection.'), + targetAccent: z + .string() + .optional() + .describe('Experimental target accent preference for translation and voice selection'), name: z.string().optional().describe('Name for the dubbing project'), numSpeakers: z .number() .optional() .describe('Number of speakers (0 for auto-detection, recommended max 9)'), watermark: z.boolean().optional().describe('Apply a watermark to the output video'), + startTime: z.number().optional().describe('Start time of the source media in seconds'), + endTime: z.number().optional().describe('End time of the source media in seconds'), highestResolution: z .boolean() .optional() @@ -43,7 +49,11 @@ export let createDubbing = SlateTool.create(spec, { ) .output( z.object({ - dubbingId: z.string().describe('ID of the created dubbing project') + dubbingId: z.string().describe('ID of the created dubbing project'), + expectedDurationSeconds: z + .number() + .optional() + .describe('Expected source media duration in seconds') }) ) .handleInvocation(async ctx => { @@ -53,9 +63,12 @@ export let createDubbing = SlateTool.create(spec, { sourceUrl: ctx.input.sourceUrl, targetLang: ctx.input.targetLang, sourceLang: ctx.input.sourceLang, + targetAccent: ctx.input.targetAccent, name: ctx.input.name, numSpeakers: ctx.input.numSpeakers, watermark: ctx.input.watermark, + startTime: ctx.input.startTime, + endTime: ctx.input.endTime, highestResolution: ctx.input.highestResolution }); @@ -63,7 +76,8 @@ export let createDubbing = SlateTool.create(spec, { return { output: { - dubbingId: data.dubbing_id as string + dubbingId: data.dubbing_id as string, + expectedDurationSeconds: data.expected_duration_sec as number | undefined }, message: `Created dubbing project \`${data.dubbing_id}\` — translating to **${ctx.input.targetLang}**${ctx.input.name ? ` ("${ctx.input.name}")` : ''}.` }; diff --git a/integrations/eleven-labs/src/tools/create-forced-alignment.ts b/integrations/eleven-labs/src/tools/create-forced-alignment.ts new file mode 100644 index 0000000000..d0f2ac2898 --- /dev/null +++ b/integrations/eleven-labs/src/tools/create-forced-alignment.ts @@ -0,0 +1,71 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { ElevenLabsClient } from '../lib/client'; +import { spec } from '../spec'; + +let alignedCharacterSchema = z.object({ + text: z.string().describe('Aligned character text'), + start: z.number().optional().describe('Start time in seconds'), + end: z.number().optional().describe('End time in seconds') +}); + +let alignedWordSchema = z.object({ + text: z.string().describe('Aligned word text'), + start: z.number().optional().describe('Start time in seconds'), + end: z.number().optional().describe('End time in seconds'), + loss: z.number().optional().describe('Alignment loss for this word') +}); + +export let createForcedAlignment = SlateTool.create(spec, { + name: 'Create Forced Alignment', + key: 'create_forced_alignment', + description: `Align an audio file to a provided transcript and return character and word timing information.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + fileBase64: z.string().describe('Base64-encoded audio file to align'), + fileName: z.string().optional().describe('Original filename for the audio'), + text: z.string().describe('Transcript text to align with the audio') + }) + ) + .output( + z.object({ + characters: z.array(alignedCharacterSchema).describe('Character-level timings'), + words: z.array(alignedWordSchema).describe('Word-level timings'), + loss: z.number().optional().describe('Average alignment loss/confidence score') + }) + ) + .handleInvocation(async ctx => { + let client = new ElevenLabsClient(ctx.auth.token); + let result = (await client.createForcedAlignment({ + fileBase64: ctx.input.fileBase64, + fileName: ctx.input.fileName, + text: ctx.input.text + })) as Record; + + let rawCharacters = (result.characters || []) as Record[]; + let rawWords = (result.words || []) as Record[]; + + return { + output: { + characters: rawCharacters.map(character => ({ + text: String(character.text ?? ''), + start: character.start as number | undefined, + end: character.end as number | undefined + })), + words: rawWords.map(word => ({ + text: String(word.text ?? ''), + start: word.start as number | undefined, + end: word.end as number | undefined, + loss: word.loss as number | undefined + })), + loss: result.loss as number | undefined + }, + message: `Aligned audio to ${ctx.input.text.length} transcript character(s).` + }; + }) + .build(); diff --git a/integrations/eleven-labs/src/tools/delete-dubbing.ts b/integrations/eleven-labs/src/tools/delete-dubbing.ts new file mode 100644 index 0000000000..9232ee9de4 --- /dev/null +++ b/integrations/eleven-labs/src/tools/delete-dubbing.ts @@ -0,0 +1,38 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { ElevenLabsClient } from '../lib/client'; +import { spec } from '../spec'; + +export let deleteDubbing = SlateTool.create(spec, { + name: 'Delete Dubbing', + key: 'delete_dubbing', + description: `Delete an ElevenLabs dubbing project by ID.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + dubbingId: z.string().describe('ID of the dubbing project to delete') + }) + ) + .output( + z.object({ + status: z.string().optional().describe('Provider deletion status'), + success: z.boolean().describe('Whether the deletion request succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new ElevenLabsClient(ctx.auth.token); + let result = (await client.deleteDubbing(ctx.input.dubbingId)) as Record; + + return { + output: { + status: result.status as string | undefined, + success: true + }, + message: `Deleted dubbing project \`${ctx.input.dubbingId}\`.` + }; + }) + .build(); diff --git a/integrations/eleven-labs/src/tools/edit-voice-settings.ts b/integrations/eleven-labs/src/tools/edit-voice-settings.ts index 1a09caacda..13ebcd2439 100644 --- a/integrations/eleven-labs/src/tools/edit-voice-settings.ts +++ b/integrations/eleven-labs/src/tools/edit-voice-settings.ts @@ -38,7 +38,13 @@ export let editVoiceSettings = SlateTool.create(spec, { useSpeakerBoost: z .boolean() .optional() - .describe('Enable speaker boost for enhanced clarity') + .describe('Enable speaker boost for enhanced clarity'), + speed: z + .number() + .min(0.25) + .max(4.0) + .optional() + .describe('Speed multiplier (0.25-4.0). 1.0 is normal speed.') }) ) .output( @@ -53,7 +59,8 @@ export let editVoiceSettings = SlateTool.create(spec, { stability: ctx.input.stability, similarityBoost: ctx.input.similarityBoost, style: ctx.input.style, - useSpeakerBoost: ctx.input.useSpeakerBoost + useSpeakerBoost: ctx.input.useSpeakerBoost, + speed: ctx.input.speed }); return { diff --git a/integrations/eleven-labs/src/tools/generate-sound-effect.ts b/integrations/eleven-labs/src/tools/generate-sound-effect.ts index 8361921ded..739ad4e79f 100644 --- a/integrations/eleven-labs/src/tools/generate-sound-effect.ts +++ b/integrations/eleven-labs/src/tools/generate-sound-effect.ts @@ -2,11 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { ElevenLabsClient } from '../lib/client'; import { spec } from '../spec'; +import { audioAttachment, audioOutput, audioOutputSchema } from './shared'; export let generateSoundEffect = SlateTool.create(spec, { name: 'Generate Sound Effect', key: 'generate_sound_effect', - description: `Create sound effects from text descriptions. Describe the desired sound using natural language or audio terminology to generate cinematic sound effects, Foley, ambient sounds, and more. Returns base64-encoded audio.`, + description: `Create sound effects from text descriptions. Describe the desired sound using natural language or audio terminology to generate cinematic sound effects, Foley, ambient sounds, and more. Returns generated audio as a Slate attachment.`, instructions: [ 'Be descriptive in the text prompt - include genre, mood, environment details for best results.', 'Enable "loop" for continuous background sounds or ambient textures.' @@ -30,15 +31,20 @@ export let generateSoundEffect = SlateTool.create(spec, { .optional() .describe('Duration of the sound effect in seconds (0.5-30)'), loop: z.boolean().optional().describe('Create a seamlessly looping sound effect'), + promptInfluence: z + .number() + .min(0) + .max(1) + .optional() + .describe('How closely the generation should follow the prompt (0-1)'), + modelId: z + .string() + .optional() + .describe('Sound generation model ID. Defaults to eleven_text_to_sound_v2.'), outputFormat: z.string().optional().describe('Audio output format, e.g. "mp3_44100_128"') }) ) - .output( - z.object({ - audioBase64: z.string().describe('Base64-encoded audio data'), - contentType: z.string().describe('MIME type of the audio') - }) - ) + .output(audioOutputSchema) .handleInvocation(async ctx => { let client = new ElevenLabsClient(ctx.auth.token); @@ -46,6 +52,8 @@ export let generateSoundEffect = SlateTool.create(spec, { text: ctx.input.text, durationSeconds: ctx.input.durationSeconds, loop: ctx.input.loop, + promptInfluence: ctx.input.promptInfluence, + modelId: ctx.input.modelId, outputFormat: ctx.input.outputFormat }); @@ -53,7 +61,8 @@ export let generateSoundEffect = SlateTool.create(spec, { ctx.input.text.length > 80 ? `${ctx.input.text.slice(0, 80)}...` : ctx.input.text; return { - output: result, + output: audioOutput(result), + attachments: [audioAttachment(result)], message: `Generated sound effect: "${textPreview}"${ctx.input.durationSeconds ? ` (${ctx.input.durationSeconds}s)` : ''}${ctx.input.loop ? ' (looping)' : ''}.` }; }) diff --git a/integrations/eleven-labs/src/tools/get-history-audio.ts b/integrations/eleven-labs/src/tools/get-history-audio.ts new file mode 100644 index 0000000000..6eb9de3303 --- /dev/null +++ b/integrations/eleven-labs/src/tools/get-history-audio.ts @@ -0,0 +1,35 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { ElevenLabsClient } from '../lib/client'; +import { spec } from '../spec'; +import { audioAttachment, audioOutput, audioOutputSchema } from './shared'; + +export let getHistoryAudio = SlateTool.create(spec, { + name: 'Get History Audio', + key: 'get_history_audio', + description: `Download the audio for a generated history item. Returns the audio file as a Slate attachment.`, + instructions: ['Use list_history first to find a historyItemId.'], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + historyItemId: z + .string() + .describe('ID of the history item whose audio should be returned') + }) + ) + .output(audioOutputSchema) + .handleInvocation(async ctx => { + let client = new ElevenLabsClient(ctx.auth.token); + let result = await client.getHistoryAudio(ctx.input.historyItemId); + + return { + output: audioOutput(result), + attachments: [audioAttachment(result)], + message: `Retrieved audio for history item \`${ctx.input.historyItemId}\`.` + }; + }) + .build(); diff --git a/integrations/eleven-labs/src/tools/get-voice.ts b/integrations/eleven-labs/src/tools/get-voice.ts index c7ecbc9c41..153595f8d7 100644 --- a/integrations/eleven-labs/src/tools/get-voice.ts +++ b/integrations/eleven-labs/src/tools/get-voice.ts @@ -30,7 +30,8 @@ export let getVoice = SlateTool.create(spec, { stability: z.number().optional().describe('Voice stability setting'), similarityBoost: z.number().optional().describe('Similarity boost setting'), style: z.number().optional().describe('Style exaggeration setting'), - useSpeakerBoost: z.boolean().optional().describe('Speaker boost enabled') + useSpeakerBoost: z.boolean().optional().describe('Speaker boost enabled'), + speed: z.number().optional().describe('Voice speed setting') }) .optional() .describe('Current voice settings') @@ -54,7 +55,8 @@ export let getVoice = SlateTool.create(spec, { stability: settingsData.stability as number | undefined, similarityBoost: settingsData.similarity_boost as number | undefined, style: settingsData.style as number | undefined, - useSpeakerBoost: settingsData.use_speaker_boost as boolean | undefined + useSpeakerBoost: settingsData.use_speaker_boost as boolean | undefined, + speed: settingsData.speed as number | undefined } : undefined }, diff --git a/integrations/eleven-labs/src/tools/index.ts b/integrations/eleven-labs/src/tools/index.ts index e012aecd19..65d88bee87 100644 --- a/integrations/eleven-labs/src/tools/index.ts +++ b/integrations/eleven-labs/src/tools/index.ts @@ -1,10 +1,14 @@ export * from './compose-music'; +export * from './create-dialogue'; export * from './create-dubbing'; +export * from './create-forced-alignment'; +export * from './delete-dubbing'; export * from './delete-voice'; export * from './edit-voice-settings'; export * from './generate-sound-effect'; export * from './get-account'; export * from './get-dubbing'; +export * from './get-history-audio'; export * from './get-voice'; export * from './isolate-audio'; export * from './list-history'; @@ -12,3 +16,4 @@ export * from './list-models'; export * from './list-voices'; export * from './speech-to-text'; export * from './text-to-speech'; +export * from './voice-changer'; diff --git a/integrations/eleven-labs/src/tools/isolate-audio.ts b/integrations/eleven-labs/src/tools/isolate-audio.ts index f5eb70581d..6c822a7ae4 100644 --- a/integrations/eleven-labs/src/tools/isolate-audio.ts +++ b/integrations/eleven-labs/src/tools/isolate-audio.ts @@ -2,11 +2,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { ElevenLabsClient } from '../lib/client'; import { spec } from '../spec'; +import { audioAttachment, audioOutput, audioOutputSchema } from './shared'; export let isolateAudio = SlateTool.create(spec, { name: 'Isolate Audio', key: 'isolate_audio', - description: `Remove background noise from audio and isolate vocal tracks. Takes a base64-encoded audio file and returns cleaned audio with background noise, music, and ambient sounds removed.`, + description: `Remove background noise from audio and isolate vocal tracks. Takes a base64-encoded audio file and returns cleaned audio as a Slate attachment.`, tags: { destructive: false, readOnly: false @@ -15,22 +16,31 @@ export let isolateAudio = SlateTool.create(spec, { .input( z.object({ fileBase64: z.string().describe('Base64-encoded audio file to process'), - fileName: z.string().optional().describe('Original filename for format detection') - }) - ) - .output( - z.object({ - audioBase64: z.string().describe('Base64-encoded isolated audio data'), - contentType: z.string().describe('MIME type of the output audio') + fileName: z.string().optional().describe('Original filename for format detection'), + fileFormat: z + .enum(['pcm_s16le_16', 'other']) + .optional() + .describe('Input format. Use pcm_s16le_16 for raw 16-bit 16kHz mono PCM.'), + previewBase64: z + .string() + .optional() + .describe('Optional preview image base64 for tracking this generation') }) ) + .output(audioOutputSchema) .handleInvocation(async ctx => { let client = new ElevenLabsClient(ctx.auth.token); - let result = await client.isolateAudio(ctx.input.fileBase64, ctx.input.fileName); + let result = await client.isolateAudio({ + fileBase64: ctx.input.fileBase64, + fileName: ctx.input.fileName, + fileFormat: ctx.input.fileFormat, + previewBase64: ctx.input.previewBase64 + }); return { - output: result, + output: audioOutput(result), + attachments: [audioAttachment(result)], message: `Isolated vocals from audio${ctx.input.fileName ? ` (${ctx.input.fileName})` : ''}.` }; }) diff --git a/integrations/eleven-labs/src/tools/list-voices.ts b/integrations/eleven-labs/src/tools/list-voices.ts index e9e585d96d..6c2f27536e 100644 --- a/integrations/eleven-labs/src/tools/list-voices.ts +++ b/integrations/eleven-labs/src/tools/list-voices.ts @@ -31,13 +31,44 @@ export let listVoices = SlateTool.create(spec, { .optional() .describe('Search term to filter voices by name, description, labels, or category'), voiceType: z - .enum(['personal', 'community', 'default', 'workspace', 'non-default', 'saved']) + .enum([ + 'personal', + 'community', + 'default', + 'workspace', + 'non-default', + 'non-community', + 'saved' + ]) .optional() .describe('Filter voices by type'), category: z .enum(['premade', 'cloned', 'generated', 'professional']) .optional() .describe('Filter voices by category'), + fineTuningState: z + .enum([ + 'draft', + 'not_verified', + 'not_started', + 'queued', + 'fine_tuning', + 'fine_tuned', + 'failed', + 'delayed' + ]) + .optional() + .describe('Filter professional voice clones by fine-tuning state'), + collectionId: z.string().optional().describe('Collection ID to filter voices by'), + includeTotalCount: z + .boolean() + .optional() + .describe('Whether to include totalCount. Defaults to the provider behavior.'), + voiceIds: z + .array(z.string()) + .max(100) + .optional() + .describe('Specific voice IDs to look up, maximum 100'), pageSize: z .number() .min(1) @@ -70,6 +101,10 @@ export let listVoices = SlateTool.create(spec, { search: ctx.input.search, voiceType: ctx.input.voiceType, category: ctx.input.category, + fineTuningState: ctx.input.fineTuningState, + collectionId: ctx.input.collectionId, + includeTotalCount: ctx.input.includeTotalCount, + voiceIds: ctx.input.voiceIds, pageSize: ctx.input.pageSize, nextPageToken: ctx.input.nextPageToken, sort: ctx.input.sort, diff --git a/integrations/eleven-labs/src/tools/shared.ts b/integrations/eleven-labs/src/tools/shared.ts new file mode 100644 index 0000000000..cefa4f378a --- /dev/null +++ b/integrations/eleven-labs/src/tools/shared.ts @@ -0,0 +1,51 @@ +import { createBase64Attachment } from 'slates'; +import { z } from 'zod'; +import type { AudioResult } from '../lib/client'; + +export let voiceSettingsSchema = z.object({ + stability: z + .number() + .min(0) + .max(1) + .optional() + .describe('Voice stability (0-1). Lower values add more variability.'), + similarityBoost: z + .number() + .min(0) + .max(1) + .optional() + .describe( + 'Similarity boost (0-1). Higher values are more faithful to the original voice.' + ), + style: z + .number() + .min(0) + .max(1) + .optional() + .describe('Style exaggeration (0-1). Higher values amplify the voice style.'), + useSpeakerBoost: z + .boolean() + .optional() + .describe('Enable speaker boost for enhanced clarity'), + speed: z + .number() + .min(0.25) + .max(4.0) + .optional() + .describe('Speed multiplier (0.25-4.0). 1.0 is normal speed.') +}); + +export let audioOutputSchema = z.object({ + contentType: z.string().describe('MIME type of the returned audio attachment'), + byteLength: z.number().describe('Decoded audio byte length'), + attachmentCount: z.number().describe('Number of audio attachments returned') +}); + +export let audioOutput = (result: AudioResult) => ({ + contentType: result.contentType, + byteLength: result.byteLength, + attachmentCount: 1 +}); + +export let audioAttachment = (result: AudioResult) => + createBase64Attachment(result.contentBase64, result.contentType); diff --git a/integrations/eleven-labs/src/tools/speech-to-text.ts b/integrations/eleven-labs/src/tools/speech-to-text.ts index 34f6cba635..9caa5ce7cc 100644 --- a/integrations/eleven-labs/src/tools/speech-to-text.ts +++ b/integrations/eleven-labs/src/tools/speech-to-text.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { ElevenLabsClient } from '../lib/client'; +import { elevenLabsServiceError } from '../lib/errors'; import { spec } from '../spec'; let wordSchema = z.object({ @@ -14,9 +15,9 @@ let wordSchema = z.object({ export let speechToText = SlateTool.create(spec, { name: 'Speech to Text', key: 'speech_to_text', - description: `Transcribe audio into text with high accuracy. Supports speaker diarization, word-level timestamps, and 99+ languages. Provide audio as a base64-encoded file or a publicly accessible cloud storage URL.`, + description: `Transcribe audio into text with high accuracy. Supports speaker diarization, word-level timestamps, and 99+ languages. Provide audio as a base64-encoded file or a publicly accessible source URL.`, instructions: [ - 'Provide either a base64-encoded audio file via "fileBase64" or a public URL via "cloudStorageUrl", not both.', + 'Set sourceType to "file" with fileBase64, or "url" with sourceUrl. cloudStorageUrl remains available only for deprecated provider compatibility.', 'Enable "diarize" to identify different speakers in the audio.' ], constraints: [ @@ -34,6 +35,12 @@ export let speechToText = SlateTool.create(spec, { .enum(['scribe_v1', 'scribe_v2']) .default('scribe_v2') .describe('Transcription model to use'), + sourceType: z + .enum(['file', 'url']) + .default('file') + .describe( + 'Audio source type. For "file", provide fileBase64. For "url", provide sourceUrl.' + ), fileBase64: z .string() .optional() @@ -45,7 +52,13 @@ export let speechToText = SlateTool.create(spec, { cloudStorageUrl: z .string() .optional() - .describe('HTTPS URL to an audio/video file to transcribe'), + .describe('Deprecated HTTPS URL field. Prefer sourceUrl for URL transcription.'), + sourceUrl: z + .string() + .optional() + .describe( + 'URL of an audio or video file to transcribe. Supports hosted files and video URLs.' + ), languageCode: z .string() .optional() @@ -65,7 +78,38 @@ export let speechToText = SlateTool.create(spec, { tagAudioEvents: z .boolean() .optional() - .describe('Tag non-speech audio events like music and laughter') + .describe('Tag non-speech audio events like music and laughter'), + diarizationThreshold: z + .number() + .min(0.1) + .max(0.4) + .optional() + .describe( + 'Speaker diarization threshold. Only valid with diarize=true and no numSpeakers.' + ), + fileFormat: z + .enum(['pcm_s16le_16', 'other']) + .optional() + .describe('Input format. Use pcm_s16le_16 for raw 16-bit 16kHz mono PCM.'), + temperature: z + .number() + .min(0) + .max(2) + .optional() + .describe('Transcription randomness, from 0.0 to 2.0'), + seed: z + .number() + .int() + .min(0) + .max(2147483647) + .optional() + .describe('Best-effort deterministic transcription seed'), + useMultiChannel: z + .boolean() + .optional() + .describe( + 'Transcribe each audio channel independently when input has multiple channels' + ) }) ) .output( @@ -81,18 +125,52 @@ export let speechToText = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + let hasFile = Boolean(ctx.input.fileBase64); + let hasUrl = Boolean(ctx.input.sourceUrl || ctx.input.cloudStorageUrl); + + if (ctx.input.sourceType === 'file' && (!hasFile || hasUrl)) { + throw elevenLabsServiceError( + 'For sourceType "file", provide fileBase64 and do not provide sourceUrl or cloudStorageUrl.' + ); + } + + if (ctx.input.sourceType === 'url' && (!hasUrl || hasFile)) { + throw elevenLabsServiceError( + 'For sourceType "url", provide exactly one of sourceUrl or cloudStorageUrl and do not provide fileBase64.' + ); + } + + if (ctx.input.sourceUrl && ctx.input.cloudStorageUrl) { + throw elevenLabsServiceError('Provide only one of sourceUrl or cloudStorageUrl.'); + } + + if ( + ctx.input.diarizationThreshold !== undefined && + (!ctx.input.diarize || ctx.input.numSpeakers !== undefined) + ) { + throw elevenLabsServiceError( + 'diarizationThreshold can only be used when diarize is true and numSpeakers is omitted.' + ); + } + let client = new ElevenLabsClient(ctx.auth.token); let result = await client.speechToText({ modelId: ctx.input.modelId, fileBase64: ctx.input.fileBase64, fileName: ctx.input.fileName, + sourceUrl: ctx.input.sourceUrl, cloudStorageUrl: ctx.input.cloudStorageUrl, languageCode: ctx.input.languageCode, diarize: ctx.input.diarize, numSpeakers: ctx.input.numSpeakers, timestampsGranularity: ctx.input.timestampsGranularity, - tagAudioEvents: ctx.input.tagAudioEvents + tagAudioEvents: ctx.input.tagAudioEvents, + diarizationThreshold: ctx.input.diarizationThreshold, + fileFormat: ctx.input.fileFormat, + temperature: ctx.input.temperature, + seed: ctx.input.seed, + useMultiChannel: ctx.input.useMultiChannel }); let data = result as Record; diff --git a/integrations/eleven-labs/src/tools/text-to-speech.ts b/integrations/eleven-labs/src/tools/text-to-speech.ts index 48eb93261e..3517de1cb5 100644 --- a/integrations/eleven-labs/src/tools/text-to-speech.ts +++ b/integrations/eleven-labs/src/tools/text-to-speech.ts @@ -2,11 +2,17 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { ElevenLabsClient } from '../lib/client'; import { spec } from '../spec'; +import { + audioAttachment, + audioOutput, + audioOutputSchema, + voiceSettingsSchema +} from './shared'; export let textToSpeech = SlateTool.create(spec, { name: 'Text to Speech', key: 'text_to_speech', - description: `Convert text into lifelike speech audio using ElevenLabs voices and models. Returns base64-encoded audio that can be saved or played back. Supports multiple languages, voice customization, and various output formats.`, + description: `Convert text into lifelike speech audio using ElevenLabs voices and models. Returns generated audio as a Slate attachment. Supports multiple languages, voice customization, and various output formats.`, instructions: [ 'Use the "listVoices" or "listModels" tools first to find available voice IDs and model IDs.', 'The default model is "eleven_multilingual_v2". Use "eleven_flash_v2_5" for lower latency.' @@ -42,49 +48,30 @@ export let textToSpeech = SlateTool.create(spec, { .enum(['auto', 'on', 'off']) .optional() .describe('Text normalization mode. Defaults to "auto".'), - voiceSettings: z - .object({ - stability: z - .number() - .min(0) - .max(1) - .optional() - .describe('Voice stability (0-1). Lower values add more variability.'), - similarityBoost: z - .number() - .min(0) - .max(1) - .optional() - .describe( - 'Similarity boost (0-1). Higher values are more faithful to the original voice.' - ), - style: z - .number() - .min(0) - .max(1) - .optional() - .describe('Style exaggeration (0-1). Higher values amplify the voice style.'), - useSpeakerBoost: z - .boolean() - .optional() - .describe('Enable speaker boost for enhanced clarity'), - speed: z - .number() - .min(0.25) - .max(4.0) - .optional() - .describe('Speed multiplier (0.25-4.0). 1.0 is normal speed.') - }) + previousText: z + .string() + .optional() + .describe('Text immediately before this request, for continuity across chunks'), + nextText: z + .string() + .optional() + .describe('Text immediately after this request, for continuity across chunks'), + pronunciationDictionaryLocators: z + .array( + z.object({ + pronunciationDictionaryId: z.string().describe('Pronunciation dictionary ID'), + versionId: z.string().describe('Pronunciation dictionary version ID') + }) + ) + .max(3) + .optional() + .describe('Up to 3 pronunciation dictionary locators to apply in order'), + voiceSettings: voiceSettingsSchema .optional() .describe('Voice settings overrides for this request') }) ) - .output( - z.object({ - audioBase64: z.string().describe('Base64-encoded audio data'), - contentType: z.string().describe('MIME type of the audio (e.g. audio/mpeg)') - }) - ) + .output(audioOutputSchema) .handleInvocation(async ctx => { let client = new ElevenLabsClient(ctx.auth.token); @@ -94,7 +81,10 @@ export let textToSpeech = SlateTool.create(spec, { languageCode: ctx.input.languageCode, outputFormat: ctx.input.outputFormat, seed: ctx.input.seed, + previousText: ctx.input.previousText, + nextText: ctx.input.nextText, applyTextNormalization: ctx.input.applyTextNormalization, + pronunciationDictionaryLocators: ctx.input.pronunciationDictionaryLocators, voiceSettings: ctx.input.voiceSettings ? { stability: ctx.input.voiceSettings.stability, @@ -110,7 +100,8 @@ export let textToSpeech = SlateTool.create(spec, { ctx.input.text.length > 80 ? `${ctx.input.text.slice(0, 80)}...` : ctx.input.text; return { - output: result, + output: audioOutput(result), + attachments: [audioAttachment(result)], message: `Generated speech audio for: "${textPreview}" using voice \`${ctx.input.voiceId}\` (model: ${ctx.input.modelId || 'eleven_multilingual_v2'}).` }; }) diff --git a/integrations/eleven-labs/src/tools/voice-changer.ts b/integrations/eleven-labs/src/tools/voice-changer.ts new file mode 100644 index 0000000000..826bf67ab6 --- /dev/null +++ b/integrations/eleven-labs/src/tools/voice-changer.ts @@ -0,0 +1,72 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { ElevenLabsClient } from '../lib/client'; +import { spec } from '../spec'; +import { + audioAttachment, + audioOutput, + audioOutputSchema, + voiceSettingsSchema +} from './shared'; + +export let voiceChanger = SlateTool.create(spec, { + name: 'Voice Changer', + key: 'voice_changer', + description: `Transform an existing audio file to sound like a selected ElevenLabs voice while preserving timing and delivery. Returns generated audio as a Slate attachment.`, + instructions: ['Use list_voices first to find the target voice ID.'], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + voiceId: z.string().describe('ID of the voice to transform the audio into'), + fileBase64: z.string().describe('Base64-encoded source audio file'), + fileName: z.string().optional().describe('Original filename for the source audio'), + modelId: z + .string() + .optional() + .describe('Speech-to-speech model ID. Defaults to eleven_english_sts_v2.'), + outputFormat: z.string().optional().describe('Audio output format, e.g. mp3_44100_128'), + voiceSettings: voiceSettingsSchema + .optional() + .describe('Voice settings overrides for this request'), + seed: z + .number() + .int() + .min(0) + .max(4294967295) + .optional() + .describe('Best-effort deterministic generation seed'), + removeBackgroundNoise: z + .boolean() + .optional() + .describe('Remove background noise from the input before voice conversion'), + fileFormat: z + .enum(['pcm_s16le_16', 'other']) + .optional() + .describe('Input format. Use pcm_s16le_16 for raw 16-bit 16kHz mono PCM.') + }) + ) + .output(audioOutputSchema) + .handleInvocation(async ctx => { + let client = new ElevenLabsClient(ctx.auth.token); + let result = await client.voiceChanger(ctx.input.voiceId, { + fileBase64: ctx.input.fileBase64, + fileName: ctx.input.fileName, + modelId: ctx.input.modelId, + outputFormat: ctx.input.outputFormat, + voiceSettings: ctx.input.voiceSettings, + seed: ctx.input.seed, + removeBackgroundNoise: ctx.input.removeBackgroundNoise, + fileFormat: ctx.input.fileFormat + }); + + return { + output: audioOutput(result), + attachments: [audioAttachment(result)], + message: `Converted audio to voice \`${ctx.input.voiceId}\`.` + }; + }) + .build(); diff --git a/integrations/eleven-labs/vitest.config.ts b/integrations/eleven-labs/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/eleven-labs/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/evernote/README.md b/integrations/evernote/README.md index ece3bc181a..abc1b0876a 100644 --- a/integrations/evernote/README.md +++ b/integrations/evernote/README.md @@ -1,6 +1,6 @@ # Evernote -Create, read, update, and delete notes, notebooks, and tags in Evernote. Manage note content written in ENML markup, attach files and resources to notes, and share notes or notebooks with other users. Search notes by full text, title, tags, date, location, or notebook. Organize notes with tags and saved searches. Access shared and linked notebooks from other users. Set reminders on notes and store custom application-specific data. Receive webhook notifications when notes are created or updated. Supports both personal and Evernote Business accounts, including business notebooks and business-specific data. +Create, read, update, and trash notes in Evernote. Manage note content written in ENML markup, attach resources when creating notes, download resource file contents through Slate attachments, organize notes with notebooks and tags, search by Evernote search grammar, and receive note-change notifications through Evernote webhooks or polling. ## Tools @@ -10,7 +10,7 @@ Copy a note to a different notebook. Creates a new note with the same content, t ### Create Note -Create a new note in Evernote. The content can be plain text or ENML (Evernote Markup Language, a subset of XHTML). If plain text or simple HTML is provided, it will be wrapped in the required ENML envelope automatically. +Create a new note in Evernote. The content can be plain text or ENML (Evernote Markup Language, a subset of XHTML). If plain text or simple HTML is provided, it will be wrapped in the required ENML envelope automatically. Optional resources are uploaded with the note and referenced from the ENML body. ### Create Notebook @@ -20,6 +20,10 @@ Create a new notebook in Evernote. Optionally assign it to a stack for organizat Move a note to the trash. The note can be recovered from trash by the user. Permanent deletion (expunge) is not available to third-party integrations. +### Download Resource + +Download the binary contents of an Evernote note resource. File bytes are returned as Slate attachments, while structured output contains only resource metadata. + ### Get Note Content Retrieve only the ENML content body of a note. This is a lightweight alternative to **Get Note** when you only need the note body and not the metadata. @@ -32,6 +36,10 @@ Retrieve a note's full details including its ENML content, metadata, tags, and r List all notebooks in the user's Evernote account. Returns notebook names, GUIDs, stack groupings, and whether each is the default notebook. Use this to discover available notebooks before creating or moving notes. +### List Saved Searches + +List active saved searches in the user's account. Saved searches store reusable Evernote search grammar queries. + ### List Tags List all tags in the user's account, or only tags used within a specific notebook. Tags can form a hierarchy via parent-child relationships. @@ -42,7 +50,7 @@ Create a new tag or update an existing tag's name or parent. Tags organize notes ### Search Notes -Search for notes using Evernote's search grammar or filter by notebook, tags, and other criteria. Returns note metadata (title, dates, notebook, tags) without full content. Use **Get Note** to retrieve content for individual results. Supports Evernote search operators in the \ +Search for notes using Evernote's search grammar or filter by notebook, tags, and other criteria. Returns note metadata (title, dates, notebook, tags) without full content. Use **Get Note** to retrieve content for individual results. ### Update Note diff --git a/integrations/evernote/package.json b/integrations/evernote/package.json index cd61802d02..7cc4bfb020 100644 --- a/integrations/evernote/package.json +++ b/integrations/evernote/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/evernote/slate.json b/integrations/evernote/slate.json index 3578f82f3b..d7a7060b5d 100644 --- a/integrations/evernote/slate.json +++ b/integrations/evernote/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/evernote", - "description": "Create, read, update, and delete notes, notebooks, and tags in Evernote. Manage note content written in ENML markup, attach files and resources to notes, and share notes or notebooks with other users. Search notes by full text, title, tags, date, location, or notebook. Organize notes with tags and saved searches. Access shared and linked notebooks from other users. Set reminders on notes and store custom application-specific data. Receive webhook notifications when notes are created or updated. Supports both personal and Evernote Business accounts, including business notebooks and business-specific data.", + "description": "Create, read, update, copy, search, and trash notes in Evernote. Organize notes with notebooks and tags, attach resources when creating notes, download resource file contents through Slate attachments, list saved searches, and receive note-change notifications through webhooks or polling.", "categories": [ "apis-and-http-requests", "note-taking-and-knowledge-bases", @@ -11,12 +11,11 @@ "organize notebooks", "manage tags", "search notes", - "attach files to notes", - "share notes and notebooks", - "manage saved searches", + "attach resources to new notes", + "download note resources", + "list saved searches", "receive change notifications", - "sync account data", - "manage business notebooks" + "sync account metadata" ], "logoUrl": "https://provider-logos.metorial-cdn.com/evernote.png" } diff --git a/integrations/evernote/src/auth.ts b/integrations/evernote/src/auth.ts index 2cd723e594..35e53b2a4a 100644 --- a/integrations/evernote/src/auth.ts +++ b/integrations/evernote/src/auth.ts @@ -1,6 +1,7 @@ import { SlateAuth } from 'slates'; import { z } from 'zod'; import { Client } from './lib/client'; +import { evernoteServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -36,7 +37,7 @@ export let auth = SlateAuth.create() // For OAuth 1.0a, this shouldn't be called directly in the standard flow // but the framework requires it. The actual token exchange happens via the OAuth flow. // If user already has a token, they can use the developer token auth method instead. - throw new Error( + throw evernoteServiceError( 'OAuth 1.0a requires the full OAuth flow. Use the Developer Token auth method for direct token access.' ); } diff --git a/integrations/evernote/src/index.ts b/integrations/evernote/src/index.ts index ed2ef71b11..aefcaf83cd 100644 --- a/integrations/evernote/src/index.ts +++ b/integrations/evernote/src/index.ts @@ -5,9 +5,11 @@ import { createNotebookTool, createNoteTool, deleteNoteTool, + downloadResourceTool, getNoteContentTool, getNoteTool, listNotebooksTool, + listSavedSearchesTool, listTagsTool, manageTagTool, searchNotesTool, @@ -25,10 +27,12 @@ export let provider = Slate.create({ createNoteTool, getNoteTool, getNoteContentTool, + downloadResourceTool, updateNoteTool, deleteNoteTool, searchNotesTool, listTagsTool, + listSavedSearchesTool, manageTagTool, copyNoteTool ], diff --git a/integrations/evernote/src/lib/client.ts b/integrations/evernote/src/lib/client.ts index 2ca70adf2d..de4ec1cad1 100644 --- a/integrations/evernote/src/lib/client.ts +++ b/integrations/evernote/src/lib/client.ts @@ -1,5 +1,6 @@ // Evernote API Client - communicates via Thrift Binary Protocol over HTTP import { axios } from 'slates'; +import { EvernoteError, evernoteApiError } from './errors'; import { readEDAMNotFoundException, readEDAMSystemException, @@ -7,6 +8,7 @@ import { readNote, readNotebook, readNotesMetadataList, + readResource, readSavedSearch, readSyncState, readTag, @@ -14,6 +16,7 @@ import { writeNote, writeNotebook, writeNoteFilter, + writeNoteResultSpec, writeNotesMetadataResultSpec, writeSavedSearch, writeTag @@ -23,8 +26,10 @@ import type { EvernoteNote, EvernoteNotebook, EvernoteNoteFilter, + EvernoteNoteResultSpec, EvernoteNotesMetadataList, EvernoteNotesMetadataResultSpec, + EvernoteResource, EvernoteSavedSearch, EvernoteSyncState, EvernoteTag, @@ -54,25 +59,6 @@ let EDAMErrorCode: Record = { 19: 'RATE_LIMIT_REACHED' }; -export class EvernoteError extends Error { - errorCode: number; - parameter?: string; - rateLimitDuration?: number; - - constructor( - message: string, - errorCode: number, - parameter?: string, - rateLimitDuration?: number - ) { - super(message); - this.name = 'EvernoteError'; - this.errorCode = errorCode; - this.parameter = parameter; - this.rateLimitDuration = rateLimitDuration; - } -} - export class Client { private token: string; private noteStoreUrl: string; @@ -108,13 +94,18 @@ export class Client { let payload = w.toUint8Array(); - let response = await axios.post(this.noteStoreUrl, payload, { - headers: { - 'Content-Type': 'application/x-thrift', - Accept: 'application/x-thrift' - }, - responseType: 'arraybuffer' - }); + let response: any; + try { + response = await axios.post(this.noteStoreUrl, payload, { + headers: { + 'Content-Type': 'application/x-thrift', + Accept: 'application/x-thrift' + }, + responseType: 'arraybuffer' + }); + } catch (error) { + throw evernoteApiError(error, `NoteStore.${method}`); + } let responseData = new Uint8Array(response.data); let reader = new ThriftReader(responseData); @@ -166,13 +157,18 @@ export class Client { let payload = w.toUint8Array(); - let response = await axios.post(userStoreUrl, payload, { - headers: { - 'Content-Type': 'application/x-thrift', - Accept: 'application/x-thrift' - }, - responseType: 'arraybuffer' - }); + let response: any; + try { + response = await axios.post(userStoreUrl, payload, { + headers: { + 'Content-Type': 'application/x-thrift', + Accept: 'application/x-thrift' + }, + responseType: 'arraybuffer' + }); + } catch (error) { + throw evernoteApiError(error, `UserStore.${method}`); + } let responseData = new Uint8Array(response.data); let reader = new ThriftReader(responseData); @@ -370,17 +366,23 @@ export class Client { withResourcesRecognition: boolean, withResourcesAlternateData: boolean ): Promise { - let reader = await this.callNoteStore('getNote', w => { + return await this.getNoteWithResultSpec(noteGuid, { + includeContent: withContent, + includeResourcesData: withResourcesData, + includeResourcesRecognition: withResourcesRecognition, + includeResourcesAlternateData: withResourcesAlternateData + }); + } + + async getNoteWithResultSpec( + noteGuid: string, + resultSpec: EvernoteNoteResultSpec + ): Promise { + let reader = await this.callNoteStore('getNoteWithResultSpec', w => { w.writeFieldBegin(TType.STRING, 2); w.writeString(noteGuid); - w.writeFieldBegin(TType.BOOL, 3); - w.writeBool(withContent); - w.writeFieldBegin(TType.BOOL, 4); - w.writeBool(withResourcesData); - w.writeFieldBegin(TType.BOOL, 5); - w.writeBool(withResourcesRecognition); - w.writeFieldBegin(TType.BOOL, 6); - w.writeBool(withResourcesAlternateData); + w.writeFieldBegin(TType.STRUCT, 3); + writeNoteResultSpec(w, resultSpec); }); return this.parseResultStruct(reader, readNote); } @@ -427,6 +429,33 @@ export class Client { }); } + async getResource( + resourceGuid: string, + withData = false, + withRecognition = false, + withAlternateData = false + ): Promise { + let reader = await this.callNoteStore('getResource', w => { + w.writeFieldBegin(TType.STRING, 2); + w.writeString(resourceGuid); + w.writeFieldBegin(TType.BOOL, 3); + w.writeBool(withData); + w.writeFieldBegin(TType.BOOL, 4); + w.writeBool(withRecognition); + w.writeFieldBegin(TType.BOOL, 5); + w.writeBool(withAlternateData); + }); + return this.parseResultStruct(reader, readResource); + } + + async getResourceData(resourceGuid: string): Promise { + let reader = await this.callNoteStore('getResourceData', w => { + w.writeFieldBegin(TType.STRING, 2); + w.writeString(resourceGuid); + }); + return this.parseResultStruct(reader, r => r.readBinary()); + } + async listTags(): Promise { let reader = await this.callNoteStore('listTags', () => {}); return this.parseResultStruct(reader, r => { @@ -506,6 +535,14 @@ export class Client { return this.parseResultStruct(reader, readSavedSearch); } + async updateSearch(search: EvernoteSavedSearch): Promise { + let reader = await this.callNoteStore('updateSearch', w => { + w.writeFieldBegin(TType.STRUCT, 2); + writeSavedSearch(w, search); + }); + return this.parseI32Result(reader); + } + async getSyncState(): Promise { let reader = await this.callNoteStore('getSyncState', () => {}); return this.parseResultStruct(reader, readSyncState); diff --git a/integrations/evernote/src/lib/errors.ts b/integrations/evernote/src/lib/errors.ts new file mode 100644 index 0000000000..892d17c0b6 --- /dev/null +++ b/integrations/evernote/src/lib/errors.ts @@ -0,0 +1,126 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractEvernoteErrorMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data ?? (isRecord(error) ? error.data : undefined); + let details: string[] = []; + + if (isRecord(data)) { + for (let key of ['message', 'error', 'error_description', 'detail']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getEvernoteErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return response?.status ?? error.status; +}; + +export class EvernoteError extends ServiceError { + errorCode: number; + parameter?: string; + rateLimitDuration?: number; + + constructor( + message: string, + errorCode: number, + parameter?: string, + rateLimitDuration?: number + ) { + super(badRequestError({ message })); + this.name = 'EvernoteError'; + this.errorCode = errorCode; + this.parameter = parameter; + this.rateLimitDuration = rateLimitDuration; + this.data.reason = 'evernote_api_error'; + this.data.evernoteErrorCode = errorCode; + + if (parameter) { + this.data.parameter = parameter; + } + + if (rateLimitDuration !== undefined) { + this.data.rateLimitDuration = rateLimitDuration; + } + } +} + +export let evernoteServiceError = (message: string, reason = 'evernote_error') => { + let error = new ServiceError(badRequestError({ message })); + error.data.reason = reason; + return error; +}; + +export let evernoteApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getEvernoteErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = evernoteServiceError( + `Evernote API ${operation} failed: ${statusLabel}${extractEvernoteErrorMessage(error)}`, + 'evernote_api_transport_error' + ); + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let requireEvernoteString = ( + value: string | undefined, + label: string, + action?: string +) => { + if (typeof value !== 'string' || value.trim().length === 0) { + throw evernoteServiceError(`${label} is required${action ? ` for "${action}"` : ''}.`); + } + + return value; +}; diff --git a/integrations/evernote/src/lib/note-content.ts b/integrations/evernote/src/lib/note-content.ts new file mode 100644 index 0000000000..21631a9059 --- /dev/null +++ b/integrations/evernote/src/lib/note-content.ts @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import { evernoteServiceError } from './errors'; +import type { EvernoteResource } from './types'; + +export type NoteResourceInput = { + fileName?: string; + mimeType: string; + contentBase64: string; +}; + +type PreparedResource = { + resource: EvernoteResource; + mediaTag: string; +}; + +let enmlPrefix = + ''; +let enmlSuffix = ''; + +let escapeXmlAttribute = (value: string) => + value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + +let decodeBase64 = (value: string, label: string) => { + let normalized = value.replace(/\s/g, ''); + let bytes = Buffer.from(normalized, 'base64'); + let roundTrip = bytes.toString('base64').replace(/=+$/, ''); + let input = normalized.replace(/=+$/, ''); + + if (bytes.length === 0 || roundTrip !== input) { + throw evernoteServiceError(`${label} must be valid non-empty base64 content.`); + } + + return bytes; +}; + +export let prepareNoteResources = ( + inputs: NoteResourceInput[] | undefined +): PreparedResource[] => { + if (!inputs?.length) { + return []; + } + + return inputs.map((input, index) => { + let body = decodeBase64(input.contentBase64, `resources[${index}].contentBase64`); + let hash = createHash('md5').update(body).digest(); + let hashHex = hash.toString('hex'); + + return { + resource: { + mime: input.mimeType, + attributes: { + fileName: input.fileName, + attachment: true + }, + data: { + bodyHash: new Uint8Array(hash), + size: body.length, + body: new Uint8Array(body) + } + }, + mediaTag: `
` + }; + }); +}; + +export let buildEnmlContent = (content: string, resources: PreparedResource[] = []) => { + let resourceMarkup = resources.map(resource => resource.mediaTag).join(''); + let trimmed = content.trimStart(); + + if (!trimmed.startsWith(' tag when resources are attached.' + ); + } + + return content.replace(/<\/en-note>\s*$/, `${resourceMarkup}${enmlSuffix}`); +}; diff --git a/integrations/evernote/src/lib/serializers.ts b/integrations/evernote/src/lib/serializers.ts index cac4739a65..5d928e1615 100644 --- a/integrations/evernote/src/lib/serializers.ts +++ b/integrations/evernote/src/lib/serializers.ts @@ -343,45 +343,45 @@ export let writeNote = (w: ThriftWriter, note: EvernoteNote) => { w.writeString(note.content); } if (note.created !== undefined) { - w.writeFieldBegin(TType.I64, 5); + w.writeFieldBegin(TType.I64, 6); w.writeI64(note.created); } if (note.updated !== undefined) { - w.writeFieldBegin(TType.I64, 6); + w.writeFieldBegin(TType.I64, 7); w.writeI64(note.updated); } if (note.deleted !== undefined) { - w.writeFieldBegin(TType.I64, 7); + w.writeFieldBegin(TType.I64, 8); w.writeI64(note.deleted); } if (note.active !== undefined) { - w.writeFieldBegin(TType.BOOL, 8); + w.writeFieldBegin(TType.BOOL, 9); w.writeBool(note.active); } if (note.notebookGuid !== undefined) { - w.writeFieldBegin(TType.STRING, 10); + w.writeFieldBegin(TType.STRING, 11); w.writeString(note.notebookGuid); } if (note.tagGuids !== undefined) { - w.writeFieldBegin(TType.LIST, 11); + w.writeFieldBegin(TType.LIST, 12); w.writeListBegin(TType.STRING, note.tagGuids.length); for (let guid of note.tagGuids) { w.writeString(guid); } } if (note.resources !== undefined) { - w.writeFieldBegin(TType.LIST, 12); + w.writeFieldBegin(TType.LIST, 13); w.writeListBegin(TType.STRUCT, note.resources.length); for (let resource of note.resources) { writeResource(w, resource); } } if (note.attributes !== undefined) { - w.writeFieldBegin(TType.STRUCT, 13); + w.writeFieldBegin(TType.STRUCT, 14); writeNoteAttributes(w, note.attributes); } if (note.tagNames !== undefined) { - w.writeFieldBegin(TType.LIST, 14); + w.writeFieldBegin(TType.LIST, 15); w.writeListBegin(TType.STRING, note.tagNames.length); for (let name of note.tagNames) { w.writeString(name); @@ -401,12 +401,16 @@ export let writeNotebook = (w: ThriftWriter, notebook: EvernoteNotebook) => { w.writeFieldBegin(TType.STRING, 2); w.writeString(notebook.name); } + if (notebook.updateSequenceNum !== undefined) { + w.writeFieldBegin(TType.I32, 5); + w.writeI32(notebook.updateSequenceNum); + } if (notebook.defaultNotebook !== undefined) { - w.writeFieldBegin(TType.BOOL, 5); + w.writeFieldBegin(TType.BOOL, 6); w.writeBool(notebook.defaultNotebook); } if (notebook.stack !== undefined) { - w.writeFieldBegin(TType.STRING, 10); + w.writeFieldBegin(TType.STRING, 12); w.writeString(notebook.stack); } w.writeFieldStop(); @@ -695,18 +699,18 @@ export let readNotebook = (r: ThriftReader): EvernoteNotebook => { notebook.name = r.readString(); break; case 5: - notebook.defaultNotebook = r.readBool(); + notebook.updateSequenceNum = r.readI32(); break; case 6: - notebook.serviceCreated = r.readI64(); + notebook.defaultNotebook = r.readBool(); break; case 7: - notebook.serviceUpdated = r.readI64(); + notebook.serviceCreated = r.readI64(); break; case 8: - notebook.updateSequenceNum = r.readI32(); + notebook.serviceUpdated = r.readI64(); break; - case 10: + case 12: notebook.stack = r.readString(); break; default: diff --git a/integrations/evernote/src/lib/thrift.ts b/integrations/evernote/src/lib/thrift.ts index 60814a1461..e736be26ea 100644 --- a/integrations/evernote/src/lib/thrift.ts +++ b/integrations/evernote/src/lib/thrift.ts @@ -1,5 +1,6 @@ // Minimal Apache Thrift Binary Protocol implementation for Evernote API // Evernote uses Thrift binary protocol over HTTP POST +import { evernoteServiceError } from './errors'; // Thrift type IDs let TType = { @@ -199,7 +200,10 @@ export class ThriftReader { if (version !== (0x80010000 | 0)) { // Check strict mode version if ((versionAndType & 0xffff0000) !== 0x80010000) { - throw new Error(`Bad Thrift version: ${version.toString(16)}`); + throw evernoteServiceError( + `Bad Thrift response version from Evernote: ${version.toString(16)}`, + 'evernote_thrift_protocol_error' + ); } } let type = versionAndType & 0x000000ff; diff --git a/integrations/evernote/src/tools.schema.test.ts b/integrations/evernote/src/tools.schema.test.ts new file mode 100644 index 0000000000..ea404addad --- /dev/null +++ b/integrations/evernote/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Evernote tool input schemas', provider.actions); diff --git a/integrations/evernote/src/tools/create-note.ts b/integrations/evernote/src/tools/create-note.ts index c40d8887b5..745d874a8a 100644 --- a/integrations/evernote/src/tools/create-note.ts +++ b/integrations/evernote/src/tools/create-note.ts @@ -1,11 +1,14 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { buildEnmlContent, prepareNoteResources } from '../lib/note-content'; import { spec } from '../spec'; -let wrapInEnml = (content: string): string => { - return `${content}`; -}; +let noteResourceInputSchema = z.object({ + fileName: z.string().optional().describe('Original file name to store on the resource'), + mimeType: z.string().describe('MIME type of the resource, such as text/plain'), + contentBase64: z.string().describe('Base64-encoded resource bytes to attach') +}); export let createNoteTool = SlateTool.create(spec, { name: 'Create Note', @@ -13,7 +16,8 @@ export let createNoteTool = SlateTool.create(spec, { description: `Create a new note in Evernote. The content can be plain text or ENML (Evernote Markup Language, a subset of XHTML). If plain text or simple HTML is provided, it will be wrapped in the required ENML envelope automatically.`, instructions: [ 'If content does not start with " 0 ? resources.map(resource => resource.resource) : undefined, attributes: ctx.input.author || ctx.input.sourceUrl ? { @@ -82,6 +101,12 @@ export let createNoteTool = SlateTool.create(spec, { noteGuid: note.noteGuid || '', title: note.title || '', notebookGuid: note.notebookGuid || '', + resources: note.resources?.map(resource => ({ + resourceGuid: resource.resourceGuid || '', + mime: resource.mime, + fileName: resource.attributes?.fileName + })), + resourceCount: note.resources?.length ?? resources.length, createdAt: note.created ? new Date(note.created).toISOString() : new Date().toISOString() diff --git a/integrations/evernote/src/tools/download-resource.ts b/integrations/evernote/src/tools/download-resource.ts new file mode 100644 index 0000000000..a0408986e6 --- /dev/null +++ b/integrations/evernote/src/tools/download-resource.ts @@ -0,0 +1,62 @@ +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let downloadResourceTool = SlateTool.create(spec, { + name: 'Download Resource', + key: 'download_resource', + description: `Download the binary contents of an Evernote note resource. File bytes are returned as a Slate attachment, while the structured output contains only resource metadata.`, + instructions: [ + 'Use Get Note to discover resource GUIDs for a note.', + 'The downloaded file contents are returned in response attachments, not inline output fields.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + resourceGuid: z.string().describe('GUID of the Evernote resource to download'), + fallbackMimeType: z + .string() + .optional() + .describe('MIME type to use if Evernote does not return one') + }) + ) + .output( + z.object({ + resourceGuid: z.string().describe('GUID of the downloaded resource'), + mimeType: z.string().describe('MIME type of the downloaded resource'), + fileName: z.string().optional().describe('Original file name, if available'), + sizeBytes: z.number().describe('Number of downloaded bytes'), + attachmentCount: z.number().describe('Number of file attachments returned') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + noteStoreUrl: ctx.auth.noteStoreUrl + }); + + let [resource, data] = await Promise.all([ + client.getResource(ctx.input.resourceGuid, false, false, false), + client.getResourceData(ctx.input.resourceGuid) + ]); + let mimeType = resource.mime || ctx.input.fallbackMimeType || 'application/octet-stream'; + let fileName = resource.attributes?.fileName; + let contentBase64 = Buffer.from(data).toString('base64'); + + return { + output: { + resourceGuid: ctx.input.resourceGuid, + mimeType, + fileName, + sizeBytes: data.length, + attachmentCount: 1 + }, + attachments: [createBase64Attachment(contentBase64, mimeType)], + message: `Downloaded resource \`${ctx.input.resourceGuid}\`${fileName ? ` (${fileName})` : ''}.` + }; + }) + .build(); diff --git a/integrations/evernote/src/tools/get-note.ts b/integrations/evernote/src/tools/get-note.ts index 45152a4c2b..4536b88f39 100644 --- a/integrations/evernote/src/tools/get-note.ts +++ b/integrations/evernote/src/tools/get-note.ts @@ -57,13 +57,12 @@ export let getNoteTool = SlateTool.create(spec, { noteStoreUrl: ctx.auth.noteStoreUrl }); - let note = await client.getNote( - ctx.input.noteGuid, - ctx.input.includeContent ?? true, - false, // withResourcesData - false, // withResourcesRecognition - false // withResourcesAlternateData - ); + let note = await client.getNoteWithResultSpec(ctx.input.noteGuid, { + includeContent: ctx.input.includeContent ?? true, + includeResourcesData: false, + includeResourcesRecognition: false, + includeResourcesAlternateData: false + }); let tagNames: string[] | undefined; if (note.tagGuids && note.tagGuids.length > 0) { diff --git a/integrations/evernote/src/tools/index.ts b/integrations/evernote/src/tools/index.ts index e9a905722b..f66c3cc755 100644 --- a/integrations/evernote/src/tools/index.ts +++ b/integrations/evernote/src/tools/index.ts @@ -2,9 +2,11 @@ export * from './copy-note'; export * from './create-note'; export * from './create-notebook'; export * from './delete-note'; +export * from './download-resource'; export * from './get-note'; export * from './get-note-content'; export * from './list-notebooks'; +export * from './list-saved-searches'; export * from './list-tags'; export * from './manage-tag'; export * from './search-notes'; diff --git a/integrations/evernote/src/tools/list-saved-searches.ts b/integrations/evernote/src/tools/list-saved-searches.ts new file mode 100644 index 0000000000..09bab61466 --- /dev/null +++ b/integrations/evernote/src/tools/list-saved-searches.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listSavedSearchesTool = SlateTool.create(spec, { + name: 'List Saved Searches', + key: 'list_saved_searches', + description: `List the active saved searches in the user's Evernote account. Saved searches store reusable Evernote search grammar queries.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + searches: z + .array( + z.object({ + searchGuid: z.string().describe('Unique identifier of the saved search'), + name: z.string().optional().describe('Saved search name'), + query: z.string().optional().describe('Evernote search grammar query'), + format: z.number().optional().describe('Saved search format value'), + updateSequenceNum: z + .number() + .optional() + .describe('Update sequence number for this saved search') + }) + ) + .describe('Active saved searches in the account'), + searchCount: z.number().describe('Number of saved searches returned') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + noteStoreUrl: ctx.auth.noteStoreUrl + }); + + let searches = (await client.listSearches()).map(search => ({ + searchGuid: search.searchGuid || '', + name: search.name, + query: search.query, + format: search.format, + updateSequenceNum: search.updateSequenceNum + })); + + return { + output: { + searches, + searchCount: searches.length + }, + message: `Found **${searches.length}** saved search(es).` + }; + }) + .build(); diff --git a/integrations/evernote/src/tools/manage-tag.ts b/integrations/evernote/src/tools/manage-tag.ts index 05ef64d846..cc60ef26d3 100644 --- a/integrations/evernote/src/tools/manage-tag.ts +++ b/integrations/evernote/src/tools/manage-tag.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { evernoteServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageTagTool = SlateTool.create(spec, { @@ -43,7 +44,7 @@ export let manageTagTool = SlateTool.create(spec, { if (ctx.input.action === 'create') { if (!ctx.input.name) { - throw new Error('Tag name is required for create action'); + throw evernoteServiceError('Tag name is required for create action.'); } let tag = await client.createTag({ name: ctx.input.name, @@ -62,7 +63,7 @@ export let manageTagTool = SlateTool.create(spec, { if (ctx.input.action === 'update') { if (!ctx.input.tagGuid) { - throw new Error('tagGuid is required for update action'); + throw evernoteServiceError('tagGuid is required for update action.'); } await client.updateTag({ tagGuid: ctx.input.tagGuid, @@ -82,7 +83,7 @@ export let manageTagTool = SlateTool.create(spec, { if (ctx.input.action === 'untag_all') { if (!ctx.input.tagGuid) { - throw new Error('tagGuid is required for untag_all action'); + throw evernoteServiceError('tagGuid is required for untag_all action.'); } await client.untagAll(ctx.input.tagGuid); return { @@ -94,6 +95,6 @@ export let manageTagTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw evernoteServiceError(`Unknown tag action: ${ctx.input.action}.`); }) .build(); diff --git a/integrations/evernote/src/tools/update-note.ts b/integrations/evernote/src/tools/update-note.ts index 74ce15d592..ca6937bdbc 100644 --- a/integrations/evernote/src/tools/update-note.ts +++ b/integrations/evernote/src/tools/update-note.ts @@ -1,12 +1,9 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { buildEnmlContent } from '../lib/note-content'; import { spec } from '../spec'; -let wrapInEnml = (content: string): string => { - return `${content}`; -}; - export let updateNoteTool = SlateTool.create(spec, { name: 'Update Note', key: 'update_note', @@ -47,10 +44,7 @@ export let updateNoteTool = SlateTool.create(spec, { noteStoreUrl: ctx.auth.noteStoreUrl }); - let content = ctx.input.content; - if (content && !content.startsWith(' Fly Io -Provision and manage applications, virtual machines (Machines), persistent storage volumes, secrets, certificates, and networking on the Fly.io cloud platform. Create and deploy containerized workloads to specific geographic regions using fast-launching Firecracker microVMs. Control the full Machine lifecycle—create, start, stop, restart, clone, update, and destroy VMs with configurable CPU, memory, and GPU resources. Manage persistent volumes, allocate IP addresses (Anycast IPv4/IPv6), configure SSL/TLS certificates for custom domains, and set app-level secrets. Query Prometheus-compatible metrics for monitoring HTTP responses, CPU, memory, disk I/O, and network traffic. Generate OIDC tokens for external service authentication. +Provision and manage applications, virtual machines (Machines), persistent storage volumes, app secrets, certificates, and networking on the Fly.io cloud platform. Create and deploy containerized workloads to specific geographic regions using fast-launching Firecracker microVMs. Control Machine lifecycle operations, inspect events, processes, versions, memory, and org-wide inventory, manage app IP assignments, configure SSL/TLS certificates for custom domains, and generate OIDC tokens for external service authentication. ## Tools @@ -12,6 +12,10 @@ Start, stop, restart, or suspend a Fly Machine. Also supports cordoning (disabli Create a new Fly App in an organization. Apps serve as named collections that group Machines, volumes, networking, and secrets. Optionally isolate the app on its own private network. +### Assign IP Address + +Assign a new IP address to a Fly App. + ### Create Machine Create a new Fly Machine from a container image in a specific region. Configure CPU, memory, GPU resources, networking services, environment variables, volume mounts, and more. @@ -24,6 +28,10 @@ Create a new persistent storage volume for a Fly App. Volumes provide local pers Delete a Fly App and all its associated resources. Use force to stop all running Machines before deletion. +### Delete IP Assignment + +Remove an IP assignment from a Fly App. + ### Delete Machine Permanently destroy a Fly Machine. Use force to stop a running machine before deletion. This action cannot be undone. @@ -36,14 +44,46 @@ Retrieve details for a specific Fly App including its status and organization. U Retrieve full details of a specific Fly Machine including its configuration, state, events, and image reference. +### Get Machine Memory + +Get the current memory limit and available capacity for a Fly Machine. + +### List IP Assignments + +List public IP assignments for a Fly App, including shared status, region, and service name. + ### List Apps List all Fly Apps in an organization. Returns app names, machine counts, volume counts, and network configuration for each app. +### List Machine Events + +List recent events for a Fly Machine. + +### List Machine Processes + +List processes currently running on a Fly Machine. + +### List Machine Versions + +List historical configuration versions for a Fly Machine. + ### List Machines List all Fly Machines in an app. Optionally filter by region or metadata. Returns machine IDs, states, regions, and configuration details. +### List Organization Machines + +List Fly Machines across an organization with filters and pagination. + +### List Organization Volumes + +List Fly Volumes across an organization with filters and pagination. + +### List Regions + +List Fly.io platform regions and the nearest region for the current API caller. + ### List Volumes List all persistent storage volumes for a Fly App. Returns volume IDs, sizes, states, regions, and attachment information. diff --git a/integrations/fly-io/docs/SPEC.md b/integrations/fly-io/docs/SPEC.md index 271a2bcadc..68ae592650 100644 --- a/integrations/fly-io/docs/SPEC.md +++ b/integrations/fly-io/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Fly.io is a cloud platform that runs applications on fast-launching Firecracker microVMs in data centers around the world. It provides a REST API (Machines API) and a GraphQL API for provisioning and managing applications, virtual machines, persistent storage volumes, and networking. Users can deploy containerized workloads to specific geographic regions with fine-grained control over VM lifecycle, resources, and placement. +Fly.io is a cloud platform that runs applications on fast-launching Firecracker microVMs in data centers around the world. It provides a REST API (Machines API) for provisioning and managing applications, virtual machines, persistent storage volumes, networking, certificates, secrets, OIDC tokens, and operational diagnostics. Users can deploy containerized workloads to specific geographic regions with fine-grained control over VM lifecycle, resources, and placement. ## Authentication @@ -39,7 +39,11 @@ Use the correct scheme based on token type: `Bearer ` for `flyctl auth to ### App Management -Create, list, retrieve, and delete Fly Apps. A Fly App can be a web app, or a database, or a bunch of task Machines, or whatever you want to deploy. Apps serve as named collections that group Machines, volumes, networking configuration, and secrets. Apps can be segmented into isolated networks. +Create, list, retrieve, and delete Fly Apps. A Fly App can be a web app, database, or group of task Machines. Apps serve as named collections that group Machines, volumes, networking configuration, and secrets. Apps can be segmented into isolated networks. + +### Region and Organization Inventory + +List platform regions, organization-wide Machines, and organization-wide volumes to support discovery, inventory, and polling workflows across apps. ### Machine Lifecycle Management @@ -48,8 +52,9 @@ You can use the Machines resource to create, stop, start, update, and delete Fly - Create Machines from container images in specific regions. - Configure CPU, memory, and GPU resources per Machine. - Start, stop, restart, and destroy individual Machines. -- Clone existing Machines to scale horizontally. +- Send Unix signals to running Machines. - Wait for Machines to reach specific states. +- Inspect Machine event history, current processes, config versions, and memory limits. - Attach services and configure networking (ports, protocols, proxy behavior). - Set metadata on Machines and filter by metadata. @@ -59,7 +64,7 @@ Create and manage persistent storage volumes for your Machines. Fly Volumes are ### Secrets Management -Manage sensitive environment variables (secrets) at the app level. Machines inherit secrets from the app. Secrets are exposed as environment variables in VMs. +Manage sensitive environment variables (secrets) at the app level. Machines inherit secrets from the app. Secrets are exposed as environment variables in VMs and values are not returned by default. ### SSL/TLS Certificate Management @@ -67,11 +72,7 @@ Manage SSL/TLS certificates for custom domains. ### Networking and IP Allocation -Allocate IP addresses (Anycast IPv4, IPv6) for apps to make them publicly accessible. Configure private networking between Machines within an organization via WireGuard. Currently this is done using flyctl or the Fly.io GraphQL API. This offers your app automatic, global routing via Anycast. - -### Metrics and Monitoring - -Query Prometheus-compatible metrics for your organization's apps via a dedicated endpoint at `https://api.fly.io/prometheus//`. Includes built-in metrics for HTTP responses, instance CPU, memory, disk I/O, and network traffic. +List, assign, and remove app IP assignments using the Machines API. Configure Machine services and app private-network segmentation for routing behavior. ### OIDC Token Generation diff --git a/integrations/fly-io/package.json b/integrations/fly-io/package.json index cde75bb12e..eae7dc168d 100644 --- a/integrations/fly-io/package.json +++ b/integrations/fly-io/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/fly-io/slate.json b/integrations/fly-io/slate.json index f8d005e1cc..ce64f7058c 100644 --- a/integrations/fly-io/slate.json +++ b/integrations/fly-io/slate.json @@ -1,16 +1,17 @@ { "name": "@metorial/flyio", - "description": "Provision and manage applications, virtual machines (Machines), persistent storage volumes, secrets, certificates, and networking on the Fly.io cloud platform. Create and deploy containerized workloads to specific geographic regions using fast-launching Firecracker microVMs. Control the full Machine lifecycle—create, start, stop, restart, clone, update, and destroy VMs with configurable CPU, memory, and GPU resources. Manage persistent volumes, allocate IP addresses (Anycast IPv4/IPv6), configure SSL/TLS certificates for custom domains, and set app-level secrets. Query Prometheus-compatible metrics for monitoring HTTP responses, CPU, memory, disk I/O, and network traffic. Generate OIDC tokens for external service authentication.", + "description": "Provision and manage applications, virtual machines (Machines), persistent storage volumes, app secrets, certificates, and networking on the Fly.io cloud platform. Create and deploy containerized workloads to specific geographic regions using fast-launching Firecracker microVMs. Control Machine lifecycle operations, inspect events, processes, versions, memory, and org-wide inventory, manage app IP assignments, configure SSL/TLS certificates for custom domains, and generate OIDC tokens for external service authentication.", "categories": ["apis-and-http-requests"], "skills": [ "create and manage apps", "provision and control VMs", "manage persistent volumes", - "allocate IP addresses", + "inspect and manage app IP assignments", "manage SSL/TLS certificates", "manage app secrets", - "query Prometheus metrics", - "clone machines for scaling", + "inspect machine events and processes", + "list org-wide machine and volume inventory", + "list Fly.io regions", "configure VM resources", "generate OIDC tokens" ], diff --git a/integrations/fly-io/src/index.ts b/integrations/fly-io/src/index.ts index 632dc5f711..0386c9e8cb 100644 --- a/integrations/fly-io/src/index.ts +++ b/integrations/fly-io/src/index.ts @@ -1,16 +1,26 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + assignIpAddress, controlMachine, createApp, createMachine, createVolume, deleteApp, + deleteIpAssignment, deleteMachine, getApp, getMachine, + getMachineMemory, listApps, + listIpAssignments, + listMachineEvents, + listMachineProcesses, listMachines, + listMachineVersions, + listOrgMachines, + listOrgVolumes, + listRegions, listVolumes, manageCertificates, manageMachineLease, @@ -30,8 +40,18 @@ export let provider = Slate.create({ getApp, createApp, deleteApp, + listIpAssignments, + assignIpAddress, + deleteIpAssignment, + listRegions, + listOrgMachines, + listOrgVolumes, listMachines, getMachine, + getMachineMemory, + listMachineEvents, + listMachineProcesses, + listMachineVersions, createMachine, updateMachine, controlMachine, diff --git a/integrations/fly-io/src/lib/client.ts b/integrations/fly-io/src/lib/client.ts index c177af9de1..6a8b053916 100644 --- a/integrations/fly-io/src/lib/client.ts +++ b/integrations/fly-io/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { flyIoApiError } from './errors'; export class FlyClient { private axios; @@ -11,13 +12,20 @@ export class FlyClient { 'Content-Type': 'application/json' } }); + this.axios.interceptors.response.use( + (response: any) => response, + (error: unknown) => Promise.reject(flyIoApiError(error)) + ); } // ─── Apps ─────────────────────────────────────────────── - async listApps(orgSlug: string): Promise<{ totalApps: number; apps: FlyApp[] }> { + async listApps( + orgSlug: string, + params?: { appRole?: string } + ): Promise<{ totalApps: number; apps: FlyApp[] }> { let response = await this.axios.get('/v1/apps', { - params: { org_slug: orgSlug } + params: { org_slug: orgSlug, app_role: params?.appRole } }); return { totalApps: response.data.total_apps, @@ -54,15 +62,46 @@ export class FlyClient { }); } + async listIpAssignments(appName: string): Promise { + let response = await this.axios.get(`/v1/apps/${appName}/ip_assignments`); + return (response.data.ips || []).map(mapIpAssignment); + } + + async assignIpAddress( + appName: string, + params: AssignIpAddressParams + ): Promise { + let response = await this.axios.post(`/v1/apps/${appName}/ip_assignments`, { + type: params.type, + region: params.region, + org_slug: params.orgSlug, + network: params.network, + service_name: params.serviceName + }); + return mapIpAssignment(response.data); + } + + async deleteIpAssignment(appName: string, ipAddress: string): Promise { + await this.axios.delete(`/v1/apps/${appName}/ip_assignments/${ipAddress}`); + } + // ─── Machines ─────────────────────────────────────────── async listMachines( appName: string, - params?: { includeDeleted?: boolean; region?: string; metadata?: Record } + params?: { + includeDeleted?: boolean; + region?: string; + state?: string; + summary?: boolean; + metadata?: Record; + } ): Promise { let queryParams: Record = {}; if (params?.includeDeleted) queryParams.include_deleted = 'true'; if (params?.region) queryParams.region = params.region; + if (params?.state) queryParams.state = params.state; + if (params?.summary !== undefined) queryParams.summary = String(params.summary); if (params?.metadata) { for (let [key, value] of Object.entries(params.metadata)) { queryParams[`metadata.${key}`] = value; @@ -88,6 +127,9 @@ export class FlyClient { if (params.skipLaunch !== undefined) body.skip_launch = params.skipLaunch; if (params.skipServiceRegistration !== undefined) body.skip_service_registration = params.skipServiceRegistration; + if (params.skipSecrets !== undefined) body.skip_secrets = params.skipSecrets; + if (params.minSecretsVersion !== undefined) + body.min_secrets_version = params.minSecretsVersion; if (params.leaseTtl !== undefined) body.lease_ttl = params.leaseTtl; let response = await this.axios.post(`/v1/apps/${appName}/machines`, body); @@ -102,6 +144,15 @@ export class FlyClient { let body: Record = { config: buildMachineConfig(params.config) }; + if (params.name) body.name = params.name; + if (params.skipLaunch !== undefined) body.skip_launch = params.skipLaunch; + if (params.skipServiceRegistration !== undefined) + body.skip_service_registration = params.skipServiceRegistration; + if (params.skipSecrets !== undefined) body.skip_secrets = params.skipSecrets; + if (params.minSecretsVersion !== undefined) + body.min_secrets_version = params.minSecretsVersion; + if (params.leaseTtl !== undefined) body.lease_ttl = params.leaseTtl; + if (params.currentVersion) body.current_version = params.currentVersion; if (params.leaseNonce) { let response = await this.axios.post(`/v1/apps/${appName}/machines/${machineId}`, body, { headers: { 'fly-machine-lease-nonce': params.leaseNonce } @@ -127,7 +178,7 @@ export class FlyClient { async stopMachine( appName: string, machineId: string, - params?: { signal?: string; timeout?: number } + params?: { signal?: string; timeout?: string } ): Promise { await this.axios.post( `/v1/apps/${appName}/machines/${machineId}/stop`, @@ -143,16 +194,20 @@ export class FlyClient { async restartMachine( appName: string, machineId: string, - params?: { signal?: string; timeout?: number } + params?: { signal?: string; timeout?: string } ): Promise { let queryParams: Record = {}; if (params?.signal) queryParams.signal = params.signal; - if (params?.timeout) queryParams.timeout = String(params.timeout); + if (params?.timeout) queryParams.timeout = params.timeout; await this.axios.post(`/v1/apps/${appName}/machines/${machineId}/restart`, undefined, { params: queryParams }); } + async signalMachine(appName: string, machineId: string, signal: string): Promise { + await this.axios.post(`/v1/apps/${appName}/machines/${machineId}/signal`, { signal }); + } + async deleteMachine(appName: string, machineId: string, force?: boolean): Promise { await this.axios.delete(`/v1/apps/${appName}/machines/${machineId}`, { params: force ? { force: true } : undefined @@ -184,6 +239,41 @@ export class FlyClient { await this.axios.post(`/v1/apps/${appName}/machines/${machineId}/uncordon`); } + async listMachineEvents( + appName: string, + machineId: string, + params?: { limit?: number } + ): Promise { + let response = await this.axios.get(`/v1/apps/${appName}/machines/${machineId}/events`, { + params + }); + return (response.data || []).map(mapMachineEvent); + } + + async listMachineProcesses( + appName: string, + machineId: string, + params?: { sortBy?: string; order?: string } + ): Promise { + let response = await this.axios.get(`/v1/apps/${appName}/machines/${machineId}/ps`, { + params: { + sort_by: params?.sortBy, + order: params?.order + } + }); + return (response.data || []).map(mapMachineProcess); + } + + async listMachineVersions(appName: string, machineId: string): Promise { + let response = await this.axios.get(`/v1/apps/${appName}/machines/${machineId}/versions`); + return (response.data || []).map(mapMachineVersion); + } + + async getMachineMemory(appName: string, machineId: string): Promise { + let response = await this.axios.get(`/v1/apps/${appName}/machines/${machineId}/memory`); + return mapMachineMemory(response.data); + } + // ─── Machine Metadata ────────────────────────────────── async getMachineMetadata( @@ -312,22 +402,52 @@ export class FlyClient { // ─── Secrets ──────────────────────────────────────────── - async listSecrets(appName: string): Promise { - let response = await this.axios.get(`/v1/apps/${appName}/secrets`); - return (response.data || []).map(mapSecret); + async listSecrets(appName: string, params?: { minVersion?: string }): Promise { + let response = await this.axios.get(`/v1/apps/${appName}/secrets`, { + params: { + min_version: params?.minVersion + } + }); + return (response.data.secrets || []).map(mapSecret); } - async setSecrets(appName: string, secrets: Record): Promise { - let secretEntries = Object.entries(secrets).map(([key, value]) => ({ - label: key, - type: 'secret', - value: value - })); - await this.axios.post(`/v1/apps/${appName}/secrets`, secretEntries); + async getSecret(appName: string, secretName: string): Promise { + let response = await this.axios.get(`/v1/apps/${appName}/secrets/${secretName}`); + return mapSecret(response.data); + } + + async setSecrets( + appName: string, + secrets: Record + ): Promise<{ version: number; secrets: FlySecret[] }> { + let response = await this.axios.post(`/v1/apps/${appName}/secrets`, { + values: secrets + }); + return { + version: response.data.version ?? response.data.Version ?? 0, + secrets: (response.data.secrets || []).map(mapSecret) + }; } - async deleteSecret(appName: string, secretName: string): Promise { - await this.axios.delete(`/v1/apps/${appName}/secrets/${secretName}`); + async setSecret( + appName: string, + secretName: string, + value: string + ): Promise<{ version: number; secret: FlySecret }> { + let response = await this.axios.post(`/v1/apps/${appName}/secrets/${secretName}`, { + value + }); + return { + version: response.data.version ?? response.data.Version ?? 0, + secret: mapSecret(response.data) + }; + } + + async deleteSecret(appName: string, secretName: string): Promise<{ version: number }> { + let response = await this.axios.delete(`/v1/apps/${appName}/secrets/${secretName}`); + return { + version: response.data?.version ?? response.data?.Version ?? 0 + }; } // ─── Certificates ────────────────────────────────────── @@ -337,7 +457,10 @@ export class FlyClient { params?: { filter?: string; cursor?: string; limit?: number } ): Promise { let response = await this.axios.get(`/v1/apps/${appName}/certificates`, { params }); - return (response.data || []).map(mapCertificate); + let certificates = Array.isArray(response.data) + ? response.data + : response.data.certificates || []; + return certificates.map(mapCertificate); } async getCertificate(appName: string, hostname: string): Promise { @@ -393,6 +516,47 @@ export class FlyClient { let response = await this.axios.post('/v1/tokens/oidc', { aud }); return response.data; } + + // ─── Organization and Platform ─────────────────────────── + + async listOrgMachines( + orgSlug: string, + params?: OrgInventoryParams + ): Promise { + let response = await this.axios.get(`/v1/orgs/${orgSlug}/machines`, { + params: buildOrgInventoryParams(params) + }); + return { + machines: (response.data.machines || []).map(mapOrgMachine), + nextCursor: response.data.next_cursor || '', + lastMachineId: response.data.last_machine_id || '', + lastUpdatedAt: response.data.last_updated_at || '', + errorRegions: response.data.error_regions || [] + }; + } + + async listOrgVolumes( + orgSlug: string, + params?: OrgInventoryParams + ): Promise { + let response = await this.axios.get(`/v1/orgs/${orgSlug}/volumes`, { + params: buildOrgInventoryParams(params) + }); + return { + volumes: (response.data.volumes || []).map(mapOrgVolume), + nextCursor: response.data.next_cursor || '', + lastVolumeId: response.data.last_volume_id || '', + lastUpdatedAt: response.data.last_updated_at || '' + }; + } + + async listRegions(): Promise { + let response = await this.axios.get('/v1/platform/regions'); + return { + nearest: response.data.nearest || '', + regions: (response.data.regions || []).map(mapRegion) + }; + } } // ─── Types ────────────────────────────────────────────── @@ -415,6 +579,22 @@ export interface FlyAppDetail { }; } +export interface FlyIpAssignment { + ipAddress: string; + region: string; + serviceName: string; + shared: boolean; + createdAt: string; +} + +export interface AssignIpAddressParams { + type: string; + orgSlug?: string; + region?: string; + network?: string; + serviceName?: string; +} + export interface FlyMachine { machineId: string; machineName: string; @@ -434,6 +614,8 @@ export interface CreateMachineParams { region?: string; skipLaunch?: boolean; skipServiceRegistration?: boolean; + skipSecrets?: boolean; + minSecretsVersion?: number; leaseTtl?: number; config: MachineConfig; } @@ -441,6 +623,13 @@ export interface CreateMachineParams { export interface UpdateMachineParams { config: MachineConfig; leaseNonce?: string; + leaseTtl?: number; + currentVersion?: string; + name?: string; + skipLaunch?: boolean; + skipServiceRegistration?: boolean; + skipSecrets?: boolean; + minSecretsVersion?: number; } export interface MachineConfig { @@ -449,48 +638,110 @@ export interface MachineConfig { guest?: { cpus?: number; memoryMb?: number; + maxMemoryMb?: number; cpuKind?: string; gpuKind?: string; + gpus?: number; + hostDedicationId?: string; + kernelArgs?: string[]; }; size?: string; services?: Array<{ ports: Array<{ port: number; + startPort?: number; + endPort?: number; handlers?: string[]; forceHttps?: boolean; }>; protocol: string; internalPort: number; + autostop?: 'off' | 'stop' | 'suspend'; + autostart?: boolean; autoStopMachines?: boolean; autoStartMachines?: boolean; minMachinesRunning?: number; + concurrency?: { + type?: string; + softLimit?: number; + hardLimit?: number; + }; }>; mounts?: Array<{ volume: string; path: string; + name?: string; + sizeGb?: number; + addSizeGb?: number; + encrypted?: boolean; + extendThresholdPercent?: number; + sizeGbLimit?: number; }>; init?: { exec?: string[]; entrypoint?: string[]; cmd?: string[]; + kernelArgs?: string[]; + swapSizeMb?: number; tty?: boolean; }; restart?: { - policy?: string; + policy?: 'no' | 'on-failure' | 'always' | 'spot-price'; maxRetries?: number; + gpuBidPrice?: number; }; metadata?: Record; metrics?: { port?: number; path?: string; + https?: boolean; }; schedule?: string; autoDestroy?: boolean; checks?: Record; - statics?: Array<{ guestPath: string; urlPrefix: string }>; + statics?: Array<{ guestPath: string; urlPrefix: string; indexDocument?: string }>; dns?: Record; - stopConfig?: { signal?: string; timeout?: number }; + stopConfig?: { signal?: string; timeout?: string }; standbys?: string[]; + rootfs?: { + persist?: 'never' | 'always' | 'restart'; + sizeGb?: number; + }; + files?: Record[]; + processes?: Record[]; + containers?: Record[]; + cacheDrive?: Record; + spot?: Record; +} + +export interface FlyMachineEvent { + eventId: string; + type: string; + status: string; + source: string; + timestamp: number; + request: Record; +} + +export interface FlyMachineProcess { + pid: number; + command: string; + directory: string; + cpu: number; + rss: number; + rtime: number; + stime: number; + listenSockets: Array<{ address: string; proto: string }>; +} + +export interface FlyMachineVersion { + version: string; + userConfig: Record; +} + +export interface FlyMachineMemory { + limitMb: number; + availableMb: number; } export interface FlyVolume { @@ -525,10 +776,10 @@ export interface FlySnapshot { } export interface FlySecret { - label: string; - type: string; + secretName: string; digest: string; createdAt: string; + updatedAt: string; } export interface FlyCertificate { @@ -545,6 +796,76 @@ export interface FlyCertificate { validation: Record; } +export interface OrgInventoryParams { + includeDeleted?: boolean; + region?: string; + state?: string; + summary?: boolean; + updatedAfter?: string; + cursor?: string; + limit?: number; +} + +export interface FlyOrgMachine { + appName: string; + machineId: string; + machineName: string; + state: string; + region: string; + privateIp: string; + version: string; + createdAt: string; + updatedAt: string; + config: Record; +} + +export interface FlyOrgMachinesResponse { + machines: FlyOrgMachine[]; + nextCursor: string; + lastMachineId: string; + lastUpdatedAt: string; + errorRegions: string[]; +} + +export interface FlyOrgVolume { + appName: string; + volumeId: string; + volumeName: string; + state: string; + sizeGb: number; + region: string; + zone: string; + encrypted: boolean; + attachedMachineId: string | null; + autoBackupEnabled: boolean; + snapshotRetention: number; + createdAt: string; + updatedAt: string; +} + +export interface FlyOrgVolumesResponse { + volumes: FlyOrgVolume[]; + nextCursor: string; + lastVolumeId: string; + lastUpdatedAt: string; +} + +export interface FlyRegion { + code: string; + name: string; + geoRegion: string; + latitude: number; + longitude: number; + gatewayAvailable: boolean; + requiresPaidPlan: boolean; + deprecated: boolean; +} + +export interface FlyRegionsResponse { + nearest: string; + regions: FlyRegion[]; +} + // ─── Mappers ──────────────────────────────────────────── let mapApp = (data: any): FlyApp => ({ @@ -565,6 +886,14 @@ let mapAppDetail = (data: any): FlyAppDetail => ({ } }); +let mapIpAssignment = (data: any): FlyIpAssignment => ({ + ipAddress: data.ip || '', + region: data.region || '', + serviceName: data.service_name || '', + shared: data.shared ?? false, + createdAt: data.created_at || '' +}); + let mapMachine = (data: any): FlyMachine => ({ machineId: data.id || '', machineName: data.name || '', @@ -579,6 +908,39 @@ let mapMachine = (data: any): FlyMachine => ({ events: data.events || [] }); +let mapMachineEvent = (data: any): FlyMachineEvent => ({ + eventId: data.id || '', + type: data.type || '', + status: data.status || '', + source: data.source || '', + timestamp: data.timestamp || 0, + request: data.request || {} +}); + +let mapMachineProcess = (data: any): FlyMachineProcess => ({ + pid: data.pid || 0, + command: data.command || '', + directory: data.directory || '', + cpu: data.cpu || 0, + rss: data.rss || 0, + rtime: data.rtime || 0, + stime: data.stime || 0, + listenSockets: (data.listen_sockets || []).map((socket: any) => ({ + address: socket.address || '', + proto: socket.proto || '' + })) +}); + +let mapMachineVersion = (data: any): FlyMachineVersion => ({ + version: data.version || '', + userConfig: data.user_config || {} +}); + +let mapMachineMemory = (data: any): FlyMachineMemory => ({ + limitMb: data.limit_mb || 0, + availableMb: data.available_mb || 0 +}); + let mapVolume = (data: any): FlyVolume => ({ volumeId: data.id || '', volumeName: data.name || '', @@ -601,10 +963,10 @@ let mapSnapshot = (data: any): FlySnapshot => ({ }); let mapSecret = (data: any): FlySecret => ({ - label: data.label || data.name || '', - type: data.type || '', + secretName: data.name || data.label || '', digest: data.digest || '', - createdAt: data.created_at || '' + createdAt: data.created_at || '', + updatedAt: data.updated_at || '' }); let mapCertificate = (data: any): FlyCertificate => ({ @@ -621,6 +983,61 @@ let mapCertificate = (data: any): FlyCertificate => ({ validation: data.validation || {} }); +let mapOrgMachine = (data: any): FlyOrgMachine => ({ + appName: data.app_name || '', + machineId: data.id || '', + machineName: data.name || '', + state: data.state || '', + region: data.region || '', + privateIp: data.private_ip || '', + version: data.version || '', + createdAt: data.created_at || '', + updatedAt: data.updated_at || '', + config: data.config || {} +}); + +let mapOrgVolume = (data: any): FlyOrgVolume => ({ + appName: data.app_name || '', + volumeId: data.id || '', + volumeName: data.name || '', + state: data.state || '', + sizeGb: data.size_gb || 0, + region: data.region || '', + zone: data.zone || '', + encrypted: data.encrypted ?? true, + attachedMachineId: data.attached_machine_id || null, + autoBackupEnabled: data.auto_backup_enabled ?? true, + snapshotRetention: data.snapshot_retention || 0, + createdAt: data.created_at || '', + updatedAt: data.updated_at || '' +}); + +let mapRegion = (data: any): FlyRegion => ({ + code: data.code || '', + name: data.name || '', + geoRegion: data.geo_region || '', + latitude: data.latitude || 0, + longitude: data.longitude || 0, + gatewayAvailable: data.gateway_available ?? false, + requiresPaidPlan: data.requires_paid_plan ?? false, + deprecated: data.deprecated ?? false +}); + +let buildOrgInventoryParams = ( + params?: OrgInventoryParams +): Record => { + let queryParams: Record = {}; + if (params?.includeDeleted !== undefined) + queryParams.include_deleted = params.includeDeleted; + if (params?.region) queryParams.region = params.region; + if (params?.state) queryParams.state = params.state; + if (params?.summary !== undefined) queryParams.summary = params.summary; + if (params?.updatedAfter) queryParams.updated_after = params.updatedAfter; + if (params?.cursor) queryParams.cursor = params.cursor; + if (params?.limit !== undefined) queryParams.limit = params.limit; + return queryParams; +}; + let buildMachineConfig = (config: MachineConfig): Record => { let result: Record = { image: config.image @@ -631,28 +1048,51 @@ let buildMachineConfig = (config: MachineConfig): Record => { result.guest = {}; if (config.guest.cpus !== undefined) result.guest.cpus = config.guest.cpus; if (config.guest.memoryMb !== undefined) result.guest.memory_mb = config.guest.memoryMb; + if (config.guest.maxMemoryMb !== undefined) + result.guest.max_memory_mb = config.guest.maxMemoryMb; if (config.guest.cpuKind) result.guest.cpu_kind = config.guest.cpuKind; if (config.guest.gpuKind) result.guest.gpu_kind = config.guest.gpuKind; + if (config.guest.gpus !== undefined) result.guest.gpus = config.guest.gpus; + if (config.guest.hostDedicationId) + result.guest.host_dedication_id = config.guest.hostDedicationId; + if (config.guest.kernelArgs) result.guest.kernel_args = config.guest.kernelArgs; } if (config.size) result.size = config.size; if (config.services) { result.services = config.services.map(s => ({ ports: s.ports.map(p => ({ port: p.port, + start_port: p.startPort, + end_port: p.endPort, handlers: p.handlers, force_https: p.forceHttps })), protocol: s.protocol, internal_port: s.internalPort, - auto_stop_machines: s.autoStopMachines, - auto_start_machines: s.autoStartMachines, - min_machines_running: s.minMachinesRunning + autostop: + s.autostop ?? + (s.autoStopMachines === undefined ? undefined : s.autoStopMachines ? 'stop' : 'off'), + autostart: s.autostart ?? s.autoStartMachines, + min_machines_running: s.minMachinesRunning, + concurrency: s.concurrency + ? { + type: s.concurrency.type, + soft_limit: s.concurrency.softLimit, + hard_limit: s.concurrency.hardLimit + } + : undefined })); } if (config.mounts) { result.mounts = config.mounts.map(m => ({ volume: m.volume, - path: m.path + path: m.path, + name: m.name, + size_gb: m.sizeGb, + add_size_gb: m.addSizeGb, + encrypted: m.encrypted, + extend_threshold_percent: m.extendThresholdPercent, + size_gb_limit: m.sizeGbLimit })); } if (config.init) { @@ -660,6 +1100,9 @@ let buildMachineConfig = (config: MachineConfig): Record => { if (config.init.exec) result.init.exec = config.init.exec; if (config.init.entrypoint) result.init.entrypoint = config.init.entrypoint; if (config.init.cmd) result.init.cmd = config.init.cmd; + if (config.init.kernelArgs) result.init.kernel_args = config.init.kernelArgs; + if (config.init.swapSizeMb !== undefined) + result.init.swap_size_mb = config.init.swapSizeMb; if (config.init.tty !== undefined) result.init.tty = config.init.tty; } if (config.restart) { @@ -667,12 +1110,15 @@ let buildMachineConfig = (config: MachineConfig): Record => { if (config.restart.policy) result.restart.policy = config.restart.policy; if (config.restart.maxRetries !== undefined) result.restart.max_retries = config.restart.maxRetries; + if (config.restart.gpuBidPrice !== undefined) + result.restart.gpu_bid_price = config.restart.gpuBidPrice; } if (config.metadata) result.metadata = config.metadata; if (config.metrics) { result.metrics = {}; if (config.metrics.port !== undefined) result.metrics.port = config.metrics.port; if (config.metrics.path) result.metrics.path = config.metrics.path; + if (config.metrics.https !== undefined) result.metrics.https = config.metrics.https; } if (config.schedule) result.schedule = config.schedule; if (config.autoDestroy !== undefined) result.auto_destroy = config.autoDestroy; @@ -680,7 +1126,8 @@ let buildMachineConfig = (config: MachineConfig): Record => { if (config.statics) { result.statics = config.statics.map(s => ({ guest_path: s.guestPath, - url_prefix: s.urlPrefix + url_prefix: s.urlPrefix, + index_document: s.indexDocument })); } if (config.dns) result.dns = config.dns; @@ -691,6 +1138,16 @@ let buildMachineConfig = (config: MachineConfig): Record => { result.stop_config.timeout = config.stopConfig.timeout; } if (config.standbys) result.standbys = config.standbys; + if (config.rootfs) { + result.rootfs = {}; + if (config.rootfs.persist) result.rootfs.persist = config.rootfs.persist; + if (config.rootfs.sizeGb !== undefined) result.rootfs.size_gb = config.rootfs.sizeGb; + } + if (config.files) result.files = config.files; + if (config.processes) result.processes = config.processes; + if (config.containers) result.containers = config.containers; + if (config.cacheDrive) result.cache_drive = config.cacheDrive; + if (config.spot) result.spot = config.spot; return result; }; diff --git a/integrations/fly-io/src/lib/errors.ts b/integrations/fly-io/src/lib/errors.ts new file mode 100644 index 0000000000..6dd8e24035 --- /dev/null +++ b/integrations/fly-io/src/lib/errors.ts @@ -0,0 +1,97 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractFlyIoMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + for (let key of ['error', 'message', 'detail', 'status']) { + addDetail(details, data[key]); + } + + let errors = data.errors; + if (Array.isArray(errors)) { + for (let item of errors) { + if (isRecord(item)) { + for (let key of ['message', 'detail', 'error']) { + addDetail(details, item[key]); + } + } else { + addDetail(details, item); + } + } + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getFlyIoErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let flyIoServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let flyIoApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getFlyIoErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = flyIoServiceError( + `Fly.io API ${operation} failed: ${statusLabel}${extractFlyIoMessage(error)}` + ); + serviceError.data.reason = 'fly_io_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/fly-io/src/tools/assign-ip-address.ts b/integrations/fly-io/src/tools/assign-ip-address.ts new file mode 100644 index 0000000000..e6474fa396 --- /dev/null +++ b/integrations/fly-io/src/tools/assign-ip-address.ts @@ -0,0 +1,49 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let assignIpAddress = SlateTool.create(spec, { + name: 'Assign IP Address', + key: 'assign_ip_address', + description: + 'Assign a new IP address to a Fly App. Use this when an app needs a public IPv4, IPv6, or service-specific address.', + constraints: [ + 'IP assignment can affect account billing or scarce shared account resources depending on address type.' + ] +}) + .input( + z.object({ + appName: z.string().describe('Name of the Fly App'), + type: z.string().describe('Fly.io IP assignment type, such as shared_v4, v4, or v6'), + orgSlug: z.string().optional().describe('Organization slug for the assignment'), + region: z.string().optional().describe('Region code for regional assignments'), + network: z.string().optional().describe('Network name for private networking'), + serviceName: z.string().optional().describe('Service name to associate with the IP') + }) + ) + .output( + z.object({ + ipAddress: z.string().describe('Assigned IP address'), + region: z.string().describe('Region for the assignment'), + serviceName: z.string().describe('Service name'), + shared: z.boolean().describe('Whether the IP is shared'), + createdAt: z.string().describe('Assignment creation timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let assignment = await client.assignIpAddress(ctx.input.appName, { + type: ctx.input.type, + orgSlug: ctx.input.orgSlug, + region: ctx.input.region, + network: ctx.input.network, + serviceName: ctx.input.serviceName + }); + + return { + output: assignment, + message: `Assigned IP **${assignment.ipAddress}** to app **${ctx.input.appName}**.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/control-machine.ts b/integrations/fly-io/src/tools/control-machine.ts index b2169c58d5..e5920655ac 100644 --- a/integrations/fly-io/src/tools/control-machine.ts +++ b/integrations/fly-io/src/tools/control-machine.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { flyIoServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -11,6 +12,7 @@ export let controlMachine = SlateTool.create(spec, { 'Use "start" to boot a stopped or suspended machine.', 'Use "stop" to gracefully shut down a running machine.', 'Use "restart" to stop and re-start a running machine.', + 'Use "signal" to send a Unix signal to a running machine.', 'Use "suspend" to snapshot the machine state including memory.', 'Use "cordon" to prevent Fly Proxy from routing requests to this machine.', 'Use "uncordon" to resume request routing to this machine.' @@ -21,19 +23,19 @@ export let controlMachine = SlateTool.create(spec, { appName: z.string().describe('Name of the Fly App'), machineId: z.string().describe('ID of the machine'), action: z - .enum(['start', 'stop', 'restart', 'suspend', 'cordon', 'uncordon']) + .enum(['start', 'stop', 'restart', 'signal', 'suspend', 'cordon', 'uncordon']) .describe('Action to perform'), signal: z .string() .optional() .describe( - 'Stop/restart signal (e.g. "SIGTERM", "SIGINT"). Only used with stop and restart actions.' + 'Unix signal (e.g. "SIGTERM", "SIGINT"). Used with stop, restart, and signal actions.' ), timeout: z - .number() + .string() .optional() .describe( - 'Grace period in seconds before SIGKILL. Only used with stop and restart actions.' + 'Grace period as a Go duration string or seconds string (for example "10s"). Only used with stop and restart actions.' ) }) ) @@ -71,6 +73,12 @@ export let controlMachine = SlateTool.create(spec, { case 'restart': await client.restartMachine(appName, machineId, { signal, timeout }); break; + case 'signal': + if (!signal) { + throw flyIoServiceError('signal is required for signal action'); + } + await client.signalMachine(appName, machineId, signal); + break; case 'suspend': await client.suspendMachine(appName, machineId); break; diff --git a/integrations/fly-io/src/tools/create-machine.ts b/integrations/fly-io/src/tools/create-machine.ts index 59b123645d..a9b0e2b13e 100644 --- a/integrations/fly-io/src/tools/create-machine.ts +++ b/integrations/fly-io/src/tools/create-machine.ts @@ -8,6 +8,8 @@ let serviceSchema = z.object({ .array( z.object({ port: z.number().describe('External port number'), + startPort: z.number().optional().describe('Start of external port range'), + endPort: z.number().optional().describe('End of external port range'), handlers: z .array(z.string()) .optional() @@ -18,22 +20,45 @@ let serviceSchema = z.object({ .describe('Port configurations for the service'), protocol: z.string().describe('Protocol (tcp or udp)'), internalPort: z.number().describe('Port the application listens on inside the machine'), - autoStopMachines: z.boolean().optional().describe('Automatically stop machines when idle'), - autoStartMachines: z + autostop: z + .enum(['off', 'stop', 'suspend']) + .optional() + .describe('Current Fly Proxy autostop behavior for this service'), + autostart: z .boolean() .optional() .describe('Automatically start machines on incoming requests'), + autoStopMachines: z + .boolean() + .optional() + .describe('Deprecated compatibility input; use autostop instead'), + autoStartMachines: z + .boolean() + .optional() + .describe('Deprecated compatibility input; use autostart instead'), minMachinesRunning: z .number() .optional() - .describe('Minimum number of machines to keep running') + .describe('Minimum number of machines to keep running'), + concurrency: z + .object({ + type: z.string().optional().describe('Concurrency metric type'), + softLimit: z.number().optional().describe('Preferred concurrency limit'), + hardLimit: z.number().optional().describe('Maximum concurrency limit') + }) + .optional() + .describe('Fly Proxy service concurrency settings') }); let guestSchema = z.object({ cpus: z.number().optional().describe('Number of CPU cores (default: 1)'), memoryMb: z.number().optional().describe('Memory in MB, multiples of 256 (default: 256)'), + maxMemoryMb: z.number().optional().describe('Maximum memory in MB'), cpuKind: z.enum(['shared', 'performance']).optional().describe('CPU type'), - gpuKind: z.string().optional().describe('GPU type (e.g. "a100-pcie-40gb")') + gpuKind: z.string().optional().describe('GPU type (e.g. "a100-pcie-40gb")'), + gpus: z.number().optional().describe('Number of GPUs'), + hostDedicationId: z.string().optional().describe('Dedicated host ID'), + kernelArgs: z.array(z.string()).optional().describe('Kernel arguments') }); export let createMachine = SlateTool.create(spec, { @@ -57,6 +82,18 @@ export let createMachine = SlateTool.create(spec, { .optional() .describe('Region code to deploy in (e.g. "ord", "lhr", "sin", "iad")'), skipLaunch: z.boolean().optional().describe('Create the machine but do not start it'), + skipServiceRegistration: z + .boolean() + .optional() + .describe('Create the machine without registering services with Fly Proxy'), + skipSecrets: z + .boolean() + .optional() + .describe('Do not inject app secrets into the machine'), + minSecretsVersion: z + .number() + .optional() + .describe('Minimum app secrets version required before machine creation'), image: z .string() .describe('Container image to run (e.g. "registry.fly.io/my-app:latest")'), @@ -92,8 +129,12 @@ export let createMachine = SlateTool.create(spec, { .describe('Process init overrides'), restart: z .object({ - policy: z.enum(['no', 'on-failure', 'always']).optional().describe('Restart policy'), - maxRetries: z.number().optional().describe('Max retries for on-failure policy') + policy: z + .enum(['no', 'on-failure', 'always', 'spot-price']) + .optional() + .describe('Restart policy'), + maxRetries: z.number().optional().describe('Max retries for on-failure policy'), + gpuBidPrice: z.number().optional().describe('GPU bid price for spot Machines') }) .optional() .describe('Restart configuration'), @@ -127,6 +168,9 @@ export let createMachine = SlateTool.create(spec, { name: ctx.input.machineName, region: ctx.input.region, skipLaunch: ctx.input.skipLaunch, + skipServiceRegistration: ctx.input.skipServiceRegistration, + skipSecrets: ctx.input.skipSecrets, + minSecretsVersion: ctx.input.minSecretsVersion, config: { image: ctx.input.image, guest: ctx.input.guest, diff --git a/integrations/fly-io/src/tools/delete-ip-assignment.ts b/integrations/fly-io/src/tools/delete-ip-assignment.ts new file mode 100644 index 0000000000..419bcbcc01 --- /dev/null +++ b/integrations/fly-io/src/tools/delete-ip-assignment.ts @@ -0,0 +1,35 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let deleteIpAssignment = SlateTool.create(spec, { + name: 'Delete IP Assignment', + key: 'delete_ip_assignment', + description: 'Remove an IP assignment from a Fly App.', + tags: { + destructive: true + }, + constraints: ['Only remove IPs that are known to belong to the app and are safe to release.'] +}) + .input( + z.object({ + appName: z.string().describe('Name of the Fly App'), + ipAddress: z.string().describe('IP address to remove from the app') + }) + ) + .output( + z.object({ + deleted: z.boolean().describe('Whether the IP assignment was removed') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + await client.deleteIpAssignment(ctx.input.appName, ctx.input.ipAddress); + + return { + output: { deleted: true }, + message: `Removed IP assignment **${ctx.input.ipAddress}** from app **${ctx.input.appName}**.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/get-machine-memory.ts b/integrations/fly-io/src/tools/get-machine-memory.ts new file mode 100644 index 0000000000..412dfe4317 --- /dev/null +++ b/integrations/fly-io/src/tools/get-machine-memory.ts @@ -0,0 +1,35 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getMachineMemory = SlateTool.create(spec, { + name: 'Get Machine Memory', + key: 'get_machine_memory', + description: 'Get the current memory limit and available capacity for a Fly Machine.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + appName: z.string().describe('Name of the Fly App'), + machineId: z.string().describe('ID of the Fly Machine') + }) + ) + .output( + z.object({ + limitMb: z.number().describe('Current machine memory limit in MB'), + availableMb: z.number().describe('Available memory capacity in MB') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let memory = await client.getMachineMemory(ctx.input.appName, ctx.input.machineId); + + return { + output: memory, + message: `Machine **${ctx.input.machineId}** has **${memory.limitMb} MB** memory limit and **${memory.availableMb} MB** available.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/index.ts b/integrations/fly-io/src/tools/index.ts index 124ddc2e4f..7be70198cf 100644 --- a/integrations/fly-io/src/tools/index.ts +++ b/integrations/fly-io/src/tools/index.ts @@ -1,13 +1,23 @@ +export * from './assign-ip-address'; export * from './control-machine'; export * from './create-app'; export * from './create-machine'; export * from './create-volume'; export * from './delete-app'; +export * from './delete-ip-assignment'; export * from './delete-machine'; export * from './get-app'; export * from './get-machine'; +export * from './get-machine-memory'; export * from './list-apps'; +export * from './list-ip-assignments'; +export * from './list-machine-events'; +export * from './list-machine-processes'; +export * from './list-machine-versions'; export * from './list-machines'; +export * from './list-org-machines'; +export * from './list-org-volumes'; +export * from './list-regions'; export * from './list-volumes'; export * from './manage-certificates'; export * from './manage-machine-lease'; diff --git a/integrations/fly-io/src/tools/list-ip-assignments.ts b/integrations/fly-io/src/tools/list-ip-assignments.ts new file mode 100644 index 0000000000..dfaa8d6171 --- /dev/null +++ b/integrations/fly-io/src/tools/list-ip-assignments.ts @@ -0,0 +1,44 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listIpAssignments = SlateTool.create(spec, { + name: 'List IP Assignments', + key: 'list_ip_assignments', + description: + 'List public IP assignments for a Fly App, including shared status, region, and service name.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + appName: z.string().describe('Name of the Fly App') + }) + ) + .output( + z.object({ + ipAssignments: z + .array( + z.object({ + ipAddress: z.string().describe('Assigned IP address'), + region: z.string().describe('Region for the assignment'), + serviceName: z.string().describe('Service name'), + shared: z.boolean().describe('Whether the IP is shared'), + createdAt: z.string().describe('Assignment creation timestamp') + }) + ) + .describe('IP assignments') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let ipAssignments = await client.listIpAssignments(ctx.input.appName); + + return { + output: { ipAssignments }, + message: `Found **${ipAssignments.length}** IP assignment(s) for app **${ctx.input.appName}**.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/list-machine-events.ts b/integrations/fly-io/src/tools/list-machine-events.ts new file mode 100644 index 0000000000..9beaae1194 --- /dev/null +++ b/integrations/fly-io/src/tools/list-machine-events.ts @@ -0,0 +1,49 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listMachineEvents = SlateTool.create(spec, { + name: 'List Machine Events', + key: 'list_machine_events', + description: + 'List recent events for a Fly Machine. Use this to inspect lifecycle operations, failures, and API actions associated with one machine.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + appName: z.string().describe('Name of the Fly App'), + machineId: z.string().describe('ID of the Fly Machine'), + limit: z.number().optional().describe('Number of events to fetch, max 50') + }) + ) + .output( + z.object({ + events: z + .array( + z.object({ + eventId: z.string().describe('Event ID'), + type: z.string().describe('Event type'), + status: z.string().describe('Event status'), + source: z.string().describe('Event source'), + timestamp: z.number().describe('Unix timestamp'), + request: z.record(z.string(), z.any()).describe('Request details when present') + }) + ) + .describe('Machine events') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let events = await client.listMachineEvents(ctx.input.appName, ctx.input.machineId, { + limit: ctx.input.limit + }); + + return { + output: { events }, + message: `Found **${events.length}** event(s) for machine **${ctx.input.machineId}**.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/list-machine-processes.ts b/integrations/fly-io/src/tools/list-machine-processes.ts new file mode 100644 index 0000000000..8d885851e0 --- /dev/null +++ b/integrations/fly-io/src/tools/list-machine-processes.ts @@ -0,0 +1,60 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listMachineProcesses = SlateTool.create(spec, { + name: 'List Machine Processes', + key: 'list_machine_processes', + description: + 'List processes currently running on a Fly Machine, including CPU, memory, and listening socket details.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + appName: z.string().describe('Name of the Fly App'), + machineId: z.string().describe('ID of the Fly Machine'), + sortBy: z.string().optional().describe('Field to sort by'), + order: z.string().optional().describe('Sort order') + }) + ) + .output( + z.object({ + processes: z + .array( + z.object({ + pid: z.number().describe('Process ID'), + command: z.string().describe('Command'), + directory: z.string().describe('Working directory'), + cpu: z.number().describe('CPU usage'), + rss: z.number().describe('Resident memory size'), + rtime: z.number().describe('Runtime'), + stime: z.number().describe('System time'), + listenSockets: z + .array( + z.object({ + address: z.string().describe('Listening address'), + proto: z.string().describe('Protocol') + }) + ) + .describe('Listening sockets') + }) + ) + .describe('Machine processes') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let processes = await client.listMachineProcesses(ctx.input.appName, ctx.input.machineId, { + sortBy: ctx.input.sortBy, + order: ctx.input.order + }); + + return { + output: { processes }, + message: `Found **${processes.length}** process(es) on machine **${ctx.input.machineId}**.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/list-machine-versions.ts b/integrations/fly-io/src/tools/list-machine-versions.ts new file mode 100644 index 0000000000..3bf6e709ca --- /dev/null +++ b/integrations/fly-io/src/tools/list-machine-versions.ts @@ -0,0 +1,42 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listMachineVersions = SlateTool.create(spec, { + name: 'List Machine Versions', + key: 'list_machine_versions', + description: + 'List historical configuration versions for a Fly Machine. Use this before rollback or audit workflows.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + appName: z.string().describe('Name of the Fly App'), + machineId: z.string().describe('ID of the Fly Machine') + }) + ) + .output( + z.object({ + versions: z + .array( + z.object({ + version: z.string().describe('Machine config version'), + userConfig: z.record(z.string(), z.any()).describe('User machine config') + }) + ) + .describe('Machine versions') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let versions = await client.listMachineVersions(ctx.input.appName, ctx.input.machineId); + + return { + output: { versions }, + message: `Found **${versions.length}** version(s) for machine **${ctx.input.machineId}**.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/list-machines.ts b/integrations/fly-io/src/tools/list-machines.ts index 08a54183b2..cb184470c2 100644 --- a/integrations/fly-io/src/tools/list-machines.ts +++ b/integrations/fly-io/src/tools/list-machines.ts @@ -18,7 +18,17 @@ export let listMachines = SlateTool.create(spec, { region: z .string() .optional() - .describe('Filter machines by region code (e.g. "ord", "lhr", "sin")') + .describe('Filter machines by region code (e.g. "ord", "lhr", "sin")'), + state: z + .string() + .optional() + .describe( + 'Comma-separated machine states to include (created, started, stopped, suspended)' + ), + summary: z + .boolean() + .optional() + .describe('Only return summary fields from the Fly.io API') }) ) .output( @@ -46,7 +56,9 @@ export let listMachines = SlateTool.create(spec, { let client = createClient(ctx); let machines = await client.listMachines(ctx.input.appName, { includeDeleted: ctx.input.includeDeleted, - region: ctx.input.region + region: ctx.input.region, + state: ctx.input.state, + summary: ctx.input.summary }); let machinesSummary = machines.map(m => ({ diff --git a/integrations/fly-io/src/tools/list-org-machines.ts b/integrations/fly-io/src/tools/list-org-machines.ts new file mode 100644 index 0000000000..0f782fa43d --- /dev/null +++ b/integrations/fly-io/src/tools/list-org-machines.ts @@ -0,0 +1,71 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listOrgMachines = SlateTool.create(spec, { + name: 'List Organization Machines', + key: 'list_org_machines', + description: + 'List Fly Machines across an organization, with filters for region, state, deletion status, and pagination. Use this for org-wide inventory and polling workflows.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + orgSlug: z.string().describe('Fly.io organization slug'), + includeDeleted: z.boolean().optional().describe('Include deleted machines'), + region: z.string().optional().describe('Filter by region code'), + state: z.string().optional().describe('Comma-separated machine states to include'), + summary: z.boolean().optional().describe('Omit machine config details from responses'), + updatedAfter: z + .string() + .optional() + .describe('Only return machines updated after this RFC 3339 timestamp'), + cursor: z.string().optional().describe('Pagination cursor from a previous response'), + limit: z.number().optional().describe('Maximum machines to request, up to 1000') + }) + ) + .output( + z.object({ + machines: z + .array( + z.object({ + appName: z.string().describe('App the machine belongs to'), + machineId: z.string().describe('Machine ID'), + machineName: z.string().describe('Machine name'), + state: z.string().describe('Machine state'), + region: z.string().describe('Region code'), + privateIp: z.string().describe('Private IPv6 address'), + version: z.string().describe('Machine version'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().describe('Last update timestamp'), + config: z.record(z.string(), z.any()).describe('Machine config when returned') + }) + ) + .describe('Organization Machines'), + nextCursor: z.string().describe('Cursor for the next page, if any'), + lastMachineId: z.string().describe('Last machine ID in the response'), + lastUpdatedAt: z.string().describe('Last updated_at value in the response'), + errorRegions: z.array(z.string()).describe('Regions Fly.io could not query') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.listOrgMachines(ctx.input.orgSlug, { + includeDeleted: ctx.input.includeDeleted, + region: ctx.input.region, + state: ctx.input.state, + summary: ctx.input.summary, + updatedAfter: ctx.input.updatedAfter, + cursor: ctx.input.cursor, + limit: ctx.input.limit + }); + + return { + output: result, + message: `Found **${result.machines.length}** machine(s) in organization **${ctx.input.orgSlug}**.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/list-org-volumes.ts b/integrations/fly-io/src/tools/list-org-volumes.ts new file mode 100644 index 0000000000..78694192c7 --- /dev/null +++ b/integrations/fly-io/src/tools/list-org-volumes.ts @@ -0,0 +1,73 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listOrgVolumes = SlateTool.create(spec, { + name: 'List Organization Volumes', + key: 'list_org_volumes', + description: + 'List Fly Volumes across an organization, with filters for region, state, deletion status, and pagination. Use this for storage inventory across apps.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + orgSlug: z.string().describe('Fly.io organization slug'), + includeDeleted: z.boolean().optional().describe('Include deleted volumes'), + region: z.string().optional().describe('Filter by region code'), + state: z.string().optional().describe('Comma-separated volume states to include'), + summary: z.boolean().optional().describe('Only return volume summary fields'), + updatedAfter: z + .string() + .optional() + .describe('Only return volumes updated after this RFC 3339 timestamp'), + cursor: z.string().optional().describe('Pagination cursor from a previous response'), + limit: z.number().optional().describe('Maximum volumes to request, up to 1000') + }) + ) + .output( + z.object({ + volumes: z + .array( + z.object({ + appName: z.string().describe('App the volume belongs to'), + volumeId: z.string().describe('Volume ID'), + volumeName: z.string().describe('Volume name'), + state: z.string().describe('Volume state'), + sizeGb: z.number().describe('Volume size in GB'), + region: z.string().describe('Region code'), + zone: z.string().describe('Hardware zone'), + encrypted: z.boolean().describe('Whether the volume is encrypted'), + attachedMachineId: z.string().nullable().describe('Attached machine ID, if any'), + autoBackupEnabled: z.boolean().describe('Whether automatic backups are enabled'), + snapshotRetention: z.number().describe('Snapshot retention in days'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().describe('Last update timestamp') + }) + ) + .describe('Organization volumes'), + nextCursor: z.string().describe('Cursor for the next page, if any'), + lastVolumeId: z.string().describe('Last volume ID in the response'), + lastUpdatedAt: z.string().describe('Last updated_at value in the response') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.listOrgVolumes(ctx.input.orgSlug, { + includeDeleted: ctx.input.includeDeleted, + region: ctx.input.region, + state: ctx.input.state, + summary: ctx.input.summary, + updatedAfter: ctx.input.updatedAfter, + cursor: ctx.input.cursor, + limit: ctx.input.limit + }); + + return { + output: result, + message: `Found **${result.volumes.length}** volume(s) in organization **${ctx.input.orgSlug}**.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/list-regions.ts b/integrations/fly-io/src/tools/list-regions.ts new file mode 100644 index 0000000000..e44f81f88e --- /dev/null +++ b/integrations/fly-io/src/tools/list-regions.ts @@ -0,0 +1,44 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listRegions = SlateTool.create(spec, { + name: 'List Regions', + key: 'list_regions', + description: + 'List Fly.io platform regions and the nearest region for the current API caller. Use this before creating Machines or volumes when a region code is needed.', + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + nearest: z.string().describe('Nearest Fly.io region code for the API caller'), + regions: z + .array( + z.object({ + code: z.string().describe('Region code'), + name: z.string().describe('Region display name'), + geoRegion: z.string().describe('Geographic grouping'), + latitude: z.number().describe('Latitude'), + longitude: z.number().describe('Longitude'), + gatewayAvailable: z.boolean().describe('Whether gateway access is available'), + requiresPaidPlan: z.boolean().describe('Whether the region requires a paid plan'), + deprecated: z.boolean().describe('Whether the region is deprecated') + }) + ) + .describe('Fly.io regions') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.listRegions(); + + return { + output: result, + message: `Found **${result.regions.length}** Fly.io region(s). Nearest: **${result.nearest || 'unknown'}**.` + }; + }) + .build(); diff --git a/integrations/fly-io/src/tools/manage-certificates.ts b/integrations/fly-io/src/tools/manage-certificates.ts index 86ba78fafd..076124b088 100644 --- a/integrations/fly-io/src/tools/manage-certificates.ts +++ b/integrations/fly-io/src/tools/manage-certificates.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { flyIoServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -90,7 +91,7 @@ export let manageCertificates = SlateTool.create(spec, { }; } case 'get': { - if (!hostname) throw new Error('hostname is required for get action'); + if (!hostname) throw flyIoServiceError('hostname is required for get action'); let cert = await client.getCertificate(appName, hostname); return { output: { certificate: cert }, @@ -98,7 +99,7 @@ export let manageCertificates = SlateTool.create(spec, { }; } case 'request_acme': { - if (!hostname) throw new Error('hostname is required for request_acme action'); + if (!hostname) throw flyIoServiceError('hostname is required for request_acme action'); let cert = await client.requestAcmeCertificate(appName, hostname); return { output: { certificate: cert }, @@ -106,7 +107,7 @@ export let manageCertificates = SlateTool.create(spec, { }; } case 'check': { - if (!hostname) throw new Error('hostname is required for check action'); + if (!hostname) throw flyIoServiceError('hostname is required for check action'); let cert = await client.checkCertificate(appName, hostname); return { output: { certificate: cert }, @@ -114,7 +115,7 @@ export let manageCertificates = SlateTool.create(spec, { }; } case 'delete': { - if (!hostname) throw new Error('hostname is required for delete action'); + if (!hostname) throw flyIoServiceError('hostname is required for delete action'); await client.deleteCertificate(appName, hostname); return { output: { deleted: true }, diff --git a/integrations/fly-io/src/tools/manage-machine-lease.ts b/integrations/fly-io/src/tools/manage-machine-lease.ts index 635eecf0a3..ae54ae143f 100644 --- a/integrations/fly-io/src/tools/manage-machine-lease.ts +++ b/integrations/fly-io/src/tools/manage-machine-lease.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { flyIoServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -76,7 +77,7 @@ export let manageMachineLease = SlateTool.create(spec, { // release if (!ctx.input.nonce) { - throw new Error('Nonce is required to release a lease'); + throw flyIoServiceError('Nonce is required to release a lease'); } await client.releaseLease(appName, machineId, ctx.input.nonce); return { diff --git a/integrations/fly-io/src/tools/manage-machine-metadata.ts b/integrations/fly-io/src/tools/manage-machine-metadata.ts index 3ff9ab68ad..b35d121a77 100644 --- a/integrations/fly-io/src/tools/manage-machine-metadata.ts +++ b/integrations/fly-io/src/tools/manage-machine-metadata.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { flyIoServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -49,8 +50,9 @@ export let manageMachineMetadata = SlateTool.create(spec, { }; } case 'set': { - if (!ctx.input.key) throw new Error('key is required for set action'); - if (ctx.input.value === undefined) throw new Error('value is required for set action'); + if (!ctx.input.key) throw flyIoServiceError('key is required for set action'); + if (ctx.input.value === undefined) + throw flyIoServiceError('value is required for set action'); await client.setMachineMetadata(appName, machineId, ctx.input.key, ctx.input.value); return { output: { updated: true }, @@ -58,7 +60,7 @@ export let manageMachineMetadata = SlateTool.create(spec, { }; } case 'delete': { - if (!ctx.input.key) throw new Error('key is required for delete action'); + if (!ctx.input.key) throw flyIoServiceError('key is required for delete action'); await client.deleteMachineMetadata(appName, machineId, ctx.input.key); return { output: { deleted: true }, diff --git a/integrations/fly-io/src/tools/manage-secrets.ts b/integrations/fly-io/src/tools/manage-secrets.ts index 80ea6c5b84..604159e597 100644 --- a/integrations/fly-io/src/tools/manage-secrets.ts +++ b/integrations/fly-io/src/tools/manage-secrets.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { flyIoServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -9,6 +10,7 @@ export let manageSecrets = SlateTool.create(spec, { description: `List, set, or delete app-level secrets. Secrets are encrypted at rest and exposed as environment variables to Machines at boot time. Setting secrets does not immediately affect running Machines; they pick up changes on next launch.`, instructions: [ 'Use "list" to see secret names and digests (values are never exposed).', + 'Use "get" to retrieve metadata for one secret by name.', 'Use "set" to create or update one or more secrets at once.', 'Use "delete" to remove a specific secret by name.' ], @@ -20,7 +22,7 @@ export let manageSecrets = SlateTool.create(spec, { .input( z.object({ appName: z.string().describe('Name of the Fly App'), - action: z.enum(['list', 'set', 'delete']).describe('Action to perform'), + action: z.enum(['list', 'get', 'set', 'delete']).describe('Action to perform'), secrets: z .record(z.string(), z.string()) .optional() @@ -28,7 +30,7 @@ export let manageSecrets = SlateTool.create(spec, { secretName: z .string() .optional() - .describe('Name of the secret to delete (for "delete" action)') + .describe('Name of the secret to get or delete (for "get" and "delete" actions)') }) ) .output( @@ -36,16 +38,26 @@ export let manageSecrets = SlateTool.create(spec, { secrets: z .array( z.object({ - label: z.string().describe('Secret name'), - type: z.string().describe('Secret type'), + secretName: z.string().describe('Secret name'), digest: z.string().describe('Secret digest (hash)'), - createdAt: z.string().describe('Creation timestamp') + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().describe('Last update timestamp') }) ) .optional() .describe('List of secrets (for list action)'), + secret: z + .object({ + secretName: z.string().describe('Secret name'), + digest: z.string().describe('Secret digest (hash)'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().describe('Last update timestamp') + }) + .optional() + .describe('Secret metadata (for get action)'), set: z.boolean().optional().describe('Whether secrets were set successfully'), - deleted: z.boolean().optional().describe('Whether the secret was deleted') + deleted: z.boolean().optional().describe('Whether the secret was deleted'), + version: z.number().optional().describe('App secrets version after mutation') }) ) .handleInvocation(async ctx => { @@ -60,24 +72,36 @@ export let manageSecrets = SlateTool.create(spec, { message: `Found **${secrets.length}** secret(s) in app **${appName}**.` }; } + case 'get': { + if (!ctx.input.secretName) { + throw flyIoServiceError('secretName is required for the get action'); + } + let secret = await client.getSecret(appName, ctx.input.secretName); + return { + output: { secret }, + message: `Secret **${secret.secretName}** exists in app **${appName}**.` + }; + } case 'set': { if (!ctx.input.secrets || Object.keys(ctx.input.secrets).length === 0) { - throw new Error('At least one secret key-value pair is required for the set action'); + throw flyIoServiceError( + 'At least one secret key-value pair is required for the set action' + ); } - await client.setSecrets(appName, ctx.input.secrets); + let result = await client.setSecrets(appName, ctx.input.secrets); let names = Object.keys(ctx.input.secrets); return { - output: { set: true }, + output: { set: true, secrets: result.secrets, version: result.version }, message: `Set **${names.length}** secret(s): ${names.map(n => `**${n}**`).join(', ')}.` }; } case 'delete': { if (!ctx.input.secretName) { - throw new Error('secretName is required for the delete action'); + throw flyIoServiceError('secretName is required for the delete action'); } - await client.deleteSecret(appName, ctx.input.secretName); + let result = await client.deleteSecret(appName, ctx.input.secretName); return { - output: { deleted: true }, + output: { deleted: true, version: result.version }, message: `Deleted secret **${ctx.input.secretName}** from app **${appName}**.` }; } diff --git a/integrations/fly-io/src/tools/manage-volume.ts b/integrations/fly-io/src/tools/manage-volume.ts index 5976a6a85e..340b54f1be 100644 --- a/integrations/fly-io/src/tools/manage-volume.ts +++ b/integrations/fly-io/src/tools/manage-volume.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { flyIoServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -113,7 +114,7 @@ export let manageVolume = SlateTool.create(spec, { }; } case 'extend': { - if (!ctx.input.sizeGb) throw new Error('sizeGb is required for extend action'); + if (!ctx.input.sizeGb) throw flyIoServiceError('sizeGb is required for extend action'); let result = await client.extendVolume(appName, volumeId, ctx.input.sizeGb); return { output: { diff --git a/integrations/fly-io/src/tools/update-machine.ts b/integrations/fly-io/src/tools/update-machine.ts index f5a787a2eb..c1a72bad76 100644 --- a/integrations/fly-io/src/tools/update-machine.ts +++ b/integrations/fly-io/src/tools/update-machine.ts @@ -21,13 +21,37 @@ export let updateMachine = SlateTool.create(spec, { .string() .optional() .describe('Lease nonce if the machine has an active lease'), + skipLaunch: z + .boolean() + .optional() + .describe('Update the machine without starting it afterward'), + skipServiceRegistration: z + .boolean() + .optional() + .describe('Update the machine without registering services with Fly Proxy'), + skipSecrets: z + .boolean() + .optional() + .describe('Do not inject app secrets into the machine'), + minSecretsVersion: z + .number() + .optional() + .describe('Minimum app secrets version required before update'), + currentVersion: z + .string() + .optional() + .describe('Machine version precondition for the update'), image: z.string().describe('Container image to run'), guest: z .object({ cpus: z.number().optional().describe('Number of CPU cores'), memoryMb: z.number().optional().describe('Memory in MB'), + maxMemoryMb: z.number().optional().describe('Maximum memory in MB'), cpuKind: z.enum(['shared', 'performance']).optional().describe('CPU type'), - gpuKind: z.string().optional().describe('GPU type') + gpuKind: z.string().optional().describe('GPU type'), + gpus: z.number().optional().describe('Number of GPUs'), + hostDedicationId: z.string().optional().describe('Dedicated host ID'), + kernelArgs: z.array(z.string()).optional().describe('Kernel arguments') }) .optional() .describe('Resource configuration'), @@ -39,12 +63,42 @@ export let updateMachine = SlateTool.create(spec, { ports: z.array( z.object({ port: z.number().describe('External port'), + startPort: z.number().optional().describe('Start of external port range'), + endPort: z.number().optional().describe('End of external port range'), handlers: z.array(z.string()).optional().describe('Connection handlers'), forceHttps: z.boolean().optional().describe('Redirect HTTP to HTTPS') }) ), protocol: z.string().describe('Protocol (tcp or udp)'), - internalPort: z.number().describe('Internal port') + internalPort: z.number().describe('Internal port'), + autostop: z + .enum(['off', 'stop', 'suspend']) + .optional() + .describe('Current Fly Proxy autostop behavior for this service'), + autostart: z + .boolean() + .optional() + .describe('Automatically start machines on incoming requests'), + autoStopMachines: z + .boolean() + .optional() + .describe('Deprecated compatibility input; use autostop instead'), + autoStartMachines: z + .boolean() + .optional() + .describe('Deprecated compatibility input; use autostart instead'), + minMachinesRunning: z + .number() + .optional() + .describe('Minimum number of machines to keep running'), + concurrency: z + .object({ + type: z.string().optional().describe('Concurrency metric type'), + softLimit: z.number().optional().describe('Preferred concurrency limit'), + hardLimit: z.number().optional().describe('Maximum concurrency limit') + }) + .optional() + .describe('Fly Proxy service concurrency settings') }) ) .optional() @@ -61,8 +115,9 @@ export let updateMachine = SlateTool.create(spec, { metadata: z.record(z.string(), z.string()).optional().describe('Machine metadata'), restart: z .object({ - policy: z.enum(['no', 'on-failure', 'always']).optional(), - maxRetries: z.number().optional() + policy: z.enum(['no', 'on-failure', 'always', 'spot-price']).optional(), + maxRetries: z.number().optional(), + gpuBidPrice: z.number().optional() }) .optional() .describe('Restart policy'), @@ -82,6 +137,11 @@ export let updateMachine = SlateTool.create(spec, { let client = createClient(ctx); let machine = await client.updateMachine(ctx.input.appName, ctx.input.machineId, { leaseNonce: ctx.input.leaseNonce, + skipLaunch: ctx.input.skipLaunch, + skipServiceRegistration: ctx.input.skipServiceRegistration, + skipSecrets: ctx.input.skipSecrets, + minSecretsVersion: ctx.input.minSecretsVersion, + currentVersion: ctx.input.currentVersion, config: { image: ctx.input.image, guest: ctx.input.guest, diff --git a/integrations/freshdesk/README.md b/integrations/freshdesk/README.md index 60f4e9d907..ced7ef8a1f 100644 --- a/integrations/freshdesk/README.md +++ b/integrations/freshdesk/README.md @@ -1,12 +1,16 @@ # Freshdesk -Manage customer support tickets, contacts, companies, and agents. Create, update, filter, merge, and delete support tickets with custom fields, tags, priorities, and assignments. Add replies, notes, and forward emails on ticket conversations. Manage contacts and companies with search, filter, import, and export capabilities. Maintain a knowledge base of articles organized in categories and folders. Run community discussion forums with topics and comments. Track time entries on tickets, configure SLA policies, and set up automation rules with webhook triggers. Manage canned response templates, custom objects, email mailboxes, collaboration threads, and agent groups with auto-assignment. Send outbound WhatsApp messages and handle satisfaction surveys and ratings. Support field service management with service tasks and technician scheduling. +Manage Freshdesk support tickets, contacts, companies, conversations, knowledge base articles, time entries, and helpdesk metadata. Create, update, search, and delete the core records agents use every day, inspect field definitions and routing metadata, and read account, SLA, business-hour, product, satisfaction-rating, and canned-response information. ## Tools ### Add Ticket Reply -Sends a reply on a ticket visible to the requester. Can also add internal notes for agent-only collaboration. Use \ +Sends a reply on a ticket visible to the requester. Can also add internal notes for agent-only collaboration. + +### Create Article + +Creates a new knowledge base article in a specified folder. ### Create Company @@ -20,10 +24,26 @@ Creates a new contact in Freshdesk. Contacts represent customers who submit supp Creates a new support ticket in Freshdesk. Supports setting subject, description, requester, priority, status, assignee, tags, and custom fields. Can also create outbound email tickets to initiate customer conversations. +### Delete Company + +Deletes a company from Freshdesk. + +### Delete Contact + +Soft deletes a Freshdesk contact. + ### Delete Ticket Deletes a ticket from Freshdesk. The ticket is moved to trash and can be restored from the Freshdesk UI within 30 days. +### Get Account + +Retrieves Freshdesk account details for the connected helpdesk. + +### Get Article + +Retrieves a single knowledge base article by ID. + ### Get Company Retrieves full details of a company by its ID including domains, health score, account tier, and custom fields. @@ -32,10 +52,30 @@ Retrieves full details of a company by its ID including domains, health score, a Retrieves a contact's full details by their ID, including email, phone, company association, tags, and custom fields. +### Get Current Agent + +Retrieves the currently authenticated Freshdesk agent. + +### Get Helpdesk Settings + +Retrieves Freshdesk helpdesk-level settings. + ### Get Ticket Retrieves a single ticket by ID with full details. Optionally includes conversations, requester info, company info, and stats (resolution/response times). +### List Business Hours + +Lists Freshdesk business-hour schedules. + +### List Canned Response Folders + +Lists folders that organize Freshdesk canned responses. + +### List Canned Responses + +Lists canned responses in a Freshdesk canned response folder. + ### List Agents Lists agents in the Freshdesk helpdesk. Can filter by email or state. Returns agent details including contact information, roles, and group memberships. @@ -52,6 +92,10 @@ Lists contacts from Freshdesk with optional filtering by email, phone, company, Lists all conversations (replies, notes, forwards) on a ticket. Returns the full conversation history including public replies and private agent notes. +### List Fields + +Lists Freshdesk field definitions for tickets, contacts, or companies. + ### List Groups Lists all agent groups in Freshdesk. Groups are used for ticket assignment and routing. @@ -64,17 +108,45 @@ Lists tickets from Freshdesk with optional filtering, ordering, and pagination. Browses the knowledge base hierarchy. Lists categories, or folders within a category, or articles within a folder depending on the parameters provided. +### List Products + +Lists Freshdesk products configured for the helpdesk. + +### List Satisfaction Ratings + +Lists Freshdesk satisfaction ratings across tickets. + +### List SLA Policies + +Lists Freshdesk SLA policies. + ### List Time Entries Lists all time entries logged on a specific ticket. Shows agent, hours spent, billable status, and notes. +### Create Time Entry + +Logs a time entry on a ticket. + +### Update Time Entry + +Updates a Freshdesk time entry. + +### Delete Time Entry + +Deletes a Freshdesk time entry from a ticket. + +### Search Companies + +Searches companies using Freshdesk's filter query language. + ### Search Contacts -Searches contacts using Freshdesk's filter query language. Supports filtering by name, email, phone, company, and custom fields. Example queries: \ +Searches contacts using Freshdesk's filter query language. Supports filtering by name, email, phone, company, and custom fields. ### Search Tickets -Searches tickets using Freshdesk's filter query language. Supports filtering by standard and custom fields with logical operators. Example queries: \ +Searches tickets using Freshdesk's filter query language. Supports filtering by standard and custom fields with logical operators. ### Update Company diff --git a/integrations/freshdesk/package.json b/integrations/freshdesk/package.json index 3af5ff4498..9f37dd3d67 100644 --- a/integrations/freshdesk/package.json +++ b/integrations/freshdesk/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/freshdesk/src/index.ts b/integrations/freshdesk/src/index.ts index a926fd66f6..8e45792bb1 100644 --- a/integrations/freshdesk/src/index.ts +++ b/integrations/freshdesk/src/index.ts @@ -7,24 +7,39 @@ import { createContact, createTicket, createTimeEntry, + deleteCompany, + deleteContact, deleteTicket, + deleteTimeEntry, + getAccount, getArticle, getCompany, getContact, + getCurrentAgent, + getHelpdeskSettings, getTicket, listAgents, + listBusinessHours, + listCannedResponseFolders, + listCannedResponses, listCompanies, listContacts, listConversations, + listFields, listGroups, listKnowledgeBase, + listProducts, + listSatisfactionRatings, + listSlaPolicies, listTickets, listTimeEntries, + searchCompanies, searchContacts, searchTickets, updateCompany, updateContact, - updateTicket + updateTicket, + updateTimeEntry } from './tools'; import { contactEvents, ticketEvents, ticketEventsWebhook } from './triggers'; @@ -42,19 +57,34 @@ export let provider = Slate.create({ createContact, getContact, updateContact, + deleteContact, listContacts, searchContacts, createCompany, getCompany, updateCompany, + deleteCompany, listCompanies, + searchCompanies, listAgents, + getCurrentAgent, listGroups, + getAccount, + getHelpdeskSettings, + listFields, + listProducts, + listBusinessHours, + listSlaPolicies, + listSatisfactionRatings, + listCannedResponseFolders, + listCannedResponses, listKnowledgeBase, getArticle, createArticle, listTimeEntries, - createTimeEntry + createTimeEntry, + updateTimeEntry, + deleteTimeEntry ], triggers: [ticketEvents, contactEvents, ticketEventsWebhook] }); diff --git a/integrations/freshdesk/src/lib/client.ts b/integrations/freshdesk/src/lib/client.ts index c3eaf26f23..c70bac1cec 100644 --- a/integrations/freshdesk/src/lib/client.ts +++ b/integrations/freshdesk/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { freshdeskApiError } from './errors'; export interface FreshdeskClientConfig { subdomain: string; @@ -17,6 +18,11 @@ export class FreshdeskClient { 'Content-Type': 'application/json' } }); + + this.axios.interceptors.response.use( + response => response, + error => Promise.reject(freshdeskApiError(error)) + ); } // ============ TICKETS ============ @@ -207,6 +213,21 @@ export class FreshdeskClient { return response.data; } + async listTicketFields(): Promise { + let response = await this.axios.get('/ticket_fields'); + return response.data; + } + + async listContactFields(): Promise { + let response = await this.axios.get('/contact_fields'); + return response.data; + } + + async listCompanyFields(): Promise { + let response = await this.axios.get('/company_fields'); + return response.data; + } + // ============ GROUPS ============ async listGroups(page?: number): Promise { @@ -321,4 +342,24 @@ export class FreshdeskClient { let response = await this.axios.get('/account'); return response.data; } + + async getHelpdeskSettings(): Promise { + let response = await this.axios.get('/settings/helpdesk'); + return response.data; + } + + async listProducts(): Promise { + let response = await this.axios.get('/products'); + return response.data; + } + + async listBusinessHours(): Promise { + let response = await this.axios.get('/business_hours'); + return response.data; + } + + async listSlaPolicies(): Promise { + let response = await this.axios.get('/sla_policies'); + return response.data; + } } diff --git a/integrations/freshdesk/src/lib/errors.ts b/integrations/freshdesk/src/lib/errors.ts new file mode 100644 index 0000000000..4326ff17c5 --- /dev/null +++ b/integrations/freshdesk/src/lib/errors.ts @@ -0,0 +1,89 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let message = value.trim(); + if (message && !messages.includes(message)) { + messages.push(message); + } +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectMessages(item, messages); + } + return; + } + + if (!isRecord(value)) { + pushMessage(messages, value); + return; + } + + for (let key of ['message', 'description', 'error', 'error_description', 'title']) { + pushMessage(messages, value[key]); + } + + for (let nested of Object.values(value)) { + if (Array.isArray(nested) || isRecord(nested)) { + collectMessages(nested, messages); + } + } +}; + +let extractFreshdeskMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectMessages(response?.data, messages); + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let freshdeskServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let freshdeskApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = freshdeskServiceError( + `Freshdesk API ${operation} failed: ${statusLabel}${extractFreshdeskMessage(error)}` + ); + + serviceError.data.reason = 'freshdesk_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/freshdesk/src/tools.schema.test.ts b/integrations/freshdesk/src/tools.schema.test.ts new file mode 100644 index 0000000000..5870604bfa --- /dev/null +++ b/integrations/freshdesk/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Freshdesk tool input schemas', provider.actions); diff --git a/integrations/freshdesk/src/tools/create-ticket.ts b/integrations/freshdesk/src/tools/create-ticket.ts index a0fad4b38f..2af3c7c508 100644 --- a/integrations/freshdesk/src/tools/create-ticket.ts +++ b/integrations/freshdesk/src/tools/create-ticket.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { FreshdeskClient } from '../lib/client'; +import { freshdeskServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createTicket = SlateTool.create(spec, { @@ -66,6 +67,12 @@ export let createTicket = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (!ctx.input.requesterId && !ctx.input.email && !ctx.input.phone) { + throw freshdeskServiceError( + 'Create Ticket requires requesterId, email, or phone to identify the requester.' + ); + } + let client = new FreshdeskClient({ subdomain: ctx.config.subdomain, token: ctx.auth.token diff --git a/integrations/freshdesk/src/tools/delete-company.ts b/integrations/freshdesk/src/tools/delete-company.ts new file mode 100644 index 0000000000..93e443fe63 --- /dev/null +++ b/integrations/freshdesk/src/tools/delete-company.ts @@ -0,0 +1,42 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { FreshdeskClient } from '../lib/client'; +import { spec } from '../spec'; + +export let deleteCompany = SlateTool.create(spec, { + name: 'Delete Company', + key: 'delete_company', + description: `Deletes a company from Freshdesk. Use when cleaning up obsolete account records after moving contacts or test data elsewhere.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + companyId: z.number().describe('ID of the company to delete') + }) + ) + .output( + z.object({ + companyId: z.number().describe('ID of the deleted company'), + deleted: z.boolean().describe('Whether the deletion request succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + await client.deleteCompany(ctx.input.companyId); + + return { + output: { + companyId: ctx.input.companyId, + deleted: true + }, + message: `Deleted company **#${ctx.input.companyId}**` + }; + }) + .build(); diff --git a/integrations/freshdesk/src/tools/delete-contact.ts b/integrations/freshdesk/src/tools/delete-contact.ts new file mode 100644 index 0000000000..257713429a --- /dev/null +++ b/integrations/freshdesk/src/tools/delete-contact.ts @@ -0,0 +1,42 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { FreshdeskClient } from '../lib/client'; +import { spec } from '../spec'; + +export let deleteContact = SlateTool.create(spec, { + name: 'Delete Contact', + key: 'delete_contact', + description: `Soft deletes a Freshdesk contact. Use this for cleanup or removing duplicate/test requester records; hard deletion is intentionally not exposed because it is irreversible.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + contactId: z.number().describe('ID of the contact to soft delete') + }) + ) + .output( + z.object({ + contactId: z.number().describe('ID of the deleted contact'), + deleted: z.boolean().describe('Whether the deletion request succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + await client.deleteContact(ctx.input.contactId); + + return { + output: { + contactId: ctx.input.contactId, + deleted: true + }, + message: `Deleted contact **#${ctx.input.contactId}**` + }; + }) + .build(); diff --git a/integrations/freshdesk/src/tools/index.ts b/integrations/freshdesk/src/tools/index.ts index a904875b78..45278217f0 100644 --- a/integrations/freshdesk/src/tools/index.ts +++ b/integrations/freshdesk/src/tools/index.ts @@ -2,6 +2,8 @@ export * from './add-ticket-reply'; export * from './create-company'; export * from './create-contact'; export * from './create-ticket'; +export * from './delete-company'; +export * from './delete-contact'; export * from './delete-ticket'; export * from './get-company'; export * from './get-contact'; @@ -14,8 +16,10 @@ export * from './list-groups'; export * from './list-tickets'; export * from './manage-knowledge-base'; export * from './manage-time-entries'; +export * from './search-companies'; export * from './search-contacts'; export * from './search-tickets'; +export * from './support-metadata'; export * from './update-company'; export * from './update-contact'; export * from './update-ticket'; diff --git a/integrations/freshdesk/src/tools/manage-knowledge-base.ts b/integrations/freshdesk/src/tools/manage-knowledge-base.ts index b02d9d7022..8577c1c11b 100644 --- a/integrations/freshdesk/src/tools/manage-knowledge-base.ts +++ b/integrations/freshdesk/src/tools/manage-knowledge-base.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { FreshdeskClient } from '../lib/client'; +import { freshdeskServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listKnowledgeBase = SlateTool.create(spec, { @@ -69,6 +70,12 @@ export let listKnowledgeBase = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.categoryId && ctx.input.folderId) { + throw freshdeskServiceError( + 'Provide either categoryId to list folders or folderId to list articles, not both.' + ); + } + let client = new FreshdeskClient({ subdomain: ctx.config.subdomain, token: ctx.auth.token diff --git a/integrations/freshdesk/src/tools/manage-time-entries.ts b/integrations/freshdesk/src/tools/manage-time-entries.ts index c100d86925..77e10a4b86 100644 --- a/integrations/freshdesk/src/tools/manage-time-entries.ts +++ b/integrations/freshdesk/src/tools/manage-time-entries.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { FreshdeskClient } from '../lib/client'; +import { freshdeskServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listTimeEntries = SlateTool.create(spec, { @@ -127,3 +128,99 @@ export let createTimeEntry = SlateTool.create(spec, { }; }) .build(); + +export let updateTimeEntry = SlateTool.create(spec, { + name: 'Update Time Entry', + key: 'update_time_entry', + description: `Updates a Freshdesk time entry. Use this to correct the time spent, billable flag, agent attribution, or work note on an existing ticket time log.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + timeEntryId: z.number().describe('ID of the time entry to update'), + timeSpent: z.string().optional().describe('Time spent in HH:MM format'), + agentId: z.number().optional().describe('Updated agent ID'), + billable: z.boolean().optional().describe('Whether the time entry is billable'), + note: z.string().optional().describe('Updated work note') + }) + ) + .output( + z.object({ + timeEntryId: z.number().describe('ID of the updated time entry'), + ticketId: z.number().nullable().describe('Parent ticket ID'), + timeSpent: z.string().nullable().describe('Time spent in HH:MM format'), + billable: z.boolean().describe('Whether the time is billable'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .handleInvocation(async ctx => { + let updateData: Record = {}; + if (ctx.input.timeSpent !== undefined) updateData.time_spent = ctx.input.timeSpent; + if (ctx.input.agentId !== undefined) updateData.agent_id = ctx.input.agentId; + if (ctx.input.billable !== undefined) updateData.billable = ctx.input.billable; + if (ctx.input.note !== undefined) updateData.note = ctx.input.note; + + if (Object.keys(updateData).length === 0) { + throw freshdeskServiceError('Provide at least one field to update on the time entry.'); + } + + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let entry = await client.updateTimeEntry(ctx.input.timeEntryId, updateData); + + return { + output: { + timeEntryId: entry.id, + ticketId: entry.ticket_id ?? null, + timeSpent: entry.time_spent ?? null, + billable: entry.billable ?? false, + updatedAt: entry.updated_at ?? null + }, + message: `Updated time entry **#${entry.id}**` + }; + }) + .build(); + +export let deleteTimeEntry = SlateTool.create(spec, { + name: 'Delete Time Entry', + key: 'delete_time_entry', + description: `Deletes a Freshdesk time entry from a ticket.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + timeEntryId: z.number().describe('ID of the time entry to delete') + }) + ) + .output( + z.object({ + timeEntryId: z.number().describe('ID of the deleted time entry'), + deleted: z.boolean().describe('Whether deletion succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + await client.deleteTimeEntry(ctx.input.timeEntryId); + + return { + output: { + timeEntryId: ctx.input.timeEntryId, + deleted: true + }, + message: `Deleted time entry **#${ctx.input.timeEntryId}**` + }; + }) + .build(); diff --git a/integrations/freshdesk/src/tools/search-companies.ts b/integrations/freshdesk/src/tools/search-companies.ts new file mode 100644 index 0000000000..696f8eda82 --- /dev/null +++ b/integrations/freshdesk/src/tools/search-companies.ts @@ -0,0 +1,67 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { FreshdeskClient } from '../lib/client'; +import { spec } from '../spec'; + +export let searchCompanies = SlateTool.create(spec, { + name: 'Search Companies', + key: 'search_companies', + description: `Searches companies using Freshdesk's filter query language. Supports fields such as name, domains, custom fields, health score, and account tier.`, + instructions: [ + 'Query uses Freshdesk filter syntax with field:value pairs joined by AND/OR', + "String values must be wrapped in single quotes within the query, for example: name:'Acme'" + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + query: z.string().describe(`Freshdesk company filter query, for example "name:'Acme'"`), + page: z.number().optional().describe('Page number for pagination') + }) + ) + .output( + z.object({ + total: z.number().describe('Total number of matching companies'), + companies: z + .array( + z.object({ + companyId: z.number().describe('Company ID'), + name: z.string().describe('Company name'), + domains: z.array(z.string()).describe('Associated email domains'), + healthScore: z.string().nullable().describe('Health score'), + accountTier: z.string().nullable().describe('Account tier'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().describe('Last update timestamp') + }) + ) + .describe('Matching companies') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let result = await client.searchCompanies(ctx.input.query, ctx.input.page); + let companies = (result.results ?? []).map((company: any) => ({ + companyId: company.id, + name: company.name, + domains: company.domains ?? [], + healthScore: company.health_score ?? null, + accountTier: company.account_tier ?? null, + createdAt: company.created_at, + updatedAt: company.updated_at + })); + + return { + output: { + total: result.total ?? companies.length, + companies + }, + message: `Found **${result.total ?? companies.length}** companies matching the query` + }; + }) + .build(); diff --git a/integrations/freshdesk/src/tools/support-metadata.ts b/integrations/freshdesk/src/tools/support-metadata.ts new file mode 100644 index 0000000000..aa530772c6 --- /dev/null +++ b/integrations/freshdesk/src/tools/support-metadata.ts @@ -0,0 +1,473 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { FreshdeskClient } from '../lib/client'; +import { spec } from '../spec'; + +let fieldSchema = z.object({ + fieldId: z.number().describe('Field ID'), + name: z.string().nullable().describe('API field name'), + label: z.string().nullable().describe('Display label'), + type: z.string().nullable().describe('Field type'), + requiredForAgents: z.boolean().nullable().describe('Whether agents must provide this field'), + requiredForCustomers: z + .boolean() + .nullable() + .describe('Whether customers must provide this field'), + choices: z.any().nullable().describe('Configured dropdown choices or nested choices') +}); + +let mapField = (field: any) => ({ + fieldId: field.id, + name: field.name ?? null, + label: field.label ?? field.label_for_customers ?? null, + type: field.type ?? null, + requiredForAgents: field.required_for_agents ?? null, + requiredForCustomers: field.required_for_customers ?? null, + choices: field.choices ?? null +}); + +export let getAccount = SlateTool.create(spec, { + name: 'Get Account', + key: 'get_account', + description: `Retrieves Freshdesk account details for the connected helpdesk, including plan and portal metadata when available.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + account: z.record(z.string(), z.any()).describe('Freshdesk account metadata') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let account = await client.getAccount(); + + return { + output: { account }, + message: `Retrieved Freshdesk account metadata` + }; + }) + .build(); + +export let getCurrentAgent = SlateTool.create(spec, { + name: 'Get Current Agent', + key: 'get_current_agent', + description: `Retrieves the currently authenticated Freshdesk agent. Use this to discover the agent ID for assignment, time entry ownership, and E2E setup.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + agentId: z.number().describe('Authenticated agent ID'), + contactId: z.number().nullable().describe('Associated contact ID'), + name: z.string().nullable().describe('Agent display name'), + email: z.string().nullable().describe('Agent email address'), + active: z.boolean().describe('Whether the agent is active'), + occasional: z.boolean().describe('Whether the agent is an occasional agent'), + ticketScope: z.number().nullable().describe('Ticket scope value'), + groupIds: z.array(z.number()).describe('Groups the agent belongs to') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let agent = await client.getCurrentAgent(); + + return { + output: { + agentId: agent.id, + contactId: agent.contact?.id ?? agent.contact_id ?? null, + name: agent.contact?.name ?? null, + email: agent.contact?.email ?? null, + active: agent.available ?? agent.active ?? true, + occasional: agent.occasional ?? false, + ticketScope: agent.ticket_scope ?? null, + groupIds: agent.group_ids ?? [] + }, + message: `Retrieved current Freshdesk agent **#${agent.id}**` + }; + }) + .build(); + +export let listFields = SlateTool.create(spec, { + name: 'List Fields', + key: 'list_fields', + description: `Lists Freshdesk field definitions for tickets, contacts, or companies. Use this before writing custom fields or validating required helpdesk fields.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + resource: z + .enum(['ticket', 'contact', 'company']) + .describe('Field resource to list: ticket, contact, or company') + }) + ) + .output( + z.object({ + resource: z.enum(['ticket', 'contact', 'company']).describe('Field resource listed'), + fields: z.array(fieldSchema).describe('Field definitions') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let fields = + ctx.input.resource === 'ticket' + ? await client.listTicketFields() + : ctx.input.resource === 'contact' + ? await client.listContactFields() + : await client.listCompanyFields(); + let mapped = fields.map(mapField); + + return { + output: { + resource: ctx.input.resource, + fields: mapped + }, + message: `Retrieved **${mapped.length}** ${ctx.input.resource} fields` + }; + }) + .build(); + +export let listProducts = SlateTool.create(spec, { + name: 'List Products', + key: 'list_products', + description: `Lists Freshdesk products configured for the helpdesk. Use product IDs when creating or routing product-specific tickets.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + products: z + .array( + z.object({ + productId: z.number().describe('Product ID'), + name: z.string().describe('Product name'), + description: z.string().nullable().describe('Product description'), + primary: z.boolean().nullable().describe('Whether this is the primary product'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .describe('Products') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let products = await client.listProducts(); + let mapped = products.map((product: any) => ({ + productId: product.id, + name: product.name, + description: product.description ?? null, + primary: product.primary ?? null, + createdAt: product.created_at ?? null, + updatedAt: product.updated_at ?? null + })); + + return { + output: { products: mapped }, + message: `Retrieved **${mapped.length}** products` + }; + }) + .build(); + +export let listBusinessHours = SlateTool.create(spec, { + name: 'List Business Hours', + key: 'list_business_hours', + description: `Lists Freshdesk business-hour schedules used by SLA policies and ticket due dates.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + businessHours: z + .array( + z.object({ + businessHourId: z.number().describe('Business hour ID'), + name: z.string().describe('Business hour name'), + description: z.string().nullable().describe('Description'), + timeZone: z.string().nullable().describe('Time zone'), + isDefault: z.boolean().nullable().describe('Whether this is the default schedule') + }) + ) + .describe('Business-hour schedules') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let businessHours = await client.listBusinessHours(); + let mapped = businessHours.map((item: any) => ({ + businessHourId: item.id, + name: item.name, + description: item.description ?? null, + timeZone: item.time_zone ?? item.timezone ?? null, + isDefault: item.default ?? item.is_default ?? null + })); + + return { + output: { businessHours: mapped }, + message: `Retrieved **${mapped.length}** business-hour schedules` + }; + }) + .build(); + +export let listSlaPolicies = SlateTool.create(spec, { + name: 'List SLA Policies', + key: 'list_sla_policies', + description: `Lists Freshdesk SLA policies. Use this to inspect support targets and escalation policies that affect ticket due dates.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + slaPolicies: z + .array( + z.object({ + slaPolicyId: z.number().describe('SLA policy ID'), + name: z.string().describe('Policy name'), + active: z.boolean().nullable().describe('Whether the policy is active'), + position: z.number().nullable().describe('Policy position'), + isDefault: z.boolean().nullable().describe('Whether this is the default policy') + }) + ) + .describe('SLA policies') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let policies = await client.listSlaPolicies(); + let mapped = policies.map((policy: any) => ({ + slaPolicyId: policy.id, + name: policy.name, + active: policy.active ?? null, + position: policy.position ?? null, + isDefault: policy.default ?? policy.is_default ?? null + })); + + return { + output: { slaPolicies: mapped }, + message: `Retrieved **${mapped.length}** SLA policies` + }; + }) + .build(); + +export let getHelpdeskSettings = SlateTool.create(spec, { + name: 'Get Helpdesk Settings', + key: 'get_helpdesk_settings', + description: `Retrieves Freshdesk helpdesk-level settings, including locale and portal behavior metadata when available.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + settings: z.record(z.string(), z.any()).describe('Helpdesk settings') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let settings = await client.getHelpdeskSettings(); + + return { + output: { settings }, + message: `Retrieved Freshdesk helpdesk settings` + }; + }) + .build(); + +export let listSatisfactionRatings = SlateTool.create(spec, { + name: 'List Satisfaction Ratings', + key: 'list_satisfaction_ratings', + description: `Lists Freshdesk satisfaction ratings across tickets, with optional created-date filters for reporting and customer support quality review.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + createdSince: z + .string() + .optional() + .describe('Return ratings created after this ISO 8601 timestamp'), + createdUntil: z + .string() + .optional() + .describe('Return ratings created before this ISO 8601 timestamp'), + page: z.number().optional().describe('Page number for pagination') + }) + ) + .output( + z.object({ + ratings: z + .array( + z.object({ + ratingId: z.number().describe('Satisfaction rating ID'), + ticketId: z.number().nullable().describe('Ticket ID'), + requesterId: z.number().nullable().describe('Requester contact ID'), + agentId: z.number().nullable().describe('Agent ID'), + groupId: z.number().nullable().describe('Group ID'), + score: z.any().nullable().describe('Rating score or choice'), + feedback: z.string().nullable().describe('Requester feedback'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .describe('Satisfaction ratings') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let ratings = await client.listSatisfactionRatings({ + createdSince: ctx.input.createdSince, + createdUntil: ctx.input.createdUntil, + page: ctx.input.page + }); + let mapped = ratings.map((rating: any) => ({ + ratingId: rating.id, + ticketId: rating.ticket_id ?? null, + requesterId: rating.requester_id ?? null, + agentId: rating.agent_id ?? null, + groupId: rating.group_id ?? null, + score: rating.rating ?? rating.score ?? null, + feedback: rating.feedback ?? null, + createdAt: rating.created_at ?? null, + updatedAt: rating.updated_at ?? null + })); + + return { + output: { ratings: mapped }, + message: `Retrieved **${mapped.length}** satisfaction ratings` + }; + }) + .build(); + +export let listCannedResponseFolders = SlateTool.create(spec, { + name: 'List Canned Response Folders', + key: 'list_canned_response_folders', + description: `Lists folders that organize Freshdesk canned responses for agent replies.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + folders: z + .array( + z.object({ + folderId: z.number().describe('Folder ID'), + name: z.string().describe('Folder name'), + personal: z.boolean().nullable().describe('Whether this is a personal folder') + }) + ) + .describe('Canned response folders') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let folders = await client.listCannedResponseFolders(); + let mapped = folders.map((folder: any) => ({ + folderId: folder.id, + name: folder.name, + personal: folder.personal ?? null + })); + + return { + output: { folders: mapped }, + message: `Retrieved **${mapped.length}** canned response folders` + }; + }) + .build(); + +export let listCannedResponses = SlateTool.create(spec, { + name: 'List Canned Responses', + key: 'list_canned_responses', + description: `Lists canned responses in a Freshdesk canned response folder so agents can reuse approved reply templates.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + folderId: z.number().describe('ID of the canned response folder') + }) + ) + .output( + z.object({ + responses: z + .array( + z.object({ + responseId: z.number().describe('Canned response ID'), + title: z.string().nullable().describe('Response title'), + content: z.string().nullable().describe('HTML response content') + }) + ) + .describe('Canned responses') + }) + ) + .handleInvocation(async ctx => { + let client = new FreshdeskClient({ + subdomain: ctx.config.subdomain, + token: ctx.auth.token + }); + + let responses = await client.listCannedResponses(ctx.input.folderId); + let mapped = responses.map((response: any) => ({ + responseId: response.id, + title: response.title ?? response.name ?? null, + content: response.content ?? response.body ?? null + })); + + return { + output: { responses: mapped }, + message: `Retrieved **${mapped.length}** canned responses` + }; + }) + .build(); diff --git a/integrations/freshdesk/src/tools/update-company.ts b/integrations/freshdesk/src/tools/update-company.ts index c410309853..661abd9326 100644 --- a/integrations/freshdesk/src/tools/update-company.ts +++ b/integrations/freshdesk/src/tools/update-company.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { FreshdeskClient } from '../lib/client'; +import { freshdeskServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateCompany = SlateTool.create(spec, { @@ -54,6 +55,10 @@ export let updateCompany = SlateTool.create(spec, { if (ctx.input.customFields !== undefined) updateData.custom_fields = ctx.input.customFields; + if (Object.keys(updateData).length === 0) { + throw freshdeskServiceError('Provide at least one field to update on the company.'); + } + let company = await client.updateCompany(ctx.input.companyId, updateData); return { diff --git a/integrations/freshdesk/src/tools/update-contact.ts b/integrations/freshdesk/src/tools/update-contact.ts index 222792e3da..8708399c0c 100644 --- a/integrations/freshdesk/src/tools/update-contact.ts +++ b/integrations/freshdesk/src/tools/update-contact.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { FreshdeskClient } from '../lib/client'; +import { freshdeskServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateContact = SlateTool.create(spec, { @@ -61,6 +62,10 @@ export let updateContact = SlateTool.create(spec, { if (ctx.input.customFields !== undefined) updateData.custom_fields = ctx.input.customFields; + if (Object.keys(updateData).length === 0) { + throw freshdeskServiceError('Provide at least one field to update on the contact.'); + } + let contact = await client.updateContact(ctx.input.contactId, updateData); return { diff --git a/integrations/freshdesk/src/tools/update-ticket.ts b/integrations/freshdesk/src/tools/update-ticket.ts index 8aaf4af47e..108697c021 100644 --- a/integrations/freshdesk/src/tools/update-ticket.ts +++ b/integrations/freshdesk/src/tools/update-ticket.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { FreshdeskClient } from '../lib/client'; +import { freshdeskServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateTicket = SlateTool.create(spec, { @@ -65,6 +66,10 @@ export let updateTicket = SlateTool.create(spec, { if (ctx.input.customFields !== undefined) updateData.custom_fields = ctx.input.customFields; + if (Object.keys(updateData).length === 0) { + throw freshdeskServiceError('Provide at least one field to update on the ticket.'); + } + let ticket = await client.updateTicket(ctx.input.ticketId, updateData); return { diff --git a/integrations/freshdesk/vitest.config.ts b/integrations/freshdesk/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/freshdesk/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/freshsales/README.md b/integrations/freshsales/README.md index 8100149503..c92fe9de1f 100644 --- a/integrations/freshsales/README.md +++ b/integrations/freshsales/README.md @@ -1,6 +1,6 @@ # Freshsales -Manage leads, contacts, accounts, and deals in Freshsales CRM. Create, view, update, delete, and list leads with lead scoring and conversion to contacts. Manage contacts and associate them with accounts. Track and manage deals through pipelines. Create and manage tasks, appointments, notes, and sales activities. Search across entities using keywords, filter records with saved views, and retrieve activity timelines. Log sales activities such as calls and emails. Manage files and documents on records. Retrieve field metadata and configure custom modules. +Manage leads, contacts, accounts, and deals in Freshsales CRM. Create, view, update, delete, and list leads with lead scoring and conversion to contacts. Manage contacts and accounts. Track and manage deals through pipelines. Create, view, update, delete, and list tasks, appointments, notes, and sales activities. Search across entities using keywords or exact contact filters, work with saved views, and retrieve field metadata. ## Tools @@ -32,14 +32,30 @@ Delete a lead from Freshsales by its ID. This action is permanent and cannot be Delete a note from Freshsales by its ID. +### Delete Sales Activity + +Delete a sales activity from Freshsales by its ID. + ### Delete Task Delete a task from Freshsales by its ID. +### Complete Task + +Mark a Freshsales task as done by setting its status to completed. + +### Filter Contacts + +Find Freshsales contacts that exactly match one or more filter rules. Use this for precise contact lookups such as matching an email address. + ### Get Account Retrieve a single account (company) by ID from Freshsales. Optionally include related contacts, deals, tasks, and more. +### Get Appointment + +Retrieve a single appointment by ID from Freshsales. + ### Get Contact Retrieve a single contact by ID from Freshsales. Optionally include related data like accounts, owner, tasks, appointments, notes, and deals. @@ -56,6 +72,14 @@ Retrieve field metadata for an entity type in Freshsales. Returns all standard a Retrieve a single lead by ID from Freshsales. Optionally include related data like owner, tasks, appointments, and notes. +### Get Sales Activity + +Retrieve a single sales activity by ID from Freshsales. + +### Get Task + +Retrieve a single task by ID from Freshsales. + ### List Accounts List accounts (companies) from a saved view in Freshsales. Use the **listFilters** tool to get available view IDs. @@ -80,6 +104,10 @@ List available filter views for an entity type in Freshsales. View IDs returned List leads from a saved view in Freshsales. Use the **listFilters** tool first to get available view IDs. Supports pagination and sorting. +### List Sales Activities + +List sales activities from Freshsales with optional pagination. + ### List Selectors Retrieve reference data (selectors) from Freshsales. Use this to get valid IDs for fields like deal stages, pipelines, lead sources, industry types, sales activity types, etc. diff --git a/integrations/freshsales/docs/SPEC.md b/integrations/freshsales/docs/SPEC.md index 132db505d5..580f450c5f 100644 --- a/integrations/freshsales/docs/SPEC.md +++ b/integrations/freshsales/docs/SPEC.md @@ -32,7 +32,7 @@ Using the APIs, users would only be able to view data that they have access to. ### Lead Management -Create, view, update, delete, and list leads. You can create or update a lead based on a unique identifier value — it searches for a record with the value mentioned and updates it if found, else it creates the record. Leads can be converted to contacts. You can add, update, or remove team members of the lead team. Leads support custom fields, filtering via views, sorting, and activity tracking. +Create, view, update, delete, and list leads. You can create or update a lead based on a unique identifier value, which searches for a record with the value mentioned and updates it if found, otherwise it creates the record. Leads can be converted to contacts. Leads support custom fields, filtering via views, sorting, and activity tracking. ### Contact Management @@ -48,27 +48,23 @@ Create, view, update, delete, and list deals. You can manage deals with the pipe ### Tasks and Appointments -Create, view, update, delete, and list tasks and appointments. Tasks are organized according to status and due date (open, due today, due tomorrow, overdue, completed). Appointments are divided into past and upcoming. +Create, view, update, delete, and list tasks and appointments. Tasks are organized according to status and due date (open, due today, due tomorrow, overdue, completed), and can be marked done. Appointments are divided into past and upcoming. ### Notes and Activities -Create notes for leads, contacts, accounts, and deals. Track sales activities and retrieve activity timelines for contacts and leads. +Create and update notes for leads, contacts, accounts, and deals. Delete notes by ID. Create, view, update, delete, and list sales activities for contacts, accounts, and deals. ### Search and Filtering -Search across entities (leads, contacts, etc.) using keywords. Filter records based on certain conditions using the filterize option in real-time, applicable for contacts, accounts, and deals. Use saved views to retrieve filtered record sets. +Search across entities using keywords. Run exact lookup search by field for contacts, accounts, and deals. Use filtered contact search for exact contact matches such as email address. Use saved views to retrieve filtered record sets. ### Sales Activities Log and manage sales activities such as calls, emails, and custom activities associated with leads, contacts, and deals. -### Files and Documents - -List and manage file and document associations on records. - ### Settings and Customization -Retrieve field metadata for contacts, leads, deals, accounts, and sales activities. Create and manage custom modules. +Retrieve field metadata for contacts, leads, deals, accounts, and sales activities. Retrieve selectors for owners, territories, deal metadata, lifecycle stages, and sales activity metadata. ## Events diff --git a/integrations/freshsales/package.json b/integrations/freshsales/package.json index 2efe444ed7..d4a9c791f8 100644 --- a/integrations/freshsales/package.json +++ b/integrations/freshsales/package.json @@ -7,12 +7,15 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/freshsales/slate.json b/integrations/freshsales/slate.json index 634aa6dc2d..cb0d1d0481 100644 --- a/integrations/freshsales/slate.json +++ b/integrations/freshsales/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/freshsales", - "description": "Manage leads, contacts, accounts, and deals in Freshsales CRM. Create, view, update, delete, and list leads with lead scoring and conversion to contacts. Manage contacts and associate them with accounts. Track and manage deals through pipelines. Create and manage tasks, appointments, notes, and sales activities. Search across entities using keywords, filter records with saved views, and retrieve activity timelines. Log sales activities such as calls and emails. Manage files and documents on records. Retrieve field metadata and configure custom modules.", + "description": "Manage leads, contacts, accounts, and deals in Freshsales CRM. Create, view, update, delete, and list leads with lead scoring and conversion to contacts. Manage contacts and accounts. Track and manage deals through pipelines. Create, view, update, delete, and list tasks, appointments, notes, and sales activities. Search across entities using keywords or exact contact filters, work with saved views, and retrieve field metadata.", "categories": ["crm-and-sales-tools", "task-and-project-management"], "skills": [ "manage leads", @@ -9,10 +9,10 @@ "manage accounts", "create tasks and appointments", "log sales activities", - "search and filter records", + "search and filter contacts", "track activity timelines", "create and manage notes", - "manage files and documents" + "complete tasks" ], "logoUrl": "https://provider-logos.metorial-cdn.com/freshsales.png" } diff --git a/integrations/freshsales/src/index.ts b/integrations/freshsales/src/index.ts index db4e447098..da65d295ac 100644 --- a/integrations/freshsales/src/index.ts +++ b/integrations/freshsales/src/index.ts @@ -1,6 +1,7 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + completeTask, convertLead, deleteAccount, deleteAppointment, @@ -8,18 +9,24 @@ import { deleteDeal, deleteLead, deleteNote, + deleteSalesActivity, deleteTask, + filterContacts, getAccount, + getAppointment, getContact, getDeal, getFields, getLead, + getSalesActivity, + getTask, listAccounts, listAppointments, listContacts, listDeals, listFilters, listLeads, + listSalesActivities, listSelectors, listTasks, manageAccount, @@ -61,15 +68,22 @@ export let provider = Slate.create({ listDeals, deleteDeal, manageTask, + getTask, + completeTask, listTasks, deleteTask, manageAppointment, + getAppointment, listAppointments, deleteAppointment, manageNote, deleteNote, manageSalesActivity, + getSalesActivity, + listSalesActivities, + deleteSalesActivity, searchRecords, + filterContacts, listFilters, listSelectors, getFields diff --git a/integrations/freshsales/src/lib/client.ts b/integrations/freshsales/src/lib/client.ts index 9f6a44f4f9..e656798c94 100644 --- a/integrations/freshsales/src/lib/client.ts +++ b/integrations/freshsales/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { freshsalesApiError } from './errors'; export type ApiVersion = 'freshworks' | 'classic'; @@ -17,6 +18,27 @@ let buildBaseUrl = (domain: string, apiVersion: ApiVersion): string => { return `https://${domain}.myfreshworks.com/crm/sales/api`; }; +let collectRecordArrays = (value: unknown, results: Record[] = []) => { + if (Array.isArray(value)) { + for (let item of value) { + if (item && typeof item === 'object' && !Array.isArray(item)) { + results.push(item as Record); + } + } + return results; + } + + if (!value || typeof value !== 'object') { + return results; + } + + for (let nested of Object.values(value)) { + collectRecordArrays(nested, results); + } + + return results; +}; + export class Client { private axios: ReturnType; @@ -28,6 +50,11 @@ export class Client { 'Content-Type': 'application/json' } }); + + this.axios.interceptors.response.use( + (response: any) => response, + (error: unknown) => Promise.reject(freshsalesApiError(error)) + ); } // ---- Leads ---- @@ -272,6 +299,11 @@ export class Client { return response.data.task; } + async completeTask(taskId: number): Promise> { + let response = await this.axios.put(`/tasks/${taskId}`, { task: { status: 1 } }); + return response.data.task; + } + async deleteTask(taskId: number): Promise { await this.axios.delete(`/tasks/${taskId}`); } @@ -345,6 +377,20 @@ export class Client { return response.data.sales_activity; } + async listSalesActivities(options?: { + page?: number; + perPage?: number; + }): Promise<{ salesActivities: Record[]; meta: Record }> { + let params: Record = {}; + if (options?.page) params.page = options.page; + if (options?.perPage) params.per_page = options.perPage; + let response = await this.axios.get('/sales_activities', { params }); + return { + salesActivities: response.data.sales_activities || [], + meta: response.data.meta || {} + }; + } + async updateSalesActivity( activityId: number, salesActivity: Record @@ -375,14 +421,14 @@ export class Client { ): Promise[]> { let params: Record = { q: query, f: field, entities }; let response = await this.axios.get('/lookup', { params }); - return response.data || []; + return collectRecordArrays(response.data); } async filteredSearch( entityType: string, filterRules: Record[], options?: { page?: number; perPage?: number } - ): Promise[]> { + ): Promise> { let params: Record = {}; if (options?.page) params.page = options.page; if (options?.perPage) params.per_page = options.perPage; @@ -391,7 +437,7 @@ export class Client { { filter_rule: filterRules }, { params } ); - return response.data || []; + return response.data || {}; } // ---- Selectors ---- diff --git a/integrations/freshsales/src/lib/errors.ts b/integrations/freshsales/src/lib/errors.ts new file mode 100644 index 0000000000..e5249c8ec4 --- /dev/null +++ b/integrations/freshsales/src/lib/errors.ts @@ -0,0 +1,95 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + for (let key of ['message', 'description', 'error_description', 'error', 'code']) { + pushDetail(details, value[key]); + } + + for (let nested of Object.values(value)) { + if (Array.isArray(nested) || isRecord(nested)) { + collectDetails(nested, details); + } + } +}; + +let extractFreshsalesMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + if (isRecord(response?.data)) { + collectDetails(response.data.errors, details); + collectDetails(response.data.error, details); + collectDetails(response.data.message, details); + } else { + collectDetails(response?.data, details); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let freshsalesServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let freshsalesApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = freshsalesServiceError( + `Freshsales API ${operation} failed: ${statusLabelFor(response)}${extractFreshsalesMessage(error)}` + ); + serviceError.data.reason = 'freshsales_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/freshsales/src/tools.schema.test.ts b/integrations/freshsales/src/tools.schema.test.ts new file mode 100644 index 0000000000..c96eb7c765 --- /dev/null +++ b/integrations/freshsales/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Freshsales tool input schemas', provider.actions); diff --git a/integrations/freshsales/src/tools/complete-task.ts b/integrations/freshsales/src/tools/complete-task.ts new file mode 100644 index 0000000000..525baf72d0 --- /dev/null +++ b/integrations/freshsales/src/tools/complete-task.ts @@ -0,0 +1,40 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let completeTask = SlateTool.create(spec, { + name: 'Complete Task', + key: 'complete_task', + description: `Mark a Freshsales task as done by setting its status to completed.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + taskId: z.number().describe('ID of the task to mark as done') + }) + ) + .output( + z.object({ + taskId: z.number(), + status: z.number().nullable().optional(), + completed: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let task = await client.completeTask(ctx.input.taskId); + + return { + output: { + taskId: task.id, + status: task.status, + completed: task.status === 1 + }, + message: `Task **${task.title || task.id}** marked as done.` + }; + }) + .build(); diff --git a/integrations/freshsales/src/tools/delete-sales-activity.ts b/integrations/freshsales/src/tools/delete-sales-activity.ts new file mode 100644 index 0000000000..e3e05303c8 --- /dev/null +++ b/integrations/freshsales/src/tools/delete-sales-activity.ts @@ -0,0 +1,34 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let deleteSalesActivity = SlateTool.create(spec, { + name: 'Delete Sales Activity', + key: 'delete_sales_activity', + description: `Delete a sales activity from Freshsales by its ID.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + activityId: z.number().describe('ID of the sales activity to delete') + }) + ) + .output( + z.object({ + deleted: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + await client.deleteSalesActivity(ctx.input.activityId); + + return { + output: { deleted: true }, + message: `Sales activity **${ctx.input.activityId}** deleted successfully.` + }; + }) + .build(); diff --git a/integrations/freshsales/src/tools/filter-contacts.ts b/integrations/freshsales/src/tools/filter-contacts.ts new file mode 100644 index 0000000000..3c6be9d8e1 --- /dev/null +++ b/integrations/freshsales/src/tools/filter-contacts.ts @@ -0,0 +1,99 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { freshsalesServiceError } from '../lib/errors'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let filterContacts = SlateTool.create(spec, { + name: 'Filter Contacts', + key: 'filter_contacts', + description: `Find Freshsales contacts that exactly match one or more filter rules. Use this for precise contact lookups such as matching an email address.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + filterRules: z + .array( + z.object({ + attribute: z + .string() + .describe('Freshsales contact attribute path, e.g. "contact_email.email"'), + operator: z.string().describe('Freshsales filtered-search operator, e.g. "is_in"'), + value: z.string().describe('Value to match exactly') + }) + ) + .describe('Filter rules passed to Freshsales as filter_rule'), + page: z.number().optional().describe('Page number for pagination (starts at 1)'), + perPage: z + .number() + .optional() + .describe('Number of contacts to return per page. Freshsales allows at most 100.') + }) + ) + .output( + z.object({ + contacts: z.array( + z.object({ + contactId: z.number(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + displayName: z.string().nullable().optional(), + email: z.string().nullable().optional(), + mobileNumber: z.string().nullable().optional(), + workNumber: z.string().nullable().optional(), + jobTitle: z.string().nullable().optional(), + city: z.string().nullable().optional(), + country: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional() + }) + ), + total: z.number().optional() + }) + ) + .handleInvocation(async ctx => { + if (ctx.input.filterRules.length === 0) { + throw freshsalesServiceError('filterRules must include at least one rule.'); + } + + if (ctx.input.perPage !== undefined && ctx.input.perPage > 100) { + throw freshsalesServiceError('perPage cannot be greater than 100.'); + } + + let client = createClient(ctx); + let result = await client.filteredSearch('contact', ctx.input.filterRules, { + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let contacts = ((result.contacts as Record[] | undefined) ?? []).map( + contact => ({ + contactId: contact.id, + firstName: contact.first_name, + lastName: contact.last_name, + displayName: contact.display_name, + email: contact.email, + mobileNumber: contact.mobile_number, + workNumber: contact.work_number, + jobTitle: contact.job_title, + city: contact.city, + country: contact.country, + createdAt: contact.created_at, + updatedAt: contact.updated_at + }) + ); + + let meta = result.meta as Record | undefined; + + return { + output: { + contacts, + total: meta?.total + }, + message: `Found **${contacts.length}** matching contacts.` + }; + }) + .build(); diff --git a/integrations/freshsales/src/tools/get-appointment.ts b/integrations/freshsales/src/tools/get-appointment.ts new file mode 100644 index 0000000000..7bf6aa0281 --- /dev/null +++ b/integrations/freshsales/src/tools/get-appointment.ts @@ -0,0 +1,54 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getAppointment = SlateTool.create(spec, { + name: 'Get Appointment', + key: 'get_appointment', + description: `Retrieve a single appointment by ID from Freshsales.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + appointmentId: z.number().describe('ID of the appointment to retrieve') + }) + ) + .output( + z.object({ + appointmentId: z.number(), + title: z.string().nullable().optional(), + description: z.string().nullable().optional(), + fromDate: z.string().nullable().optional(), + endDate: z.string().nullable().optional(), + location: z.string().nullable().optional(), + targetableId: z.number().nullable().optional(), + targetableType: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let appointment = await client.getAppointment(ctx.input.appointmentId); + + return { + output: { + appointmentId: appointment.id, + title: appointment.title, + description: appointment.description, + fromDate: appointment.from_date, + endDate: appointment.end_date, + location: appointment.location, + targetableId: appointment.targetable_id, + targetableType: appointment.targetable_type, + createdAt: appointment.created_at, + updatedAt: appointment.updated_at + }, + message: `Retrieved appointment **${appointment.title || appointment.id}**.` + }; + }) + .build(); diff --git a/integrations/freshsales/src/tools/get-sales-activity.ts b/integrations/freshsales/src/tools/get-sales-activity.ts new file mode 100644 index 0000000000..588cbc8cc0 --- /dev/null +++ b/integrations/freshsales/src/tools/get-sales-activity.ts @@ -0,0 +1,60 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getSalesActivity = SlateTool.create(spec, { + name: 'Get Sales Activity', + key: 'get_sales_activity', + description: `Retrieve a single sales activity by ID from Freshsales.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + activityId: z.number().describe('ID of the sales activity to retrieve') + }) + ) + .output( + z.object({ + activityId: z.number(), + title: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + salesActivityTypeId: z.number().nullable().optional(), + salesActivityOutcomeId: z.number().nullable().optional(), + targetableId: z.number().nullable().optional(), + targetableType: z.string().nullable().optional(), + startDate: z.string().nullable().optional(), + endDate: z.string().nullable().optional(), + ownerId: z.number().nullable().optional(), + location: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let activity = await client.getSalesActivity(ctx.input.activityId); + + return { + output: { + activityId: activity.id, + title: activity.title, + notes: activity.notes, + salesActivityTypeId: activity.sales_activity_type_id, + salesActivityOutcomeId: activity.sales_activity_outcome_id, + targetableId: activity.targetable_id, + targetableType: activity.targetable_type, + startDate: activity.start_date, + endDate: activity.end_date, + ownerId: activity.owner_id, + location: activity.location, + createdAt: activity.created_at, + updatedAt: activity.updated_at + }, + message: `Retrieved sales activity **${activity.title || activity.id}**.` + }; + }) + .build(); diff --git a/integrations/freshsales/src/tools/get-task.ts b/integrations/freshsales/src/tools/get-task.ts new file mode 100644 index 0000000000..3a2ea4a21b --- /dev/null +++ b/integrations/freshsales/src/tools/get-task.ts @@ -0,0 +1,54 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getTask = SlateTool.create(spec, { + name: 'Get Task', + key: 'get_task', + description: `Retrieve a single task by ID from Freshsales.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + taskId: z.number().describe('ID of the task to retrieve') + }) + ) + .output( + z.object({ + taskId: z.number(), + title: z.string().nullable().optional(), + description: z.string().nullable().optional(), + dueDate: z.string().nullable().optional(), + ownerId: z.number().nullable().optional(), + targetableId: z.number().nullable().optional(), + targetableType: z.string().nullable().optional(), + status: z.number().nullable().optional(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let task = await client.getTask(ctx.input.taskId); + + return { + output: { + taskId: task.id, + title: task.title, + description: task.description, + dueDate: task.due_date, + ownerId: task.owner_id, + targetableId: task.targetable_id, + targetableType: task.targetable_type, + status: task.status, + createdAt: task.created_at, + updatedAt: task.updated_at + }, + message: `Retrieved task **${task.title || task.id}**.` + }; + }) + .build(); diff --git a/integrations/freshsales/src/tools/index.ts b/integrations/freshsales/src/tools/index.ts index 064cd97dff..35c1e04c29 100644 --- a/integrations/freshsales/src/tools/index.ts +++ b/integrations/freshsales/src/tools/index.ts @@ -1,3 +1,4 @@ +export * from './complete-task'; export * from './convert-lead'; export * from './delete-account'; export * from './delete-appointment'; @@ -5,18 +6,24 @@ export * from './delete-contact'; export * from './delete-deal'; export * from './delete-lead'; export * from './delete-note'; +export * from './delete-sales-activity'; export * from './delete-task'; +export * from './filter-contacts'; export * from './get-account'; +export * from './get-appointment'; export * from './get-contact'; export * from './get-deal'; export * from './get-fields'; export * from './get-lead'; +export * from './get-sales-activity'; +export * from './get-task'; export * from './list-accounts'; export * from './list-appointments'; export * from './list-contacts'; export * from './list-deals'; export * from './list-filters'; export * from './list-leads'; +export * from './list-sales-activities'; export * from './list-selectors'; export * from './list-tasks'; export * from './manage-account'; diff --git a/integrations/freshsales/src/tools/list-sales-activities.ts b/integrations/freshsales/src/tools/list-sales-activities.ts new file mode 100644 index 0000000000..ba5fa5f289 --- /dev/null +++ b/integrations/freshsales/src/tools/list-sales-activities.ts @@ -0,0 +1,74 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listSalesActivities = SlateTool.create(spec, { + name: 'List Sales Activities', + key: 'list_sales_activities', + description: `List sales activities from Freshsales with optional pagination.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + page: z.number().optional().describe('Page number for pagination (starts at 1)'), + perPage: z.number().optional().describe('Number of sales activities to return per page') + }) + ) + .output( + z.object({ + salesActivities: z.array( + z.object({ + activityId: z.number(), + title: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + salesActivityTypeId: z.number().nullable().optional(), + salesActivityOutcomeId: z.number().nullable().optional(), + targetableId: z.number().nullable().optional(), + targetableType: z.string().nullable().optional(), + startDate: z.string().nullable().optional(), + endDate: z.string().nullable().optional(), + ownerId: z.number().nullable().optional(), + location: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional() + }) + ), + total: z.number().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.listSalesActivities({ + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let salesActivities = result.salesActivities.map((activity: Record) => ({ + activityId: activity.id, + title: activity.title, + notes: activity.notes, + salesActivityTypeId: activity.sales_activity_type_id, + salesActivityOutcomeId: activity.sales_activity_outcome_id, + targetableId: activity.targetable_id, + targetableType: activity.targetable_type, + startDate: activity.start_date, + endDate: activity.end_date, + ownerId: activity.owner_id, + location: activity.location, + createdAt: activity.created_at, + updatedAt: activity.updated_at + })); + + return { + output: { + salesActivities, + total: result.meta?.total + }, + message: `Found **${salesActivities.length}** sales activities.` + }; + }) + .build(); diff --git a/integrations/freshsales/src/tools/search-records.ts b/integrations/freshsales/src/tools/search-records.ts index e763088545..20ac9b4556 100644 --- a/integrations/freshsales/src/tools/search-records.ts +++ b/integrations/freshsales/src/tools/search-records.ts @@ -1,12 +1,13 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { freshsalesServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; export let searchRecords = SlateTool.create(spec, { name: 'Search Records', key: 'search_records', - description: `Search across Freshsales entities (contacts, leads, deals, accounts) using keywords or field-specific lookup. + description: `Search across Freshsales entities (contacts, accounts, deals, users, and custom modules) using keywords or field-specific lookup. Use **keyword** for general search or **lookupField** + **lookupValue** for precise field-based lookups.`, instructions: [ 'For general keyword search, provide "keyword" and optionally "entities" to filter by type.', @@ -24,7 +25,7 @@ Use **keyword** for general search or **lookupField** + **lookupValue** for prec .string() .optional() .describe( - 'Comma-separated entity types to search: "contact", "lead", "deal", "sales_account"' + 'Comma-separated entity types to search, e.g. "contact", "sales_account", "deal", or "user"' ), lookupValue: z .string() @@ -47,20 +48,25 @@ Use **keyword** for general search or **lookupField** + **lookupValue** for prec let results: Record[]; + let hasAnyLookupInput = + ctx.input.lookupValue || ctx.input.lookupField || ctx.input.lookupEntity; + if (ctx.input.lookupValue && ctx.input.lookupField && ctx.input.lookupEntity) { results = await client.lookup( ctx.input.lookupValue, ctx.input.lookupField, ctx.input.lookupEntity ); + } else if (hasAnyLookupInput) { + throw freshsalesServiceError( + 'lookupValue, lookupField, and lookupEntity are all required for lookup search.' + ); } else if (ctx.input.keyword) { results = await client.search(ctx.input.keyword, ctx.input.entities); } else { - return { - output: { results: [] }, - message: - 'No search criteria provided. Provide either **keyword** or **lookupValue** with **lookupField** and **lookupEntity**.' - }; + throw freshsalesServiceError( + 'Provide either keyword, or lookupValue with lookupField and lookupEntity.' + ); } return { diff --git a/integrations/freshsales/vitest.config.ts b/integrations/freshsales/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/freshsales/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/freshservice/package.json b/integrations/freshservice/package.json index 808960799b..19ae286300 100644 --- a/integrations/freshservice/package.json +++ b/integrations/freshservice/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/freshservice/src/auth.ts b/integrations/freshservice/src/auth.ts index db659c6b00..0c170b07b0 100644 --- a/integrations/freshservice/src/auth.ts +++ b/integrations/freshservice/src/auth.ts @@ -1,5 +1,19 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { freshserviceApiError, freshserviceServiceError } from './lib/errors'; + +let normalizeFreshworksDomain = (domain: string) => { + let normalized = domain + .trim() + .replace(/^https?:\/\//, '') + .replace(/\/$/, ''); + if (!normalized || !/^[a-z0-9][a-z0-9.-]*$/i.test(normalized)) { + throw freshserviceServiceError( + 'Freshworks organization domain must be a host such as "mycompany.myfreshworks.com".' + ); + } + return normalized; +}; export let auth = SlateAuth.create() .output( @@ -48,7 +62,7 @@ export let auth = SlateAuth.create() { title: 'Update Tickets', description: 'Update tickets and service requests', - scope: 'freshservice.tickets.update' + scope: 'freshservice.tickets.edit' }, { title: 'Delete Tickets', @@ -68,13 +82,18 @@ export let auth = SlateAuth.create() { title: 'Update Problems', description: 'Update problems', - scope: 'freshservice.problems.update' + scope: 'freshservice.problems.edit' }, { title: 'Delete Problems', description: 'Delete problems', scope: 'freshservice.problems.delete' }, + { + title: 'View Problem Fields', + description: 'View problem form field metadata', + scope: 'freshservice.problems.fields.view' + }, { title: 'View Changes', description: 'View change requests', @@ -88,28 +107,63 @@ export let auth = SlateAuth.create() { title: 'Update Changes', description: 'Update change requests', - scope: 'freshservice.changes.update' + scope: 'freshservice.changes.edit' }, { title: 'Delete Changes', description: 'Delete change requests', scope: 'freshservice.changes.delete' }, + { + title: 'View Ticket Conversations', + description: 'View ticket replies and notes', + scope: 'freshservice.tickets.conversations.view' + }, + { + title: 'Create Ticket Conversations', + description: 'Create ticket replies and notes', + scope: 'freshservice.tickets.conversations.create' + }, + { + title: 'Edit Ticket Conversations', + description: 'Update ticket conversation bodies', + scope: 'freshservice.tickets.conversations.edit' + }, + { + title: 'Delete Ticket Conversations', + description: 'Delete ticket conversations', + scope: 'freshservice.tickets.conversations.delete' + }, + { + title: 'Manage Ticket Fields', + description: 'View ticket form field metadata', + scope: 'freshservice.tickets.fields.manage' + }, { title: 'View Assets', description: 'View assets', scope: 'freshservice.assets.view' }, { title: 'Manage Assets', description: 'Create, update, and manage assets', scope: 'freshservice.assets.manage' }, + { + title: 'Delete Assets', + description: 'Delete or restore assets', + scope: 'freshservice.assets.delete' + }, { title: 'View Solutions', description: 'View knowledge base articles', scope: 'freshservice.solutions.view' }, { - title: 'Manage Solutions', - description: 'Manage knowledge base articles', - scope: 'freshservice.solutions.manage' + title: 'Publish Solutions', + description: 'Create and update knowledge base articles', + scope: 'freshservice.solutions.publish' + }, + { + title: 'Delete Solutions', + description: 'Delete or restore knowledge base articles', + scope: 'freshservice.solutions.delete' }, { title: 'View Releases', @@ -117,9 +171,19 @@ export let auth = SlateAuth.create() scope: 'freshservice.releases.view' }, { - title: 'Manage Releases', - description: 'Create and manage releases', - scope: 'freshservice.releases.manage' + title: 'Create Releases', + description: 'Create releases', + scope: 'freshservice.releases.create' + }, + { + title: 'Edit Releases', + description: 'Update releases', + scope: 'freshservice.releases.edit' + }, + { + title: 'Delete Releases', + description: 'Delete or restore releases', + scope: 'freshservice.releases.delete' }, { title: 'View Requesters', @@ -127,15 +191,69 @@ export let auth = SlateAuth.create() scope: 'freshservice.requesters.view' }, { - title: 'Manage Requesters', - description: 'Manage requesters', - scope: 'freshservice.requesters.manage' + title: 'Create Requesters', + description: 'Create requesters', + scope: 'freshservice.requesters.create' + }, + { + title: 'Edit Requesters', + description: 'Update requesters', + scope: 'freshservice.requesters.edit' + }, + { + title: 'Delete Requesters', + description: 'Deactivate or reactivate requesters', + scope: 'freshservice.requesters.delete' }, - { title: 'View Agents', description: 'View agents', scope: 'freshservice.agents.view' }, { title: 'Manage Agents', - description: 'Manage agents', + description: 'View agent records', scope: 'freshservice.agents.manage' + }, + { + title: 'View Departments', + description: 'View departments and department fields', + scope: 'freshservice.departments.view' + }, + { + title: 'View Department Fields', + description: 'View department field metadata', + scope: 'freshservice.departments.fields.view' + }, + { + title: 'View Agent Fields', + description: 'View agent field metadata', + scope: 'freshservice.agents.fields.view' + }, + { + title: 'View Requester Fields', + description: 'View requester field metadata', + scope: 'freshservice.requesters.fields.view' + }, + { + title: 'Manage Agent Groups', + description: 'View agent group records', + scope: 'freshservice.agentgroups.manage' + }, + { + title: 'View Locations', + description: 'View locations', + scope: 'freshservice.locations.view' + }, + { + title: 'View Vendors', + description: 'View vendors', + scope: 'freshservice.vendors.view' + }, + { + title: 'View Service Catalog', + description: 'View service catalog items', + scope: 'freshservice.service_catalog.view' + }, + { + title: 'Edit Service Catalog', + description: 'View service catalog item details and metadata', + scope: 'freshservice.service_catalog.edit' } ], inputSchema: z.object({ @@ -144,7 +262,7 @@ export let auth = SlateAuth.create() .describe('Your Freshworks organization domain (e.g. "mycompany.myfreshworks.com")') }), getAuthorizationUrl: async ctx => { - let domain = ctx.input.organizationDomain.replace(/^https?:\/\//, '').replace(/\/$/, ''); + let domain = normalizeFreshworksDomain(ctx.input.organizationDomain); let params = new URLSearchParams({ client_id: ctx.clientId, redirect_uri: ctx.redirectUri, @@ -158,24 +276,29 @@ export let auth = SlateAuth.create() }; }, handleCallback: async ctx => { - let domain = ctx.input.organizationDomain.replace(/^https?:\/\//, '').replace(/\/$/, ''); + let domain = normalizeFreshworksDomain(ctx.input.organizationDomain); let axios = createAxios(); let encoded = Buffer.from(`${ctx.clientId}:${ctx.clientSecret}`).toString('base64'); - let response = await axios.post( - `https://${domain}/oauth/token`, - { - code: ctx.code, - grant_type: 'authorization_code', - redirect_uri: ctx.redirectUri - }, - { - headers: { - Authorization: `Basic ${encoded}`, - 'Content-Type': 'application/json' + let response: any; + try { + response = await axios.post( + `https://${domain}/oauth/token`, + { + code: ctx.code, + grant_type: 'authorization_code', + redirect_uri: ctx.redirectUri + }, + { + headers: { + Authorization: `Basic ${encoded}`, + 'Content-Type': 'application/json' + } } - } - ); + ); + } catch (error) { + throw freshserviceApiError(error, 'exchange OAuth authorization code'); + } let expiresAt = response.data.expires_in ? new Date(Date.now() + response.data.expires_in * 1000).toISOString() : undefined; @@ -193,23 +316,33 @@ export let auth = SlateAuth.create() handleTokenRefresh: async (ctx: any) => { let domain = ctx.output.organizationDomain || - ctx.input.organizationDomain.replace(/^https?:\/\//, '').replace(/\/$/, ''); + normalizeFreshworksDomain(ctx.input.organizationDomain); + if (!ctx.output.refreshToken) { + throw freshserviceServiceError( + 'Freshservice OAuth refresh token is missing. Reconnect the Freshservice authentication profile.' + ); + } let axios = createAxios(); let encoded = Buffer.from(`${ctx.clientId}:${ctx.clientSecret}`).toString('base64'); - let response = await axios.post( - `https://${domain}/oauth/token`, - { - grant_type: 'refresh_token', - refresh_token: ctx.output.refreshToken - }, - { - headers: { - Authorization: `Basic ${encoded}`, - 'Content-Type': 'application/json' + let response: any; + try { + response = await axios.post( + `https://${domain}/oauth/token`, + { + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken + }, + { + headers: { + Authorization: `Basic ${encoded}`, + 'Content-Type': 'application/json' + } } - } - ); + ); + } catch (error) { + throw freshserviceApiError(error, 'refresh OAuth token'); + } let expiresAt = response.data.expires_in ? new Date(Date.now() + response.data.expires_in * 1000).toISOString() : undefined; diff --git a/integrations/freshservice/src/index.ts b/integrations/freshservice/src/index.ts index 5273399bb2..50ce25bfed 100644 --- a/integrations/freshservice/src/index.ts +++ b/integrations/freshservice/src/index.ts @@ -6,41 +6,66 @@ import { createChange, createKnowledgeBaseArticle, createProblem, + createRelease, createRequester, createTicket, deleteAsset, deleteChange, + deleteKnowledgeBaseArticle, deleteProblem, + deleteRelease, + deleteRequester, deleteTicket, + deleteTicketConversation, getAgent, + getAgentGroup, getAsset, getChange, getDepartment, getKnowledgeBaseArticle, + getLocation, getProblem, + getRelease, getRequester, getServiceCatalogItem, getTicket, + getTicketActivities, + getVendor, + listAgentGroups, listAgents, listAssets, listChanges, listDepartments, + listFormFields, listKnowledgeBaseArticles, listKnowledgeBaseCategories, listKnowledgeBaseFolders, + listLocations, listProblems, + listReleases, listRequesters, listServiceCatalogItems, listServiceCategories, + listTicketConversations, listTickets, + listVendors, placeServiceRequest, + reactivateRequester, + restoreAsset, + restoreKnowledgeBaseArticle, + restoreProblem, + restoreRelease, + restoreTicket, + searchAssets, searchTickets, updateAsset, updateChange, updateKnowledgeBaseArticle, updateProblem, + updateRelease, updateRequester, - updateTicket + updateTicket, + updateTicketConversation } from './tools'; import { changeUpdates, inboundWebhook, ticketUpdates } from './triggers'; @@ -59,26 +84,45 @@ export let provider = Slate.create({ listProblems, updateProblem, deleteProblem, + restoreProblem, createChange, getChange, listChanges, updateChange, deleteChange, + createRelease, + getRelease, + listReleases, + updateRelease, + deleteRelease, + restoreRelease, createAsset, getAsset, listAssets, updateAsset, deleteAsset, + restoreAsset, + searchAssets, createRequester, getRequester, listRequesters, updateRequester, + deleteRequester, + reactivateRequester, listAgents, getAgent, + listAgentGroups, + getAgentGroup, + listLocations, + getLocation, + listVendors, + getVendor, listKnowledgeBaseArticles, getKnowledgeBaseArticle, createKnowledgeBaseArticle, updateKnowledgeBaseArticle, + deleteKnowledgeBaseArticle, + restoreKnowledgeBaseArticle, listKnowledgeBaseCategories, listKnowledgeBaseFolders, listServiceCatalogItems, @@ -86,7 +130,13 @@ export let provider = Slate.create({ placeServiceRequest, listServiceCategories, listDepartments, - getDepartment + getDepartment, + restoreTicket, + listTicketConversations, + updateTicketConversation, + deleteTicketConversation, + getTicketActivities, + listFormFields ], triggers: [inboundWebhook, ticketUpdates, changeUpdates] }); diff --git a/integrations/freshservice/src/lib/client.ts b/integrations/freshservice/src/lib/client.ts index 4b43136ecc..fb94af7a0d 100644 --- a/integrations/freshservice/src/lib/client.ts +++ b/integrations/freshservice/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { freshserviceApiError, freshserviceServiceError } from './errors'; export interface ClientConfig { token: string; @@ -134,6 +135,47 @@ export interface ChangeUpdateParams { customFields?: Record; } +export interface ReleaseCreateParams { + subject: string; + description?: string; + status?: number; + priority?: number; + releaseType?: number; + groupId?: number; + agentId?: number; + departmentId?: number; + category?: string; + subCategory?: string; + itemCategory?: string; + plannedStartDate?: string; + plannedEndDate?: string; + workStartDate?: string; + workEndDate?: string; + customFields?: Record; + planningFields?: Record; + workspaceId?: number; +} + +export interface ReleaseUpdateParams { + subject?: string; + description?: string; + status?: number; + priority?: number; + releaseType?: number; + groupId?: number; + agentId?: number; + departmentId?: number; + category?: string; + subCategory?: string; + itemCategory?: string; + plannedStartDate?: string; + plannedEndDate?: string; + workStartDate?: string; + workEndDate?: string; + customFields?: Record; + planningFields?: Record; +} + export interface AssetCreateParams { name: string; assetTypeId: number; @@ -168,26 +210,57 @@ let toSnakeCase = (str: string): string => { return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); }; +let convertValueToSnakeCase = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(item => convertValueToSnakeCase(item)); + } + + if (typeof value === 'object' && value !== null) { + return convertKeysToSnakeCase(value as Record); + } + + return value; +}; + let convertKeysToSnakeCase = (obj: Record): Record => { let result: Record = {}; for (let [key, value] of Object.entries(obj)) { if (value === undefined || value === null) continue; let snakeKey = toSnakeCase(key); - if (typeof value === 'object' && !Array.isArray(value) && value !== null) { - result[snakeKey] = convertKeysToSnakeCase(value as Record); - } else { - result[snakeKey] = value; - } + result[snakeKey] = convertValueToSnakeCase(value); } return result; }; +let applyFreshserviceErrorInterceptor = (http: ReturnType) => { + http.interceptors.response.use( + response => response, + error => Promise.reject(freshserviceApiError(error)) + ); +}; + +let normalizeSubdomain = (subdomain: string) => { + let normalized = subdomain + .trim() + .replace(/^https?:\/\//, '') + .replace(/\/.*$/, '') + .replace(/\.freshservice\.com$/i, ''); + + if (!normalized || !/^[a-z0-9][a-z0-9-]*$/i.test(normalized)) { + throw freshserviceServiceError( + 'Freshservice subdomain must be the portal subdomain before .freshservice.com.' + ); + } + + return normalized; +}; + export class Client { private baseUrl: string; private headers: Record; constructor(clientConfig: ClientConfig) { - this.baseUrl = `https://${clientConfig.subdomain}.freshservice.com/api/v2`; + this.baseUrl = `https://${normalizeSubdomain(clientConfig.subdomain)}.freshservice.com/api/v2`; if (clientConfig.authType === 'api_key') { let encoded = Buffer.from(`${clientConfig.token}:X`).toString('base64'); this.headers = { @@ -203,10 +276,12 @@ export class Client { } private get axios() { - return createAxios({ + let http = createAxios({ baseURL: this.baseUrl, headers: this.headers }); + applyFreshserviceErrorInterceptor(http); + return http; } // ===== Tickets ===== @@ -298,6 +373,55 @@ export class Client { return response.data.conversations || []; } + async updateTicketConversation(conversationId: number, body: string) { + let response = await this.axios.put(`/conversations/${conversationId}`, { body }); + return response.data.conversation; + } + + async deleteTicketConversation(conversationId: number) { + await this.axios.delete(`/conversations/${conversationId}`); + } + + async getTicketActivities(ticketId: number, pagination?: PaginationParams) { + let params: Record = {}; + if (pagination?.page) params.page = pagination.page; + if (pagination?.perPage) params.per_page = pagination.perPage; + let response = await this.axios.get(`/tickets/${ticketId}/activities`, { params }); + return response.data.activities || []; + } + + async listFormFields(entity: string, pagination?: PaginationParams) { + let endpointByEntity: Record = { + ticket: '/ticket_form_fields', + problem: '/problem_form_fields', + change: '/change_form_fields', + release: '/release_form_fields', + requester: '/requester_fields', + agent: '/agent_fields', + department: '/department_fields' + }; + let endpoint = endpointByEntity[entity]; + if (!endpoint) { + throw freshserviceServiceError(`Unsupported Freshservice field entity: ${entity}`); + } + + let params: Record = {}; + if (pagination?.page) params.page = pagination.page; + if (pagination?.perPage) params.per_page = pagination.perPage; + let response = await this.axios.get(endpoint, { params }); + return ( + response.data.fields || + response.data.ticket_fields || + response.data.problem_fields || + response.data.change_fields || + response.data.release_fields || + response.data.requester_fields || + response.data.agent_fields || + response.data.department_fields || + [] + ); + } + // ===== Problems ===== async createProblem(params: ProblemCreateParams) { @@ -329,6 +453,11 @@ export class Client { await this.axios.delete(`/problems/${problemId}`); } + async restoreProblem(problemId: number) { + let response = await this.axios.put(`/problems/${problemId}/restore`); + return response.data; + } + // ===== Changes ===== async createChange(params: ChangeCreateParams) { @@ -393,9 +522,14 @@ export class Client { await this.axios.delete(`/assets/${assetId}`); } + async restoreAsset(assetId: number) { + let response = await this.axios.put(`/assets/${assetId}/restore`); + return response.data; + } + async searchAssets(query: string, page?: number) { let params: Record = {}; - if (query) params.query = query; + if (query) params.search = query; if (page) params.page = page; let response = await this.axios.get('/assets', { params }); return { assets: response.data.assets || [] }; @@ -471,6 +605,11 @@ export class Client { await this.axios.delete(`/requesters/${requesterId}`); } + async reactivateRequester(requesterId: number) { + let response = await this.axios.put(`/requesters/${requesterId}/reactivate`); + return response.data.requester; + } + // ===== Departments ===== async getDepartment(departmentId: number) { @@ -496,21 +635,21 @@ export class Client { return { categories: response.data.categories || [] }; } - async listSolutionFolders(categoryId: number, pagination?: PaginationParams) { + async listSolutionFolders(categoryId?: number, pagination?: PaginationParams) { let params: Record = {}; + if (categoryId) params.category_id = categoryId; if (pagination?.page) params.page = pagination.page; if (pagination?.perPage) params.per_page = pagination.perPage; - let response = await this.axios.get(`/solutions/categories/${categoryId}/folders`, { - params - }); + let response = await this.axios.get('/solutions/folders', { params }); return { folders: response.data.folders || [] }; } - async listSolutionArticles(folderId: number, pagination?: PaginationParams) { + async listSolutionArticles(folderId?: number, pagination?: PaginationParams) { let params: Record = {}; + if (folderId) params.folder_id = folderId; if (pagination?.page) params.page = pagination.page; if (pagination?.perPage) params.per_page = pagination.perPage; - let response = await this.axios.get(`/solutions/folders/${folderId}/articles`, { params }); + let response = await this.axios.get('/solutions/articles', { params }); return { articles: response.data.articles || [] }; } @@ -529,8 +668,11 @@ export class Client { tags?: string[]; } ) { - let body = convertKeysToSnakeCase(params as unknown as Record); - let response = await this.axios.post(`/solutions/folders/${folderId}/articles`, body); + let body = convertKeysToSnakeCase({ + ...params, + folderId + } as unknown as Record); + let response = await this.axios.post('/solutions/articles', body); return response.data.article; } @@ -543,6 +685,15 @@ export class Client { return response.data.article; } + async deleteSolutionArticle(articleId: number) { + await this.axios.delete(`/solutions/articles/${articleId}`); + } + + async restoreSolutionArticle(articleId: number) { + let response = await this.axios.put(`/solutions/articles/${articleId}/restore`); + return response.data; + } + // ===== Service Catalog ===== async listServiceCategories(pagination?: PaginationParams) { @@ -609,6 +760,11 @@ export class Client { return { locations: response.data.locations || [] }; } + async getLocation(locationId: number) { + let response = await this.axios.get(`/locations/${locationId}`); + return response.data.location; + } + // ===== Vendors ===== async listVendors(pagination?: PaginationParams) { @@ -619,20 +775,14 @@ export class Client { return { vendors: response.data.vendors || [] }; } + async getVendor(vendorId: number) { + let response = await this.axios.get(`/vendors/${vendorId}`); + return response.data.vendor; + } + // ===== Releases ===== - async createRelease(params: { - subject: string; - description?: string; - releaseType?: number; - status?: number; - priority?: number; - plannedStartDate?: string; - plannedEndDate?: string; - groupId?: number; - agentId?: number; - customFields?: Record; - }) { + async createRelease(params: ReleaseCreateParams) { let body = convertKeysToSnakeCase(params as unknown as Record); let response = await this.axios.post('/releases', body); return response.data.release; @@ -643,16 +793,22 @@ export class Client { return response.data.release; } - async listReleases(pagination?: PaginationParams) { - let params: Record = {}; + async listReleases( + pagination?: PaginationParams, + filterName?: string, + workspaceId?: number + ) { + let params: Record = {}; if (pagination?.page) params.page = pagination.page; if (pagination?.perPage) params.per_page = pagination.perPage; + if (filterName) params.filter_name = filterName; + if (workspaceId !== undefined) params.workspace_id = workspaceId; let response = await this.axios.get('/releases', { params }); - return { releases: response.data.releases || [] }; + return { releases: response.data.releases || response.data || [] }; } - async updateRelease(releaseId: number, params: Record) { - let body = convertKeysToSnakeCase(params); + async updateRelease(releaseId: number, params: ReleaseUpdateParams) { + let body = convertKeysToSnakeCase(params as unknown as Record); let response = await this.axios.put(`/releases/${releaseId}`, body); return response.data.release; } @@ -661,11 +817,16 @@ export class Client { await this.axios.delete(`/releases/${releaseId}`); } + async restoreRelease(releaseId: number) { + let response = await this.axios.put(`/releases/${releaseId}/restore`); + return response.data; + } + // ===== Ticket Fields ===== async listTicketFields() { - let response = await this.axios.get('/ticket_fields'); - return response.data.ticket_fields || []; + let response = await this.axios.get('/ticket_form_fields'); + return response.data.fields || response.data.ticket_fields || []; } // ===== Time Entries ===== diff --git a/integrations/freshservice/src/lib/errors.ts b/integrations/freshservice/src/lib/errors.ts new file mode 100644 index 0000000000..0058362b44 --- /dev/null +++ b/integrations/freshservice/src/lib/errors.ts @@ -0,0 +1,94 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let collectFreshserviceMessages = (value: unknown, messages: string[]) => { + if (!isRecord(value)) { + addMessage(messages, value); + return; + } + + for (let key of ['message', 'description', 'error', 'code']) { + addMessage(messages, value[key]); + } + + if (Array.isArray(value.errors)) { + for (let error of value.errors) { + collectFreshserviceMessages(error, messages); + } + } else if (isRecord(value.errors)) { + collectFreshserviceMessages(value.errors, messages); + } +}; + +let extractFreshserviceMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectFreshserviceMessages(response?.data, messages); + + if (isRecord(error)) { + collectFreshserviceMessages(error.data, messages); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getFreshserviceErrorStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let freshserviceServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let freshserviceApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getFreshserviceErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = freshserviceServiceError( + `Freshservice API ${operation} failed: ${statusLabel}${extractFreshserviceMessage(error)}` + ); + serviceError.data.reason = 'freshservice_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/freshservice/src/lib/validation.ts b/integrations/freshservice/src/lib/validation.ts new file mode 100644 index 0000000000..8e831b1818 --- /dev/null +++ b/integrations/freshservice/src/lib/validation.ts @@ -0,0 +1,12 @@ +import { createApiServiceError } from 'slates'; + +export let requireAtLeastOneDefined = >( + values: T, + message: string +) => { + if (!Object.values(values).some(value => value !== undefined)) { + throw createApiServiceError(message); + } + + return values; +}; diff --git a/integrations/freshservice/src/tools.schema.test.ts b/integrations/freshservice/src/tools.schema.test.ts new file mode 100644 index 0000000000..308a86793f --- /dev/null +++ b/integrations/freshservice/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Freshservice tool input schemas', provider.actions); diff --git a/integrations/freshservice/src/tools/create-ticket.ts b/integrations/freshservice/src/tools/create-ticket.ts index 62c543df1b..50f168cd9c 100644 --- a/integrations/freshservice/src/tools/create-ticket.ts +++ b/integrations/freshservice/src/tools/create-ticket.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { freshserviceServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createTicket = SlateTool.create(spec, { @@ -66,6 +67,12 @@ Source: 1=Email, 2=Portal, 3=Phone, 7=Chat, 9=Feedback Widget, 10=Outbound Email }) ) .handleInvocation(async ctx => { + if (!ctx.input.email && !ctx.input.phone && !ctx.input.requesterId) { + throw freshserviceServiceError( + 'Provide one requester identifier: email, phone, or requesterId.' + ); + } + let client = new Client({ token: ctx.auth.token, subdomain: ctx.config.subdomain, diff --git a/integrations/freshservice/src/tools/index.ts b/integrations/freshservice/src/tools/index.ts index 40da429d6f..2e1b922bce 100644 --- a/integrations/freshservice/src/tools/index.ts +++ b/integrations/freshservice/src/tools/index.ts @@ -4,12 +4,15 @@ export * from './delete-ticket'; export * from './get-ticket'; export * from './list-agents'; export * from './list-departments'; +export * from './list-lookup-resources'; export * from './list-tickets'; export * from './manage-asset'; export * from './manage-change'; export * from './manage-knowledge-base'; export * from './manage-problem'; +export * from './manage-release'; export * from './manage-requester'; export * from './manage-service-catalog'; +export * from './manage-ticket-metadata'; export * from './search-tickets'; export * from './update-ticket'; diff --git a/integrations/freshservice/src/tools/list-lookup-resources.ts b/integrations/freshservice/src/tools/list-lookup-resources.ts new file mode 100644 index 0000000000..4a697dad3e --- /dev/null +++ b/integrations/freshservice/src/tools/list-lookup-resources.ts @@ -0,0 +1,280 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let createClient = (ctx: { auth: any; config: any }) => + new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + +let mapAgentGroup = (group: Record) => ({ + groupId: group.id, + name: group.name, + description: group.description ?? null, + agentIds: group.agent_ids ?? null, + observerIds: group.observer_ids ?? null, + restricted: group.restricted ?? null, + createdAt: group.created_at ?? null, + updatedAt: group.updated_at ?? null +}); + +let mapLocation = (location: Record) => ({ + locationId: location.id, + name: location.name, + parentLocationId: location.parent_location_id ?? null, + primaryContactId: location.primary_contact_id ?? null, + address: location.address ?? null, + createdAt: location.created_at ?? null, + updatedAt: location.updated_at ?? null +}); + +let mapVendor = (vendor: Record) => ({ + vendorId: vendor.id, + name: vendor.name, + description: vendor.description ?? null, + primaryContactId: vendor.primary_contact_id ?? null, + createdAt: vendor.created_at ?? null, + updatedAt: vendor.updated_at ?? null +}); + +const paginationInput = { + page: z.number().optional().describe('Page number'), + perPage: z.number().optional().describe('Results per page') +}; + +export let listAgentGroups = SlateTool.create(spec, { + name: 'List Agent Groups', + key: 'list_agent_groups', + description: 'List Freshservice agent groups for assignment and routing.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object(paginationInput)) + .output( + z.object({ + groups: z + .array( + z.object({ + groupId: z.number().describe('Agent group ID'), + name: z.string().describe('Group name'), + description: z.string().nullable().describe('Group description'), + agentIds: z.array(z.number()).nullable().describe('Agent IDs in the group'), + observerIds: z.array(z.number()).nullable().describe('Observer IDs'), + restricted: z.boolean().nullable().describe('Whether the group is restricted'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .describe('Agent groups') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.listAgentGroups({ + page: ctx.input.page, + perPage: ctx.input.perPage + }); + let groups = result.groups.map(mapAgentGroup); + + return { + output: { groups }, + message: `Found **${groups.length}** agent groups` + }; + }) + .build(); + +export let getAgentGroup = SlateTool.create(spec, { + name: 'Get Agent Group', + key: 'get_agent_group', + description: 'Retrieve a Freshservice agent group by ID.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + groupId: z.number().describe('Agent group ID') + }) + ) + .output( + z.object({ + groupId: z.number().describe('Agent group ID'), + name: z.string().describe('Group name'), + description: z.string().nullable().describe('Group description'), + agentIds: z.array(z.number()).nullable().describe('Agent IDs in the group'), + observerIds: z.array(z.number()).nullable().describe('Observer IDs'), + restricted: z.boolean().nullable().describe('Whether the group is restricted'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let group = await client.getAgentGroup(ctx.input.groupId); + + return { + output: mapAgentGroup(group), + message: `Retrieved agent group **#${group.id}**: "${group.name}"` + }; + }) + .build(); + +export let listLocations = SlateTool.create(spec, { + name: 'List Locations', + key: 'list_locations', + description: 'List Freshservice locations for requester, department, and asset assignment.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object(paginationInput)) + .output( + z.object({ + locations: z + .array( + z.object({ + locationId: z.number().describe('Location ID'), + name: z.string().describe('Location name'), + parentLocationId: z.number().nullable().describe('Parent location ID'), + primaryContactId: z.number().nullable().describe('Primary contact ID'), + address: z.unknown().nullable().describe('Location address object'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .describe('Locations') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.listLocations({ + page: ctx.input.page, + perPage: ctx.input.perPage + }); + let locations = result.locations.map(mapLocation); + + return { + output: { locations }, + message: `Found **${locations.length}** locations` + }; + }) + .build(); + +export let getLocation = SlateTool.create(spec, { + name: 'Get Location', + key: 'get_location', + description: 'Retrieve a Freshservice location by ID.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + locationId: z.number().describe('Location ID') + }) + ) + .output( + z.object({ + locationId: z.number().describe('Location ID'), + name: z.string().describe('Location name'), + parentLocationId: z.number().nullable().describe('Parent location ID'), + primaryContactId: z.number().nullable().describe('Primary contact ID'), + address: z.unknown().nullable().describe('Location address object'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let location = await client.getLocation(ctx.input.locationId); + + return { + output: mapLocation(location), + message: `Retrieved location **#${location.id}**: "${location.name}"` + }; + }) + .build(); + +export let listVendors = SlateTool.create(spec, { + name: 'List Vendors', + key: 'list_vendors', + description: 'List Freshservice vendors for asset and procurement workflows.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object(paginationInput)) + .output( + z.object({ + vendors: z + .array( + z.object({ + vendorId: z.number().describe('Vendor ID'), + name: z.string().describe('Vendor name'), + description: z.string().nullable().describe('Vendor description'), + primaryContactId: z.number().nullable().describe('Primary contact ID'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .describe('Vendors') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.listVendors({ + page: ctx.input.page, + perPage: ctx.input.perPage + }); + let vendors = result.vendors.map(mapVendor); + + return { + output: { vendors }, + message: `Found **${vendors.length}** vendors` + }; + }) + .build(); + +export let getVendor = SlateTool.create(spec, { + name: 'Get Vendor', + key: 'get_vendor', + description: 'Retrieve a Freshservice vendor by ID.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + vendorId: z.number().describe('Vendor ID') + }) + ) + .output( + z.object({ + vendorId: z.number().describe('Vendor ID'), + name: z.string().describe('Vendor name'), + description: z.string().nullable().describe('Vendor description'), + primaryContactId: z.number().nullable().describe('Primary contact ID'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let vendor = await client.getVendor(ctx.input.vendorId); + + return { + output: mapVendor(vendor), + message: `Retrieved vendor **#${vendor.id}**: "${vendor.name}"` + }; + }) + .build(); diff --git a/integrations/freshservice/src/tools/manage-asset.ts b/integrations/freshservice/src/tools/manage-asset.ts index f06280d886..aa35328b16 100644 --- a/integrations/freshservice/src/tools/manage-asset.ts +++ b/integrations/freshservice/src/tools/manage-asset.ts @@ -254,7 +254,7 @@ export let updateAsset = SlateTool.create(spec, { export let deleteAsset = SlateTool.create(spec, { name: 'Delete Asset', key: 'delete_asset', - description: `Permanently delete an asset by its ID.`, + description: `Delete an asset by its ID. Deleted assets can be restored unless permanently deleted outside this tool.`, tags: { destructive: true, readOnly: false @@ -289,3 +289,101 @@ export let deleteAsset = SlateTool.create(spec, { }; }) .build(); + +export let restoreAsset = SlateTool.create(spec, { + name: 'Restore Asset', + key: 'restore_asset', + description: `Restore a deleted Freshservice asset by display ID.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + assetId: z.number().describe('Display ID of the deleted asset to restore') + }) + ) + .output( + z.object({ + assetId: z.number().describe('ID of the restored asset'), + restored: z.boolean().describe('Whether the restore was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + + await client.restoreAsset(ctx.input.assetId); + + return { + output: { + assetId: ctx.input.assetId, + restored: true + }, + message: `Restored asset **#${ctx.input.assetId}**` + }; + }) + .build(); + +export let searchAssets = SlateTool.create(spec, { + name: 'Search Assets', + key: 'search_assets', + description: `Search Freshservice assets by name, serial number, MAC address, IP address, UUID, or IMEI.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + query: z.string().describe('Asset search query'), + page: z.number().optional().describe('Page number') + }) + ) + .output( + z.object({ + assets: z.array( + z.object({ + assetId: z.number().describe('ID'), + name: z.string().describe('Name'), + assetTag: z.string().nullable().describe('Asset tag'), + assetTypeId: z.number().describe('Asset type ID'), + impact: z.string().nullable().describe('Impact'), + userId: z.number().nullable().describe('User ID'), + departmentId: z.number().nullable().describe('Department ID'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().describe('Last update timestamp') + }) + ) + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + + let result = await client.searchAssets(ctx.input.query, ctx.input.page); + let assets = result.assets.map((a: Record) => ({ + assetId: a.id as number, + name: a.name as string, + assetTag: a.asset_tag as string | null, + assetTypeId: a.asset_type_id as number, + impact: a.impact as string | null, + userId: a.user_id as number | null, + departmentId: a.department_id as number | null, + createdAt: a.created_at as string, + updatedAt: a.updated_at as string + })); + + return { + output: { assets }, + message: `Found **${assets.length}** assets matching "${ctx.input.query}"` + }; + }) + .build(); diff --git a/integrations/freshservice/src/tools/manage-knowledge-base.ts b/integrations/freshservice/src/tools/manage-knowledge-base.ts index d2b8e3ea36..16f858b132 100644 --- a/integrations/freshservice/src/tools/manage-knowledge-base.ts +++ b/integrations/freshservice/src/tools/manage-knowledge-base.ts @@ -6,9 +6,9 @@ import { spec } from '../spec'; export let listKnowledgeBaseArticles = SlateTool.create(spec, { name: 'List Knowledge Base Articles', key: 'list_knowledge_base_articles', - description: `List knowledge base articles from a specific folder. To discover folders, first list categories, then list folders within a category.`, + description: `List knowledge base articles. Optionally filter by folder. To discover folders, list categories and folders first.`, instructions: [ - 'Use listKnowledgeBaseCategories first to find category IDs, then get folders within a category to find folder IDs.' + 'Use folderId to filter articles by folder, or omit it to list articles across accessible folders.' ], tags: { destructive: false, @@ -17,7 +17,7 @@ export let listKnowledgeBaseArticles = SlateTool.create(spec, { }) .input( z.object({ - folderId: z.number().describe('ID of the solution folder to list articles from'), + folderId: z.number().optional().describe('ID of the solution folder to filter by'), page: z.number().optional().describe('Page number'), perPage: z.number().optional().describe('Results per page') }) @@ -223,6 +223,84 @@ export let updateKnowledgeBaseArticle = SlateTool.create(spec, { }) .build(); +export let deleteKnowledgeBaseArticle = SlateTool.create(spec, { + name: 'Delete Knowledge Base Article', + key: 'delete_knowledge_base_article', + description: `Delete a Freshservice knowledge base article. Deleted articles can be restored.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + articleId: z.number().describe('ID of the article to delete') + }) + ) + .output( + z.object({ + articleId: z.number().describe('ID of the deleted article'), + deleted: z.boolean().describe('Whether deletion was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + + await client.deleteSolutionArticle(ctx.input.articleId); + + return { + output: { + articleId: ctx.input.articleId, + deleted: true + }, + message: `Deleted article **#${ctx.input.articleId}**` + }; + }) + .build(); + +export let restoreKnowledgeBaseArticle = SlateTool.create(spec, { + name: 'Restore Knowledge Base Article', + key: 'restore_knowledge_base_article', + description: `Restore a deleted Freshservice knowledge base article.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + articleId: z.number().describe('ID of the deleted article to restore') + }) + ) + .output( + z.object({ + articleId: z.number().describe('ID of the restored article'), + restored: z.boolean().describe('Whether restore was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + + await client.restoreSolutionArticle(ctx.input.articleId); + + return { + output: { + articleId: ctx.input.articleId, + restored: true + }, + message: `Restored article **#${ctx.input.articleId}**` + }; + }) + .build(); + export let listKnowledgeBaseCategories = SlateTool.create(spec, { name: 'List Knowledge Base Categories', key: 'list_knowledge_base_categories', @@ -281,7 +359,7 @@ export let listKnowledgeBaseCategories = SlateTool.create(spec, { export let listKnowledgeBaseFolders = SlateTool.create(spec, { name: 'List Knowledge Base Folders', key: 'list_knowledge_base_folders', - description: `List all folders within a knowledge base category. Each folder contains articles.`, + description: `List knowledge base folders. Optionally filter by category. Each folder contains articles.`, tags: { destructive: false, readOnly: true @@ -289,7 +367,7 @@ export let listKnowledgeBaseFolders = SlateTool.create(spec, { }) .input( z.object({ - categoryId: z.number().describe('ID of the category to list folders from'), + categoryId: z.number().optional().describe('ID of the category to filter by'), page: z.number().optional().describe('Page number'), perPage: z.number().optional().describe('Results per page') }) @@ -333,7 +411,7 @@ export let listKnowledgeBaseFolders = SlateTool.create(spec, { return { output: { folders }, - message: `Found **${folders.length}** folders in category #${ctx.input.categoryId}` + message: `Found **${folders.length}** knowledge base folders` }; }) .build(); diff --git a/integrations/freshservice/src/tools/manage-problem.ts b/integrations/freshservice/src/tools/manage-problem.ts index 6e3c32827b..606538ae8f 100644 --- a/integrations/freshservice/src/tools/manage-problem.ts +++ b/integrations/freshservice/src/tools/manage-problem.ts @@ -263,7 +263,7 @@ Priority: 1=Low, 2=Medium, 3=High, 4=Urgent.`, export let deleteProblem = SlateTool.create(spec, { name: 'Delete Problem', key: 'delete_problem', - description: `Permanently delete a problem by its ID.`, + description: `Delete a problem by its ID. Deleted problems can be restored.`, tags: { destructive: true, readOnly: false @@ -298,3 +298,42 @@ export let deleteProblem = SlateTool.create(spec, { }; }) .build(); + +export let restoreProblem = SlateTool.create(spec, { + name: 'Restore Problem', + key: 'restore_problem', + description: `Restore a deleted Freshservice problem by ID.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + problemId: z.number().describe('ID of the deleted problem to restore') + }) + ) + .output( + z.object({ + problemId: z.number().describe('ID of the restored problem'), + restored: z.boolean().describe('Whether the restore was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + + await client.restoreProblem(ctx.input.problemId); + + return { + output: { + problemId: ctx.input.problemId, + restored: true + }, + message: `Restored problem **#${ctx.input.problemId}**` + }; + }) + .build(); diff --git a/integrations/freshservice/src/tools/manage-release.ts b/integrations/freshservice/src/tools/manage-release.ts new file mode 100644 index 0000000000..2d388f44b2 --- /dev/null +++ b/integrations/freshservice/src/tools/manage-release.ts @@ -0,0 +1,261 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { requireAtLeastOneDefined } from '../lib/validation'; +import { spec } from '../spec'; + +const releaseFields = { + subject: z.string().optional().describe('Release subject'), + description: z.string().optional().describe('HTML description of the release'), + status: z + .number() + .optional() + .describe('Status: 1=Open, 2=On hold, 3=In Progress, 4=Incomplete, 5=Completed'), + priority: z.number().optional().describe('Priority: 1=Low, 2=Medium, 3=High, 4=Urgent'), + releaseType: z + .number() + .optional() + .describe('Release type: 1=Minor, 2=Standard, 3=Major, 4=Emergency'), + groupId: z.number().optional().describe('Agent group ID'), + agentId: z.number().optional().describe('Assigned agent ID'), + departmentId: z.number().optional().describe('Department ID'), + category: z.string().optional().describe('Category'), + subCategory: z.string().optional().describe('Sub-category'), + itemCategory: z.string().optional().describe('Item category'), + plannedStartDate: z.string().optional().describe('Planned start date (ISO 8601)'), + plannedEndDate: z.string().optional().describe('Planned end date (ISO 8601)'), + workStartDate: z.string().optional().describe('Actual work start date (ISO 8601)'), + workEndDate: z.string().optional().describe('Actual work end date (ISO 8601)'), + customFields: z.record(z.string(), z.unknown()).optional().describe('Custom fields'), + planningFields: z + .record(z.string(), z.unknown()) + .optional() + .describe('Planning fields such as build and test plans') +}; + +const releaseOutput = z.object({ + releaseId: z.number().describe('Freshservice release ID'), + subject: z.string().describe('Release subject'), + status: z.number().nullable().describe('Release status'), + priority: z.number().nullable().describe('Release priority'), + releaseType: z.number().nullable().describe('Release type'), + agentId: z.number().nullable().describe('Assigned agent ID'), + groupId: z.number().nullable().describe('Agent group ID'), + plannedStartDate: z.string().nullable().describe('Planned start date'), + plannedEndDate: z.string().nullable().describe('Planned end date'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') +}); + +let createClient = (ctx: { auth: any; config: any }) => + new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + +let mapRelease = (release: Record) => ({ + releaseId: release.id, + subject: release.subject, + status: release.status ?? null, + priority: release.priority ?? null, + releaseType: release.release_type ?? null, + agentId: release.agent_id ?? null, + groupId: release.group_id ?? null, + plannedStartDate: release.planned_start_date ?? null, + plannedEndDate: release.planned_end_date ?? null, + createdAt: release.created_at ?? null, + updatedAt: release.updated_at ?? null +}); + +export let createRelease = SlateTool.create(spec, { + name: 'Create Release', + key: 'create_release', + description: `Create a Freshservice release request for planned deployment work. + +Status: 1=Open, 2=On hold, 3=In Progress, 4=Incomplete, 5=Completed. +Release type: 1=Minor, 2=Standard, 3=Major, 4=Emergency.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + ...releaseFields, + subject: z.string().describe('Release subject'), + workspaceId: z + .number() + .optional() + .describe('Workspace ID. If omitted, Freshservice uses the primary workspace.') + }) + ) + .output(releaseOutput) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let release = await client.createRelease(ctx.input); + + return { + output: mapRelease(release), + message: `Created release **#${release.id}**: "${release.subject}"` + }; + }) + .build(); + +export let getRelease = SlateTool.create(spec, { + name: 'Get Release', + key: 'get_release', + description: 'Retrieve a Freshservice release by ID.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + releaseId: z.number().describe('ID of the release') + }) + ) + .output(releaseOutput) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let release = await client.getRelease(ctx.input.releaseId); + + return { + output: mapRelease(release), + message: `Retrieved release **#${release.id}**: "${release.subject}"` + }; + }) + .build(); + +export let listReleases = SlateTool.create(spec, { + name: 'List Releases', + key: 'list_releases', + description: `List Freshservice releases. Use filterName for documented release filters such as all, my_open, unassigned, completed, incomplete, or deleted.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + filterName: z.string().optional().describe('Optional Freshservice release filter name'), + workspaceId: z + .number() + .optional() + .describe('Workspace ID. Use 0 to list releases across all accessible workspaces.'), + page: z.number().optional().describe('Page number'), + perPage: z.number().optional().describe('Results per page') + }) + ) + .output( + z.object({ + releases: z.array(releaseOutput).describe('Freshservice releases') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.listReleases( + { page: ctx.input.page, perPage: ctx.input.perPage }, + ctx.input.filterName, + ctx.input.workspaceId + ); + let releases = result.releases.map(mapRelease); + + return { + output: { releases }, + message: `Found **${releases.length}** releases` + }; + }) + .build(); + +export let updateRelease = SlateTool.create(spec, { + name: 'Update Release', + key: 'update_release', + description: 'Update an existing Freshservice release.', + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + releaseId: z.number().describe('ID of the release to update'), + ...releaseFields + }) + ) + .output(releaseOutput) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let { releaseId, ...updates } = ctx.input; + requireAtLeastOneDefined(updates, 'Provide at least one field to update a release.'); + let release = await client.updateRelease(releaseId, updates); + + return { + output: mapRelease(release), + message: `Updated release **#${release.id}**: "${release.subject}"` + }; + }) + .build(); + +export let deleteRelease = SlateTool.create(spec, { + name: 'Delete Release', + key: 'delete_release', + description: 'Delete a Freshservice release. Deleted releases can be restored.', + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + releaseId: z.number().describe('ID of the release to delete') + }) + ) + .output( + z.object({ + releaseId: z.number().describe('ID of the deleted release'), + deleted: z.boolean().describe('Whether the deletion was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + await client.deleteRelease(ctx.input.releaseId); + + return { + output: { releaseId: ctx.input.releaseId, deleted: true }, + message: `Deleted release **#${ctx.input.releaseId}**` + }; + }) + .build(); + +export let restoreRelease = SlateTool.create(spec, { + name: 'Restore Release', + key: 'restore_release', + description: 'Restore a deleted Freshservice release.', + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + releaseId: z.number().describe('ID of the deleted release to restore') + }) + ) + .output( + z.object({ + releaseId: z.number().describe('ID of the restored release'), + restored: z.boolean().describe('Whether the restore was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + await client.restoreRelease(ctx.input.releaseId); + + return { + output: { releaseId: ctx.input.releaseId, restored: true }, + message: `Restored release **#${ctx.input.releaseId}**` + }; + }) + .build(); diff --git a/integrations/freshservice/src/tools/manage-requester.ts b/integrations/freshservice/src/tools/manage-requester.ts index b33fbf20d1..a9b9db30a4 100644 --- a/integrations/freshservice/src/tools/manage-requester.ts +++ b/integrations/freshservice/src/tools/manage-requester.ts @@ -228,3 +228,87 @@ export let updateRequester = SlateTool.create(spec, { }; }) .build(); + +export let deleteRequester = SlateTool.create(spec, { + name: 'Deactivate Requester', + key: 'delete_requester', + description: `Deactivate a requester/contact in Freshservice. Deactivated requesters can be reactivated.`, + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + requesterId: z.number().describe('ID of the requester to deactivate') + }) + ) + .output( + z.object({ + requesterId: z.number().describe('ID of the deactivated requester'), + deleted: z.boolean().describe('Whether deactivation was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + + await client.deleteRequester(ctx.input.requesterId); + + return { + output: { + requesterId: ctx.input.requesterId, + deleted: true + }, + message: `Deactivated requester **#${ctx.input.requesterId}**` + }; + }) + .build(); + +export let reactivateRequester = SlateTool.create(spec, { + name: 'Reactivate Requester', + key: 'reactivate_requester', + description: `Reactivate a deactivated requester/contact in Freshservice.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + requesterId: z.number().describe('ID of the requester to reactivate') + }) + ) + .output( + z.object({ + requesterId: z.number().describe('ID of the reactivated requester'), + firstName: z.string().describe('First name'), + lastName: z.string().nullable().describe('Last name'), + email: z.string().nullable().describe('Primary email'), + active: z.boolean().nullable().describe('Whether the requester is active') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + + let requester = await client.reactivateRequester(ctx.input.requesterId); + + return { + output: { + requesterId: requester.id, + firstName: requester.first_name, + lastName: requester.last_name, + email: requester.primary_email, + active: requester.active ?? null + }, + message: `Reactivated requester **#${requester.id}**` + }; + }) + .build(); diff --git a/integrations/freshservice/src/tools/manage-ticket-metadata.ts b/integrations/freshservice/src/tools/manage-ticket-metadata.ts new file mode 100644 index 0000000000..0cd8c4231b --- /dev/null +++ b/integrations/freshservice/src/tools/manage-ticket-metadata.ts @@ -0,0 +1,277 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let createClient = (ctx: { auth: any; config: any }) => + new Client({ + token: ctx.auth.token, + subdomain: ctx.config.subdomain, + authType: ctx.auth.authType + }); + +let mapConversation = (conversation: Record, ticketId?: number) => ({ + conversationId: conversation.id, + ticketId: conversation.ticket_id ?? ticketId ?? null, + bodyText: conversation.body_text ?? null, + body: conversation.body ?? null, + private: conversation.private ?? null, + userId: conversation.user_id ?? null, + source: conversation.source ?? null, + createdAt: conversation.created_at ?? null, + updatedAt: conversation.updated_at ?? null +}); + +let mapField = (field: Record) => ({ + fieldId: String(field.id ?? field.name ?? field.field_name), + name: field.name ?? field.label ?? field.field_name, + label: field.label ?? field.name ?? field.field_name ?? null, + type: field.type ?? field.field_type ?? null, + requiredForAgents: field.required_for_agents ?? null, + requiredForRequesters: field.required_for_requesters ?? null, + default: field.default ?? null, + choices: field.choices ?? field.nested_fields ?? null +}); + +export let restoreTicket = SlateTool.create(spec, { + name: 'Restore Ticket', + key: 'restore_ticket', + description: 'Restore a deleted Freshservice ticket.', + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + ticketId: z.number().describe('ID of the deleted ticket to restore') + }) + ) + .output( + z.object({ + ticketId: z.number().describe('ID of the restored ticket'), + restored: z.boolean().describe('Whether the restore was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + await client.restoreTicket(ctx.input.ticketId); + + return { + output: { ticketId: ctx.input.ticketId, restored: true }, + message: `Restored ticket **#${ctx.input.ticketId}**` + }; + }) + .build(); + +export let listTicketConversations = SlateTool.create(spec, { + name: 'List Ticket Conversations', + key: 'list_ticket_conversations', + description: 'List replies and notes on a Freshservice ticket.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ticketId: z.number().describe('ID of the ticket'), + page: z.number().optional().describe('Page number'), + perPage: z.number().optional().describe('Results per page') + }) + ) + .output( + z.object({ + conversations: z + .array( + z.object({ + conversationId: z.number().describe('Conversation ID'), + ticketId: z.number().nullable().describe('Ticket ID'), + bodyText: z.string().nullable().describe('Plain text body'), + body: z.string().nullable().describe('HTML body'), + private: z.boolean().nullable().describe('Whether the note is private'), + userId: z.number().nullable().describe('Author user ID'), + source: z.number().nullable().describe('Freshservice conversation source'), + createdAt: z.string().nullable().describe('Creation timestamp'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .describe('Ticket conversations') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let conversations = ( + await client.listTicketConversations(ctx.input.ticketId, { + page: ctx.input.page, + perPage: ctx.input.perPage + }) + ).map((conversation: Record) => + mapConversation(conversation, ctx.input.ticketId) + ); + + return { + output: { conversations }, + message: `Found **${conversations.length}** conversations on ticket #${ctx.input.ticketId}` + }; + }) + .build(); + +export let updateTicketConversation = SlateTool.create(spec, { + name: 'Update Ticket Conversation', + key: 'update_ticket_conversation', + description: 'Update the body of a Freshservice ticket conversation.', + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + conversationId: z.number().describe('ID of the conversation to update'), + body: z.string().describe('Updated HTML body') + }) + ) + .output( + z.object({ + conversationId: z.number().describe('Conversation ID'), + bodyText: z.string().nullable().describe('Updated plain text body'), + updatedAt: z.string().nullable().describe('Last update timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let conversation = await client.updateTicketConversation( + ctx.input.conversationId, + ctx.input.body + ); + + return { + output: { + conversationId: conversation.id, + bodyText: conversation.body_text ?? null, + updatedAt: conversation.updated_at ?? null + }, + message: `Updated conversation **#${conversation.id}**` + }; + }) + .build(); + +export let deleteTicketConversation = SlateTool.create(spec, { + name: 'Delete Ticket Conversation', + key: 'delete_ticket_conversation', + description: 'Delete a Freshservice ticket conversation.', + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + conversationId: z.number().describe('ID of the conversation to delete') + }) + ) + .output( + z.object({ + conversationId: z.number().describe('Deleted conversation ID'), + deleted: z.boolean().describe('Whether the deletion was accepted') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + await client.deleteTicketConversation(ctx.input.conversationId); + + return { + output: { conversationId: ctx.input.conversationId, deleted: true }, + message: `Deleted conversation **#${ctx.input.conversationId}**` + }; + }) + .build(); + +export let getTicketActivities = SlateTool.create(spec, { + name: 'Get Ticket Activities', + key: 'get_ticket_activities', + description: 'Get the activity feed for a Freshservice ticket.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ticketId: z.number().describe('ID of the ticket'), + page: z.number().optional().describe('Page number'), + perPage: z.number().optional().describe('Results per page') + }) + ) + .output( + z.object({ + activities: z.array(z.record(z.string(), z.unknown())).describe('Ticket activities') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let activities = await client.getTicketActivities(ctx.input.ticketId, { + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + return { + output: { activities }, + message: `Found **${activities.length}** activities on ticket #${ctx.input.ticketId}` + }; + }) + .build(); + +export let listFormFields = SlateTool.create(spec, { + name: 'List Form Fields', + key: 'list_form_fields', + description: + 'List Freshservice form field metadata for tickets, problems, changes, releases, requesters, agents, or departments.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + entity: z + .enum(['ticket', 'problem', 'change', 'release', 'requester', 'agent', 'department']) + .describe('Freshservice entity whose form fields should be listed'), + page: z.number().optional().describe('Page number'), + perPage: z.number().optional().describe('Results per page') + }) + ) + .output( + z.object({ + fields: z + .array( + z.object({ + fieldId: z.string().describe('Field ID or stable name'), + name: z.string().nullable().describe('Field API name'), + label: z.string().nullable().describe('Field label'), + type: z.string().nullable().describe('Field type'), + requiredForAgents: z.boolean().nullable().describe('Required for agents'), + requiredForRequesters: z.boolean().nullable().describe('Required for requesters'), + default: z.unknown().nullable().describe('Default value'), + choices: z.unknown().nullable().describe('Allowed choices or nested fields') + }) + ) + .describe('Form fields') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let fields = ( + await client.listFormFields(ctx.input.entity, { + page: ctx.input.page, + perPage: ctx.input.perPage + }) + ).map((field: Record) => mapField(field)); + + return { + output: { fields }, + message: `Found **${fields.length}** ${ctx.input.entity} fields` + }; + }) + .build(); diff --git a/integrations/freshservice/src/tools/update-ticket.ts b/integrations/freshservice/src/tools/update-ticket.ts index fe1c04b9f8..f98f3faeeb 100644 --- a/integrations/freshservice/src/tools/update-ticket.ts +++ b/integrations/freshservice/src/tools/update-ticket.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { requireAtLeastOneDefined } from '../lib/validation'; import { spec } from '../spec'; export let updateTicket = SlateTool.create(spec, { @@ -58,6 +59,8 @@ Status: 2=Open, 3=Pending, 4=Resolved, 5=Closed.`, }); let { ticketId, ...updateParams } = ctx.input; + requireAtLeastOneDefined(updateParams, 'Provide at least one field to update a ticket.'); + let ticket = await client.updateTicket(ticketId, updateParams); return { diff --git a/integrations/freshservice/vitest.config.ts b/integrations/freshservice/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/freshservice/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/front/README.md b/integrations/front/README.md index 5052097853..513e875246 100644 --- a/integrations/front/README.md +++ b/integrations/front/README.md @@ -1,6 +1,6 @@ # Front -Manage shared inboxes and team-based communication across email, SMS, chat, and social media. Create, reply to, and manage conversations including assignment, tagging, archiving, and moving between inboxes. Send messages, create drafts, and import historical messages. Manage contacts, accounts, and contact groups with custom fields and handles. Add internal comments and mentions for team collaboration. Create and organize tags with hierarchical structures. Configure and manage channels connecting external messaging platforms. Create and manage knowledge base articles and categories. Export analytics data on team performance and response times. Manage message templates, teammate shifts, email signatures, and automation rules. Listen for real-time webhook events on conversation assignments, inbound/outbound messages, tagging changes, and conversation lifecycle updates. +Manage shared inboxes and team-based communication across email, SMS, chat, and social media. List, read, reply to, and manage conversations including assignment, status, tags, followers, links, reminders, custom fields, and inbox moves. Send messages through Front channels, manage contacts, merge duplicate contacts, manage accounts, create tags, attach links, add internal comments, discover inboxes and channels, manage teams and teammates, work with message templates, request analytics reports/exports, read knowledge base content, and listen for webhook events. ## Tools @@ -8,6 +8,10 @@ Manage shared inboxes and team-based communication across email, SMS, chat, and Add an internal comment to a conversation or reply to an existing comment. Comments are visible only to teammates, not external contacts. Supports @mentions by including teammate handles in the body. +### List Channels + +List Front channels available in the company. Use this to find channel IDs or addresses for sending new outbound messages. + ### Create Analytics Export Create a new analytics data export for a specified time range. Exports can be used to extract analytics data for BI tools or data warehouses. The export is processed asynchronously — use the returned export ID to check status. @@ -36,6 +40,10 @@ List company/organization accounts in Front with optional pagination and sorting List and search contacts in Front. Supports filtering by search query and pagination. +### Merge Contacts + +Merge duplicate Front contacts into one contact. Front deletes the merged-in contacts and preserves handles, groups, links, and notes on the surviving contact. + ### List Inboxes List all inboxes (shared and private) available in Front. Optionally includes channels and conversations for each inbox. @@ -70,7 +78,7 @@ Send a reply to an existing Front conversation. The reply will be sent to the co ### Update Conversation -Update a conversation's properties including status, assignee, tags, followers, and inbox. Supports assigning/unassigning teammates, archiving, reopening, trashing, restoring, adding/removing tags, and managing followers — all in a single flexible operation. +Update a conversation's properties including status, assignee, tags, followers, links, reminders, description, custom fields, and inbox. Supports assigning/unassigning teammates, archiving, reopening, trashing, restoring, adding/removing tags and links, and managing followers — all in a single flexible operation. ## License diff --git a/integrations/front/docs/SPEC.md b/integrations/front/docs/SPEC.md index 3146b146b4..6c07a0a1d2 100644 --- a/integrations/front/docs/SPEC.md +++ b/integrations/front/docs/SPEC.md @@ -1,5 +1,3 @@ -Now let me get the specific OAuth scopes and webhook event types from Front's documentation.Now let me get the full list of event types:The events page didn't render the table with event types. Let me search for the specific event types.Now I have comprehensive information to write the specification. Let me compile it. - # Slates Specification for Front ## Overview @@ -31,26 +29,22 @@ Front's OAuth implementation uses the **authorization code grant type**: ### Token Scopes -Scopes are configured when creating API tokens or setting up OAuth and cover three dimensions: - -**Features:** - -- **Access resources** — manage Core API resources (conversations, contacts, inboxes, etc.) -- **Auto-provisioning** — manage provisioning resources via SCIM (not generally available) -- **Application triggers** — process events from external services (not available for OAuth tokens) - -**Namespaces:** - -- **Global resources** — company-level resources (company rules, teams, accounts) -- **Shared resources** — workspace-scoped resources (shared inboxes, workspace tags); can be set to all shared workspaces or specific ones -- **Private resources** — individual teammate resources (personal inboxes, signatures); requires the teammate to enable API access - -**Permissions** (per namespace): - -- **Read** — retrieve resource information -- **Write** — create and update resources -- **Delete** — remove resources -- **Send** — create and send messages (distinct from importing historical messages, which only requires Write) +Front's current Core API reference lists resource-specific required scopes per endpoint. This integration requests scopes for the resources it exposes: + +- `conversations:read` and `conversations:write` +- `messages:read` and `messages:send` +- `contacts:read`, `contacts:write`, and `contacts:delete` +- `accounts:read`, `accounts:write`, and `accounts:delete` +- `inboxes:read` and `channels:read` +- `tags:read`, `tags:write`, and `tags:delete` +- `comments:write` +- `teammates:read` and `teammates:write` +- `teams:read` and `teams:write` +- `links:read` and `links:write` +- `message_templates:read`, `message_templates:write`, and `message_templates:delete` +- `analytics:read` +- `knowledge_bases:read` +- `events:*:read` ## Features diff --git a/integrations/front/package.json b/integrations/front/package.json index f520377d84..aa3c227237 100644 --- a/integrations/front/package.json +++ b/integrations/front/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/front/slate.json b/integrations/front/slate.json index ad5149d3b1..e10d348dfd 100644 --- a/integrations/front/slate.json +++ b/integrations/front/slate.json @@ -1,14 +1,17 @@ { "name": "@metorial/front", - "description": "Manage shared inboxes and team-based communication across email, SMS, chat, and social media. Create, reply to, and manage conversations including assignment, tagging, archiving, and moving between inboxes. Send messages, create drafts, and import historical messages. Manage contacts, accounts, and contact groups with custom fields and handles. Add internal comments and mentions for team collaboration. Create and organize tags with hierarchical structures. Configure and manage channels connecting external messaging platforms. Create and manage knowledge base articles and categories. Export analytics data on team performance and response times. Manage message templates, teammate shifts, email signatures, and automation rules. Listen for real-time webhook events on conversation assignments, inbound/outbound messages, tagging changes, and conversation lifecycle updates.", + "description": "Manage Front shared inboxes and team-based communication across email, SMS, chat, and social media. List, read, reply to, and manage conversations including assignment, status, tags, followers, links, reminders, custom fields, and inbox moves. Send messages through Front channels, manage contacts and duplicate merges, manage accounts, tags, links, inboxes, channels, teams, teammates, message templates, analytics reports and exports, knowledge base content, and webhook events.", "categories": ["crm-and-sales-tools", "email-and-messaging"], "skills": [ "manage shared inboxes", "send and reply messages", "assign conversations to teammates", "manage contacts and accounts", + "merge duplicate contacts", "tag and organize conversations", + "link conversations to external records", "add internal comments", + "discover inboxes and channels", "create message templates", "export analytics reports", "manage knowledge base articles", diff --git a/integrations/front/src/auth.ts b/integrations/front/src/auth.ts index 051bca9b20..5db67d391e 100644 --- a/integrations/front/src/auth.ts +++ b/integrations/front/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { frontApiError, frontServiceError } from './lib/errors'; let oauthAxios = createAxios({ baseURL: 'https://app.frontapp.com' @@ -36,61 +37,145 @@ export let auth = SlateAuth.create() scopes: [ { - title: 'Read Shared', + title: 'Read conversations', + description: 'Read Front conversations and conversation metadata', + scope: 'conversations:read' + }, + { + title: 'Write conversations', description: - 'Read access to shared workspace resources (conversations, inboxes, tags, etc.)', - scope: 'shared:resources:read' + 'Update conversation status, assignment, tags, followers, links, and reminders', + scope: 'conversations:write' }, { - title: 'Write Shared', - description: 'Write access to shared workspace resources', - scope: 'shared:resources:write' + title: 'Read messages', + description: 'Read messages in conversations', + scope: 'messages:read' }, { - title: 'Delete Shared', - description: 'Delete access to shared workspace resources', - scope: 'shared:resources:delete' + title: 'Send messages', + description: 'Send new messages and replies from Front channels', + scope: 'messages:send' }, { - title: 'Send Shared', - description: 'Send messages from shared inboxes', - scope: 'shared:resources:send' + title: 'Read contacts', + description: 'Read Front contacts', + scope: 'contacts:read' }, { - title: 'Read Global', - description: - 'Read access to global/company-level resources (teams, accounts, company rules)', - scope: 'global:resources:read' + title: 'Write contacts', + description: 'Create, update, merge, and manage Front contact handles', + scope: 'contacts:write' + }, + { + title: 'Delete contacts', + description: 'Delete Front contacts', + scope: 'contacts:delete' + }, + { + title: 'Read accounts', + description: 'Read Front accounts', + scope: 'accounts:read' + }, + { + title: 'Write accounts', + description: 'Create and update Front accounts', + scope: 'accounts:write' + }, + { + title: 'Delete accounts', + description: 'Delete Front accounts', + scope: 'accounts:delete' + }, + { + title: 'Read inboxes and channels', + description: 'Read Front inboxes and channels', + scope: 'inboxes:read' + }, + { + title: 'Read channels', + description: 'Read Front channel metadata used for sending messages', + scope: 'channels:read' + }, + { + title: 'Read tags', + description: 'Read Front tags', + scope: 'tags:read' + }, + { + title: 'Write tags', + description: 'Create and update Front tags', + scope: 'tags:write' + }, + { + title: 'Delete tags', + description: 'Delete Front tags', + scope: 'tags:delete' + }, + { + title: 'Write comments', + description: 'Add internal comments to Front conversations', + scope: 'comments:write' + }, + { + title: 'Read teammates', + description: 'Read Front teammate metadata', + scope: 'teammates:read' + }, + { + title: 'Write teammates', + description: 'Update Front teammate metadata and availability', + scope: 'teammates:write' }, { - title: 'Write Global', - description: 'Write access to global/company-level resources', - scope: 'global:resources:write' + title: 'Read teams', + description: 'Read Front teams', + scope: 'teams:read' }, { - title: 'Delete Global', - description: 'Delete access to global/company-level resources', - scope: 'global:resources:delete' + title: 'Write teams', + description: 'Manage Front team membership', + scope: 'teams:write' }, { - title: 'Read Private', - description: 'Read access to private/individual teammate resources', - scope: 'private:resources:read' + title: 'Read links', + description: 'Read Front links that connect conversations to external resources', + scope: 'links:read' }, { - title: 'Write Private', - description: 'Write access to private/individual teammate resources', - scope: 'private:resources:write' + title: 'Write links', + description: 'Create and update Front links', + scope: 'links:write' }, { - title: 'Delete Private', - description: 'Delete access to private/individual teammate resources', - scope: 'private:resources:delete' + title: 'Read message templates', + description: 'Read reusable Front message templates', + scope: 'message_templates:read' }, { - title: 'Send Private', - description: 'Send messages from private inboxes', - scope: 'private:resources:send' + title: 'Write message templates', + description: 'Create and update reusable Front message templates', + scope: 'message_templates:write' + }, + { + title: 'Delete message templates', + description: 'Delete reusable Front message templates', + scope: 'message_templates:delete' + }, + { + title: 'Read analytics', + description: 'Create and inspect Front analytics reports and exports', + scope: 'analytics:read' + }, + { + title: 'Read knowledge bases', + description: 'Read Front knowledge bases, categories, and articles', + scope: 'knowledge_bases:read' + }, + { + title: 'Read events', + description: 'Read Front events and conversation activity', + scope: 'events:*:read' } ], @@ -110,58 +195,84 @@ export let auth = SlateAuth.create() handleCallback: async ctx => { let credentials = btoa(`${ctx.clientId}:${ctx.clientSecret}`); - let response = await oauthAxios.post( - '/oauth/token', - { - code: ctx.code, - redirect_uri: ctx.redirectUri, - grant_type: 'authorization_code' - }, - { - headers: { - Authorization: `Basic ${credentials}`, - 'Content-Type': 'application/json' + try { + let response = await oauthAxios.post( + '/oauth/token', + { + code: ctx.code, + redirect_uri: ctx.redirectUri, + grant_type: 'authorization_code' + }, + { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json' + } } - } - ); + ); - let data = response.data; + let data = response.data; - return { - output: { - token: data.access_token, - refreshToken: data.refresh_token, - expiresAt: data.expires_at ? String(data.expires_at) : undefined + if (!data.access_token) { + throw frontServiceError( + 'Front OAuth token response did not include an access token.' + ); } - }; + + return { + output: { + token: data.access_token, + refreshToken: data.refresh_token, + expiresAt: data.expires_at ? String(data.expires_at) : undefined + } + }; + } catch (error) { + throw frontApiError(error, 'OAuth token exchange'); + } }, handleTokenRefresh: async (ctx: any) => { + if (!ctx.output.refreshToken) { + throw frontServiceError( + 'Front OAuth refresh token is missing. Reconnect the account.' + ); + } + let credentials = btoa(`${ctx.clientId}:${ctx.clientSecret}`); - let response = await oauthAxios.post( - '/oauth/token', - { - refresh_token: ctx.output.refreshToken, - grant_type: 'refresh_token' - }, - { - headers: { - Authorization: `Basic ${credentials}`, - 'Content-Type': 'application/json' + try { + let response = await oauthAxios.post( + '/oauth/token', + { + refresh_token: ctx.output.refreshToken, + grant_type: 'refresh_token' + }, + { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json' + } } - } - ); + ); - let data = response.data; + let data = response.data; - return { - output: { - token: data.access_token, - refreshToken: data.refresh_token || ctx.output.refreshToken, - expiresAt: data.expires_at ? String(data.expires_at) : undefined + if (!data.access_token) { + throw frontServiceError( + 'Front OAuth refresh response did not include an access token.' + ); } - }; + + return { + output: { + token: data.access_token, + refreshToken: data.refresh_token || ctx.output.refreshToken, + expiresAt: data.expires_at ? String(data.expires_at) : undefined + } + }; + } catch (error) { + throw frontApiError(error, 'OAuth token refresh'); + } }, getProfile: async (ctx: { @@ -169,20 +280,24 @@ export let auth = SlateAuth.create() input: {}; scopes: string[]; }) => { - let response = await apiAxios.get('/me', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + try { + let response = await apiAxios.get('/me', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); - let data = response.data; + let data = response.data; - return { - profile: { - id: data.id, - name: data.name - } - }; + return { + profile: { + id: data.id, + name: data.name + } + }; + } catch (error) { + throw frontApiError(error, 'profile lookup'); + } } }) .addTokenAuth({ @@ -208,19 +323,23 @@ export let auth = SlateAuth.create() output: { token: string; refreshToken?: string; expiresAt?: string }; input: { apiToken: string }; }) => { - let response = await apiAxios.get('/me', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + try { + let response = await apiAxios.get('/me', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); - let data = response.data; + let data = response.data; - return { - profile: { - id: data.id, - name: data.name - } - }; + return { + profile: { + id: data.id, + name: data.name + } + }; + } catch (error) { + throw frontApiError(error, 'profile lookup'); + } } }); diff --git a/integrations/front/src/index.ts b/integrations/front/src/index.ts index 6054f983e5..19dbbad081 100644 --- a/integrations/front/src/index.ts +++ b/integrations/front/src/index.ts @@ -16,6 +16,7 @@ import { deleteTag, getAccount, getAnalyticsExport, + getChannel, getContact, getConversation, getInbox, @@ -23,6 +24,7 @@ import { getTeam, getTeammate, listAccounts, + listChannels, listContacts, listConversations, listEvents, @@ -34,6 +36,7 @@ import { listTeammates, listTeams, manageTeamMembers, + mergeContacts, sendMessage, sendReply, updateAccount, @@ -60,6 +63,7 @@ export let provider = Slate.create({ createContact.build(), updateContact.build(), deleteContact.build(), + mergeContacts.build(), listAccounts.build(), getAccount.build(), createAccount.build(), @@ -73,6 +77,8 @@ export let provider = Slate.create({ listTeammates.build(), getTeammate.build(), updateTeammate.build(), + listChannels.build(), + getChannel.build(), listInboxes.build(), getInbox.build(), listTeams.build(), diff --git a/integrations/front/src/lib/client.ts b/integrations/front/src/lib/client.ts index 7522d67845..cd45a956cd 100644 --- a/integrations/front/src/lib/client.ts +++ b/integrations/front/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { frontApiError } from './errors'; import type { FrontAccount, FrontAnalyticsExport, @@ -34,6 +35,10 @@ export class Client { 'Content-Type': 'application/json' } }); + + this.axios.interceptors.response.use(undefined, (error: unknown) => { + throw frontApiError(error); + }); } // ---- Token Identity ---- @@ -77,17 +82,22 @@ export class Client { async updateConversation( conversationId: string, data: { - assignee_id?: string; + assignee_id?: string | null; inbox_id?: string; status?: 'archived' | 'open' | 'deleted' | 'spam'; - subject?: string; + status_id?: string; tag_ids?: string[]; + description?: string | null; + custom_fields?: Record; } ): Promise { await this.axios.patch(`/conversations/${conversationId}`, data); } - async updateConversationAssignee(conversationId: string, assigneeId: string): Promise { + async updateConversationAssignee( + conversationId: string, + assigneeId: string | null + ): Promise { await this.axios.put(`/conversations/${conversationId}/assignee`, { assignee_id: assigneeId }); @@ -151,15 +161,19 @@ export class Client { }); } - async addConversationLink(conversationId: string, linkId: string): Promise { - await this.axios.post(`/conversations/${conversationId}/links`, { - link_ids: [linkId] - }); + async addConversationLinks( + conversationId: string, + data: { + link_ids?: string[]; + link_external_urls?: string[]; + } + ): Promise { + await this.axios.post(`/conversations/${conversationId}/links`, data); } - async removeConversationLink(conversationId: string, linkId: string): Promise { + async removeConversationLinks(conversationId: string, linkIds: string[]): Promise { await this.axios.delete(`/conversations/${conversationId}/links`, { - data: { link_ids: [linkId] } + data: { link_ids: linkIds } }); } @@ -167,7 +181,8 @@ export class Client { conversationId: string, data: { teammate_id: string; - scheduled_at: number; + scheduled_at: number | null; + status_id?: string; } ): Promise { await this.axios.patch(`/conversations/${conversationId}/reminders`, data); @@ -177,13 +192,15 @@ export class Client { async createMessage(data: { author_id?: string; - to: string[]; + to?: string[]; cc?: string[]; bcc?: string[]; sender_name?: string; subject?: string; body: string; - body_format?: 'html' | 'markdown'; + text?: string; + signature_id?: string; + should_add_default_signature?: boolean; channel_id?: string; options?: Record; metadata?: Record; @@ -196,13 +213,15 @@ export class Client { channelId: string, data: { author_id?: string; - to: string[]; + to?: string[]; cc?: string[]; bcc?: string[]; sender_name?: string; subject?: string; body: string; - body_format?: 'html' | 'markdown'; + text?: string; + signature_id?: string; + should_add_default_signature?: boolean; options?: Record; metadata?: Record; } @@ -221,8 +240,11 @@ export class Client { sender_name?: string; subject?: string; body: string; - body_format?: 'html' | 'markdown'; + text?: string; + quote_body?: string; channel_id?: string; + signature_id?: string; + should_add_default_signature?: boolean; options?: Record; metadata?: Record; } @@ -277,6 +299,7 @@ export class Client { is_spammer?: boolean; links?: string[]; group_names?: string[]; + list_names?: string[]; handles?: { handle: string; source: string }[]; custom_fields?: Record; }): Promise { @@ -292,6 +315,7 @@ export class Client { is_spammer?: boolean; links?: string[]; group_names?: string[]; + list_names?: string[]; custom_fields?: Record; } ): Promise { @@ -612,6 +636,7 @@ export class Client { data: { author_id?: string; body: string; + is_pinned?: boolean; } ): Promise { let response = await this.axios.post(`/conversations/${conversationId}/comments`, data); @@ -728,6 +753,7 @@ export class Client { timezone?: string; filters?: Record; type: string; + columns: string[]; }): Promise { let response = await this.axios.post('/analytics/exports', data); return response.data; diff --git a/integrations/front/src/lib/errors.ts b/integrations/front/src/lib/errors.ts new file mode 100644 index 0000000000..8f91ea5d31 --- /dev/null +++ b/integrations/front/src/lib/errors.ts @@ -0,0 +1,80 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) messages.push(trimmed); +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (!isRecord(value)) { + addMessage(messages, value); + return; + } + + for (let key of ['message', 'error', 'error_description', 'description', 'detail']) { + addMessage(messages, value[key]); + } + + for (let key of ['errors', 'messages']) { + let nested = value[key]; + if (!Array.isArray(nested)) continue; + + for (let item of nested) collectMessages(item, messages); + } +}; + +let getFrontErrorStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + return response?.status ?? (typeof error.status === 'number' ? error.status : undefined); +}; + +let extractFrontMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectMessages(response?.data, messages); + collectMessages(isRecord(error) ? error.data : undefined, messages); + + if (messages.length > 0) return messages.join(' - '); + + if (error instanceof Error && error.message) return error.message; + + return 'Unknown error'; +}; + +export let frontServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let frontApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getFrontErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = frontServiceError( + `Front API ${operation} failed: ${statusLabel}${extractFrontMessage(error)}` + ); + serviceError.data.reason = 'front_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) serviceError.setParent(error); + + return serviceError; +}; diff --git a/integrations/front/src/tools/add-comment.ts b/integrations/front/src/tools/add-comment.ts index 4f3528f2a2..ec191aec32 100644 --- a/integrations/front/src/tools/add-comment.ts +++ b/integrations/front/src/tools/add-comment.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { frontServiceError } from '../lib/errors'; import { spec } from '../spec'; export let addComment = SlateTool.create(spec, { @@ -20,7 +21,11 @@ export let addComment = SlateTool.create(spec, { .optional() .describe('Comment ID to reply to (for threaded comments)'), authorId: z.string().optional().describe('Teammate ID of the comment author'), - body: z.string().describe('Comment body text. Use @mentions to notify teammates.') + body: z.string().describe('Comment body text. Use @mentions to notify teammates.'), + isPinned: z + .boolean() + .optional() + .describe('Whether to pin the new comment. Only applies when conversationId is used.') }) ) .output( @@ -36,6 +41,12 @@ export let addComment = SlateTool.create(spec, { let comment: any; if (ctx.input.parentCommentId) { + if (ctx.input.isPinned !== undefined) { + throw frontServiceError( + 'isPinned can only be used when adding a comment to a conversation.' + ); + } + comment = await client.replyToComment(ctx.input.parentCommentId, { author_id: ctx.input.authorId, body: ctx.input.body @@ -43,10 +54,11 @@ export let addComment = SlateTool.create(spec, { } else if (ctx.input.conversationId) { comment = await client.addComment(ctx.input.conversationId, { author_id: ctx.input.authorId, - body: ctx.input.body + body: ctx.input.body, + is_pinned: ctx.input.isPinned }); } else { - throw new Error('Either conversationId or parentCommentId must be provided.'); + throw frontServiceError('Either conversationId or parentCommentId must be provided.'); } return { diff --git a/integrations/front/src/tools/analytics.ts b/integrations/front/src/tools/analytics.ts index cba9e46365..67bea74366 100644 --- a/integrations/front/src/tools/analytics.ts +++ b/integrations/front/src/tools/analytics.ts @@ -3,6 +3,16 @@ import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; +let defaultAnalyticsExportColumns = [ + 'Message ID', + 'Conversation ID', + 'Message date', + 'Direction', + 'Status', + 'Inbox', + 'Subject' +]; + export let createAnalyticsExport = SlateTool.create(spec, { name: 'Create Analytics Export', key: 'create_analytics_export', @@ -21,7 +31,18 @@ export let createAnalyticsExport = SlateTool.create(spec, { .string() .optional() .describe('Timezone for the export (e.g., America/New_York)'), - type: z.string().describe('Export type (e.g., messages, conversations, tags)'), + type: z + .enum(['messages']) + .optional() + .describe( + 'Export type. Front currently allows messages exports. Defaults to messages.' + ), + columns: z + .array(z.string()) + .optional() + .describe( + 'Analytics export columns to include. Defaults to common message export columns.' + ), filters: z .record(z.string(), z.any()) .optional() @@ -43,7 +64,8 @@ export let createAnalyticsExport = SlateTool.create(spec, { start: ctx.input.start, end: ctx.input.end, timezone: ctx.input.timezone, - type: ctx.input.type, + type: ctx.input.type ?? 'messages', + columns: ctx.input.columns ?? defaultAnalyticsExportColumns, filters: ctx.input.filters }); diff --git a/integrations/front/src/tools/index.ts b/integrations/front/src/tools/index.ts index b42bc4e891..1711af73ca 100644 --- a/integrations/front/src/tools/index.ts +++ b/integrations/front/src/tools/index.ts @@ -5,6 +5,7 @@ export * from './knowledge-base'; export * from './list-conversations'; export * from './list-events'; export * from './manage-accounts'; +export * from './manage-channels'; export * from './manage-contacts'; export * from './manage-inboxes'; export * from './manage-links'; diff --git a/integrations/front/src/tools/manage-channels.ts b/integrations/front/src/tools/manage-channels.ts new file mode 100644 index 0000000000..b6f70b37df --- /dev/null +++ b/integrations/front/src/tools/manage-channels.ts @@ -0,0 +1,73 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let channelOutputSchema = z.object({ + channelId: z.string(), + address: z.string(), + type: z.string(), + name: z.string().optional(), + sendAs: z.string().optional(), + isPrivate: z.boolean() +}); + +export let listChannels = SlateTool.create(spec, { + name: 'List Channels', + key: 'list_channels', + description: `List Front channels available in the company. Use this to find channel IDs or addresses for sending new outbound messages.`, + tags: { readOnly: true } +}) + .input(z.object({})) + .output( + z.object({ + channels: z.array(channelOutputSchema) + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listChannels(); + + let channels = result._results.map(channel => ({ + channelId: channel.id, + address: channel.address, + type: channel.type, + name: channel.name, + sendAs: channel.send_as, + isPrivate: channel.is_private + })); + + return { + output: { channels }, + message: `Found **${channels.length}** channels.` + }; + }); + +export let getChannel = SlateTool.create(spec, { + name: 'Get Channel', + key: 'get_channel', + description: `Retrieve details for a specific Front channel.`, + tags: { readOnly: true } +}) + .input( + z.object({ + channelId: z.string().describe('Channel ID or channel address alias to retrieve') + }) + ) + .output(channelOutputSchema) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let channel = await client.getChannel(ctx.input.channelId); + + return { + output: { + channelId: channel.id, + address: channel.address, + type: channel.type, + name: channel.name, + sendAs: channel.send_as, + isPrivate: channel.is_private + }, + message: `Retrieved channel **${channel.name || channel.address}**.` + }; + }); diff --git a/integrations/front/src/tools/manage-contacts.ts b/integrations/front/src/tools/manage-contacts.ts index 2646faf6db..41a234e4e2 100644 --- a/integrations/front/src/tools/manage-contacts.ts +++ b/integrations/front/src/tools/manage-contacts.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { frontServiceError } from '../lib/errors'; import { spec } from '../spec'; let contactOutputSchema = z.object({ @@ -18,6 +19,19 @@ let contactOutputSchema = z.object({ customFields: z.record(z.string(), z.string()).optional() }); +let handleSchema = z.object({ + handle: z.string().describe('Handle value (email, phone, etc.)'), + source: z + .enum(['twitter', 'email', 'phone', 'facebook', 'intercom', 'front_chat', 'custom']) + .describe('Handle source type') +}); + +let requireHandles = (handles: z.infer[] | undefined) => { + if (!handles || handles.length === 0) { + throw frontServiceError('At least one contact handle is required.'); + } +}; + export let listContacts = SlateTool.create(spec, { name: 'List Contacts', key: 'list_contacts', @@ -142,26 +156,32 @@ export let createContact = SlateTool.create(spec, { name: z.string().optional().describe('Contact name'), description: z.string().optional().describe('Contact description'), handles: z - .array( - z.object({ - handle: z.string().describe('Handle value (email, phone, etc.)'), - source: z.string().describe('Handle source type (e.g., email, phone, twitter)') - }) - ) + .array(handleSchema) .optional() - .describe('Contact handles'), - groupNames: z.array(z.string()).optional().describe('Contact group names to assign'), + .describe( + 'Contact handles. Front requires at least one handle when creating a contact.' + ), + listNames: z + .array(z.string()) + .optional() + .describe('Contact list names to assign. Front creates missing lists automatically.'), + groupNames: z + .array(z.string()) + .optional() + .describe('Deprecated by Front. Use listNames instead.'), customFields: z.record(z.string(), z.string()).optional().describe('Custom field values') }) ) .output(contactOutputSchema) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); + requireHandles(ctx.input.handles); let contact = await client.createContact({ name: ctx.input.name, description: ctx.input.description, handles: ctx.input.handles, + list_names: ctx.input.listNames, group_names: ctx.input.groupNames, custom_fields: ctx.input.customFields }); @@ -192,27 +212,21 @@ export let updateContact = SlateTool.create(spec, { name: z.string().optional().describe('Updated name'), description: z.string().optional().describe('Updated description'), isSpammer: z.boolean().optional().describe('Mark/unmark as spammer'), - groupNames: z.array(z.string()).optional().describe('Updated group names'), + listNames: z + .array(z.string()) + .optional() + .describe('Updated contact list names. Front creates missing lists automatically.'), + groupNames: z + .array(z.string()) + .optional() + .describe('Deprecated by Front. Use listNames instead.'), customFields: z .record(z.string(), z.string()) .optional() .describe('Updated custom field values'), - addHandles: z - .array( - z.object({ - handle: z.string(), - source: z.string() - }) - ) - .optional() - .describe('Handles to add to the contact'), + addHandles: z.array(handleSchema).optional().describe('Handles to add to the contact'), removeHandles: z - .array( - z.object({ - handle: z.string(), - source: z.string() - }) - ) + .array(handleSchema) .optional() .describe('Handles to remove from the contact') }) @@ -225,6 +239,7 @@ export let updateContact = SlateTool.create(spec, { name: ctx.input.name, description: ctx.input.description, is_spammer: ctx.input.isSpammer, + list_names: ctx.input.listNames, group_names: ctx.input.groupNames, custom_fields: ctx.input.customFields }); @@ -241,6 +256,10 @@ export let updateContact = SlateTool.create(spec, { } } + if (ctx.input.addHandles?.length || ctx.input.removeHandles?.length) { + contact = await client.getContact(ctx.input.contactId); + } + return { output: { contactId: contact.id, @@ -255,6 +274,56 @@ export let updateContact = SlateTool.create(spec, { }; }); +export let mergeContacts = SlateTool.create(spec, { + name: 'Merge Contacts', + key: 'merge_contacts', + description: `Merge duplicate Front contacts into one contact. Front deletes the merged-in contacts and preserves handles, groups, links, and notes on the surviving contact.`, + tags: { destructive: true } +}) + .input( + z.object({ + targetContactId: z + .string() + .optional() + .describe('Optional contact ID that should survive the merge'), + contactIds: z + .array(z.string()) + .describe( + 'Contact IDs to merge. If targetContactId is omitted or included, provide at least two IDs. If targetContactId is separate, provide at least one source ID.' + ) + }) + ) + .output(contactOutputSchema) + .handleInvocation(async ctx => { + let { targetContactId, contactIds } = ctx.input; + + if (contactIds.length === 0) { + throw frontServiceError('contactIds must contain at least one contact ID.'); + } + + if ((!targetContactId || contactIds.includes(targetContactId)) && contactIds.length < 2) { + throw frontServiceError( + 'Provide at least two contactIds when targetContactId is omitted or included in contactIds.' + ); + } + + let client = new Client({ token: ctx.auth.token }); + let contact = await client.mergeContacts(targetContactId ?? contactIds[0]!, contactIds); + + return { + output: { + contactId: contact.id, + name: contact.name, + description: contact.description, + avatarUrl: contact.avatar_url, + isSpammer: contact.is_spammer, + handles: contact.handles, + customFields: contact.custom_fields + }, + message: `Merged ${contactIds.length} contact(s) into ${contact.id}.` + }; + }); + export let deleteContact = SlateTool.create(spec, { name: 'Delete Contact', key: 'delete_contact', diff --git a/integrations/front/src/tools/send-message.ts b/integrations/front/src/tools/send-message.ts index deb8324018..6f5d22d672 100644 --- a/integrations/front/src/tools/send-message.ts +++ b/integrations/front/src/tools/send-message.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { frontServiceError } from '../lib/errors'; import { spec } from '../spec'; export let sendMessage = SlateTool.create(spec, { @@ -9,7 +10,7 @@ export let sendMessage = SlateTool.create(spec, { description: `Send a new outbound message or reply to an existing conversation. When providing a channelId, creates a new conversation. When providing a conversationId, sends a reply in that conversation. Requires the **Send** permission.`, instructions: [ 'Provide either channelId (for new messages) or conversationId (for replies), not both.', - 'The body supports HTML by default. Set bodyFormat to "markdown" for markdown content.' + 'Use body for HTML content and text for the plain-text alternative.' ], tags: { destructive: false @@ -26,16 +27,24 @@ export let sendMessage = SlateTool.create(spec, { .optional() .describe('Conversation ID to reply to (for replies)'), authorId: z.string().optional().describe('Teammate ID of the message author'), - to: z.array(z.string()).describe('Recipient email addresses or handles'), + to: z.array(z.string()).optional().describe('Recipient email addresses or handles'), cc: z.array(z.string()).optional().describe('CC recipients'), bcc: z.array(z.string()).optional().describe('BCC recipients'), subject: z.string().optional().describe('Message subject line'), body: z.string().describe('Message body content (HTML or markdown)'), - bodyFormat: z - .enum(['html', 'markdown']) + text: z.string().optional().describe('Plain-text body for email messages'), + quoteBody: z + .string() + .optional() + .describe('Quoted body for a reply. Only used when conversationId is provided.'), + senderName: z.string().optional().describe('Display name of the sender'), + signatureId: z.string().optional().describe('Signature ID to attach for email channels'), + shouldAddDefaultSignature: z + .boolean() .optional() - .describe('Format of the body content'), - senderName: z.string().optional().describe('Display name of the sender') + .describe( + 'Whether Front should try to resolve the default signature for email channels' + ) }) ) .output( @@ -48,6 +57,22 @@ export let sendMessage = SlateTool.create(spec, { let client = new Client({ token: ctx.auth.token }); let input = ctx.input; + if (input.channelId && input.conversationId) { + throw frontServiceError('Provide either channelId or conversationId, not both.'); + } + + if (!input.channelId && !input.conversationId) { + throw frontServiceError('Provide either channelId or conversationId.'); + } + + if (input.quoteBody && !input.conversationId) { + throw frontServiceError('quoteBody can only be used when replying with conversationId.'); + } + + if (input.channelId && !input.to?.length && !input.cc?.length && !input.bcc?.length) { + throw frontServiceError('At least one of to, cc, or bcc is required for a new message.'); + } + if (input.conversationId) { await client.replyToConversation(input.conversationId, { author_id: input.authorId, @@ -56,8 +81,11 @@ export let sendMessage = SlateTool.create(spec, { bcc: input.bcc, subject: input.subject, body: input.body, - body_format: input.bodyFormat, - sender_name: input.senderName + text: input.text, + quote_body: input.quoteBody, + sender_name: input.senderName, + signature_id: input.signatureId, + should_add_default_signature: input.shouldAddDefaultSignature }); return { @@ -72,18 +100,17 @@ export let sendMessage = SlateTool.create(spec, { bcc: input.bcc, subject: input.subject, body: input.body, - body_format: input.bodyFormat, - sender_name: input.senderName + text: input.text, + sender_name: input.senderName, + signature_id: input.signatureId, + should_add_default_signature: input.shouldAddDefaultSignature }); return { output: { sent: true, messageId: message.id }, - message: `New message sent via channel ${input.channelId} to ${input.to.join(', ')}.` + message: `New message sent via channel ${input.channelId}.` }; } - return { - output: { sent: false }, - message: `No message sent — provide either a channelId or conversationId.` - }; + throw frontServiceError('Provide either channelId or conversationId.'); }); diff --git a/integrations/front/src/tools/send-reply.ts b/integrations/front/src/tools/send-reply.ts index dcfc293906..a95e57b0ee 100644 --- a/integrations/front/src/tools/send-reply.ts +++ b/integrations/front/src/tools/send-reply.ts @@ -20,11 +20,19 @@ export let sendReply = SlateTool.create(spec, { bcc: z.array(z.string()).optional().describe('BCC recipients'), subject: z.string().optional().describe('Override subject line'), body: z.string().describe('Reply body content (HTML or markdown)'), - bodyFormat: z - .enum(['html', 'markdown']) + text: z.string().optional().describe('Plain-text body for email replies'), + quoteBody: z + .string() .optional() - .describe('Format of the body content'), - channelId: z.string().optional().describe('Channel ID to send the reply from') + .describe('Quoted body that the reply references. Only available on email channels.'), + channelId: z.string().optional().describe('Channel ID to send the reply from'), + signatureId: z.string().optional().describe('Signature ID to attach for email channels'), + shouldAddDefaultSignature: z + .boolean() + .optional() + .describe( + 'Whether Front should try to resolve the default signature for email channels' + ) }) ) .output( @@ -42,8 +50,11 @@ export let sendReply = SlateTool.create(spec, { bcc: ctx.input.bcc, subject: ctx.input.subject, body: ctx.input.body, - body_format: ctx.input.bodyFormat, - channel_id: ctx.input.channelId + text: ctx.input.text, + quote_body: ctx.input.quoteBody, + channel_id: ctx.input.channelId, + signature_id: ctx.input.signatureId, + should_add_default_signature: ctx.input.shouldAddDefaultSignature }); return { diff --git a/integrations/front/src/tools/update-conversation.ts b/integrations/front/src/tools/update-conversation.ts index 3326dae946..d7245b1a7f 100644 --- a/integrations/front/src/tools/update-conversation.ts +++ b/integrations/front/src/tools/update-conversation.ts @@ -1,15 +1,17 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { frontServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateConversation = SlateTool.create(spec, { name: 'Update Conversation', key: 'update_conversation', - description: `Update a conversation's properties including status, assignee, tags, followers, and inbox. Supports assigning/unassigning teammates, archiving, reopening, trashing, restoring, adding/removing tags, and managing followers — all in a single flexible operation.`, + description: `Update a conversation's properties including status, assignee, tags, followers, links, reminders, description, custom fields, and inbox. Supports assigning/unassigning teammates, archiving, reopening, trashing, restoring, adding/removing tags and links, and managing followers — all in a single flexible operation.`, instructions: [ - 'To unassign a conversation, set assigneeId to an empty string.', - 'Use addTagIds/removeTagIds to manage tags without replacing existing ones.' + 'To unassign a conversation, set assigneeId to an empty string; the tool sends null to Front as required by the current API.', + 'Use addTagIds/removeTagIds to manage tags without replacing existing ones.', + 'Use addLinkIds/removeLinkIds or addLinkExternalUrls to manage conversation links.' ], tags: { destructive: false @@ -22,17 +24,57 @@ export let updateConversation = SlateTool.create(spec, { .enum(['archived', 'open', 'deleted', 'spam']) .optional() .describe('New status for the conversation'), + statusId: z + .string() + .optional() + .describe( + 'Ticketing status ID to set. Do not provide with status. Requires Front ticketing.' + ), assigneeId: z .string() .optional() .describe('Teammate ID to assign to, or empty string to unassign'), - subject: z.string().optional().describe('New subject line'), inboxId: z.string().optional().describe('Inbox ID to move the conversation to'), + description: z + .string() + .optional() + .describe('Task conversation description to set. Only allowed on task conversations.'), + clearDescription: z + .boolean() + .optional() + .describe( + 'Set true to clear the task conversation description. Do not provide with description.' + ), + customFields: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Complete custom field object for this conversation. Front replaces the full custom field set.' + ), addTagIds: z.array(z.string()).optional().describe('Tag IDs to add to the conversation'), removeTagIds: z .array(z.string()) .optional() .describe('Tag IDs to remove from the conversation'), + addLinkIds: z + .array(z.string()) + .max(10) + .optional() + .describe( + 'Link IDs to add to the conversation. Do not provide with addLinkExternalUrls.' + ), + addLinkExternalUrls: z + .array(z.string()) + .max(10) + .optional() + .describe( + 'External URLs to attach to the conversation. Front creates links as needed. Do not provide with addLinkIds.' + ), + removeLinkIds: z + .array(z.string()) + .max(10) + .optional() + .describe('Link IDs to remove from the conversation'), addFollowerIds: z .array(z.string()) .optional() @@ -40,7 +82,23 @@ export let updateConversation = SlateTool.create(spec, { removeFollowerIds: z .array(z.string()) .optional() - .describe('Teammate IDs to remove as followers') + .describe('Teammate IDs to remove as followers'), + reminderTeammateId: z + .string() + .optional() + .describe('Teammate ID or email alias to snooze or unsnooze for'), + reminderScheduledAt: z + .number() + .optional() + .describe('Unix timestamp in seconds for the reminder. Must be in the future.'), + clearReminder: z + .boolean() + .optional() + .describe('Set true with reminderTeammateId to unsnooze/cancel the reminder.'), + reminderStatusId: z + .string() + .optional() + .describe('Optional waiting status ID for the reminder. Requires Front ticketing.') }) ) .output( @@ -54,26 +112,74 @@ export let updateConversation = SlateTool.create(spec, { let input = ctx.input; let actions: string[] = []; + if (input.status && input.statusId) { + throw frontServiceError('Provide either status or statusId, not both.'); + } + + if (input.description !== undefined && input.clearDescription) { + throw frontServiceError('Provide either description or clearDescription, not both.'); + } + + if (input.addLinkIds?.length && input.addLinkExternalUrls?.length) { + throw frontServiceError('Provide either addLinkIds or addLinkExternalUrls, not both.'); + } + + if ( + (input.reminderScheduledAt !== undefined || + input.clearReminder || + input.reminderStatusId) && + !input.reminderTeammateId + ) { + throw frontServiceError( + 'reminderTeammateId is required when updating conversation reminders.' + ); + } + + if (input.reminderScheduledAt !== undefined && input.clearReminder) { + throw frontServiceError( + 'Provide either reminderScheduledAt or clearReminder, not both.' + ); + } + + if ( + input.reminderTeammateId && + input.reminderScheduledAt === undefined && + !input.clearReminder + ) { + throw frontServiceError('reminderScheduledAt is required unless clearReminder is true.'); + } + let updateData: Record = {}; if (input.status !== undefined) { updateData.status = input.status; actions.push(`status → ${input.status}`); } - if (input.subject !== undefined) { - updateData.subject = input.subject; - actions.push(`subject updated`); + if (input.statusId !== undefined) { + updateData.status_id = input.statusId; + actions.push(`ticket status updated`); } if (input.inboxId !== undefined) { updateData.inbox_id = input.inboxId; actions.push(`moved to inbox`); } + if (input.description !== undefined || input.clearDescription) { + updateData.description = input.clearDescription ? null : input.description; + actions.push(input.clearDescription ? `description cleared` : `description updated`); + } + if (input.customFields !== undefined) { + updateData.custom_fields = input.customFields; + actions.push(`custom fields updated`); + } if (Object.keys(updateData).length > 0) { await client.updateConversation(input.conversationId, updateData); } if (input.assigneeId !== undefined) { - await client.updateConversationAssignee(input.conversationId, input.assigneeId); + await client.updateConversationAssignee( + input.conversationId, + input.assigneeId === '' ? null : input.assigneeId + ); actions.push(input.assigneeId ? `assigned to ${input.assigneeId}` : `unassigned`); } @@ -91,6 +197,21 @@ export let updateConversation = SlateTool.create(spec, { actions.push(`removed ${input.removeTagIds.length} tag(s)`); } + if (input.addLinkIds?.length || input.addLinkExternalUrls?.length) { + await client.addConversationLinks(input.conversationId, { + link_ids: input.addLinkIds, + link_external_urls: input.addLinkExternalUrls + }); + actions.push( + `added ${(input.addLinkIds?.length ?? 0) + (input.addLinkExternalUrls?.length ?? 0)} link(s)` + ); + } + + if (input.removeLinkIds && input.removeLinkIds.length > 0) { + await client.removeConversationLinks(input.conversationId, input.removeLinkIds); + actions.push(`removed ${input.removeLinkIds.length} link(s)`); + } + if (input.addFollowerIds && input.addFollowerIds.length > 0) { await client.addConversationFollowers(input.conversationId, input.addFollowerIds); actions.push(`added ${input.addFollowerIds.length} follower(s)`); @@ -101,6 +222,15 @@ export let updateConversation = SlateTool.create(spec, { actions.push(`removed ${input.removeFollowerIds.length} follower(s)`); } + if (input.reminderTeammateId) { + await client.updateConversationReminders(input.conversationId, { + teammate_id: input.reminderTeammateId, + scheduled_at: input.clearReminder ? null : input.reminderScheduledAt!, + status_id: input.reminderStatusId + }); + actions.push(input.clearReminder ? `reminder cleared` : `reminder updated`); + } + return { output: { conversationId: input.conversationId, diff --git a/integrations/fullstory/README.md b/integrations/fullstory/README.md index c23b1da27b..0bd0559bdb 100644 --- a/integrations/fullstory/README.md +++ b/integrations/fullstory/README.md @@ -24,14 +24,26 @@ Delete a user from FullStory by their uid. This is an asynchronous operation tha Start an export of segment data from FullStory. Exports can include individual user data or event data in CSV, JSON, or NDJSON format. Returns an operation ID that can be used to track the export progress. +### Generate Session Context + +Generate a structured, AI-friendly context summary for a FullStory session, including selected session metadata and transformed event details. + ### Get Operation Status Check the status of an asynchronous FullStory operation such as user deletion or segment export. If the operation is a completed export, also retrieves the download URL for the results. +### Get Organization Quotas + +Retrieve FullStory organization quota usage for the current billing cycle, including session quota and server event quota. + ### Get Segment Retrieve details of a specific FullStory segment by its ID. Returns the segment name, creator, creation date, and URL. +### Get Session Events + +Retrieve the raw captured events for a FullStory session. This is useful for inspecting the exact timeline behind a replay or downstream activation workflow. + ### Get User Retrieve a user's profile from FullStory by their FullStory user ID. Returns the full user profile including custom properties and the link to view them in FullStory. diff --git a/integrations/fullstory/docs/SPEC.md b/integrations/fullstory/docs/SPEC.md index 073f71ba26..471a92e30a 100644 --- a/integrations/fullstory/docs/SPEC.md +++ b/integrations/fullstory/docs/SPEC.md @@ -32,7 +32,11 @@ Event data can be created with the Events API. Send custom server-side events to ### Session Retrieval -Retrieve a list of sessions for a user using the Sessions API. Sessions can be looked up by user ID or email, and the API returns session replay URLs that can be embedded into support tools or other applications. +Retrieve a list of sessions for a user using the Sessions API. Sessions can be looked up by uid or email, and the API returns canonical session IDs, creation timestamps, and replay URLs that can be embedded into support tools or other applications. Current Anywhere: Activation APIs can also return raw session events or AI-ready context data for a known session ID. + +### Organization Quotas + +Retrieve session and server-event quota usage for the current billing cycle from the Organization API. This helps teams understand API/event ingestion limits before creating additional server-side events. ### Segments diff --git a/integrations/fullstory/package.json b/integrations/fullstory/package.json index ec57da0bb1..ac7f4ace29 100644 --- a/integrations/fullstory/package.json +++ b/integrations/fullstory/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/fullstory/src/index.ts b/integrations/fullstory/src/index.ts index 70ad03fbad..f20a942af3 100644 --- a/integrations/fullstory/src/index.ts +++ b/integrations/fullstory/src/index.ts @@ -6,8 +6,11 @@ import { createOrUpdateUser, deleteUser, exportSegment, + generateSessionContext, getOperationStatus, + getOrganizationQuotas, getSegment, + getSessionEvents, getUser, listSegments, listSessions, @@ -26,6 +29,9 @@ export let provider = Slate.create({ deleteUser, createEvent, listSessions, + getSessionEvents, + generateSessionContext, + getOrganizationQuotas, listSegments, getSegment, exportSegment, diff --git a/integrations/fullstory/src/lib/client.ts b/integrations/fullstory/src/lib/client.ts index dba2eae341..297e28eebe 100644 --- a/integrations/fullstory/src/lib/client.ts +++ b/integrations/fullstory/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { fullStoryApiError } from './errors'; export interface FullStoryUser { userId: string; @@ -13,12 +14,49 @@ export interface FullStoryUser { } export interface FullStorySession { - userId: string; sessionId: string; createdTime: string; fsUrl: string; } +export interface FullStoryQuota { + usage?: string; + limit?: string; + periodStart?: string; + periodEnd?: string; +} + +export interface FullStoryOrganizationQuotas { + sessionQuota?: FullStoryQuota; + serverEventQuota?: FullStoryQuota; +} + +export interface GenerateSessionContextParams { + sessionId: string; + sliceMode?: 'UNSPECIFIED' | 'FIRST' | 'LAST' | 'TIMESTAMP'; + eventLimit?: number; + durationLimitMs?: string; + startTimestamp?: string; + endTimestamp?: string; + includeContext?: string[]; + excludeContext?: string[]; + excludeOrgContext?: boolean; + excludeUserContext?: boolean; + excludeLocation?: boolean; + excludeDevice?: boolean; + includeEventTypes?: string[]; + excludeEventTypes?: string[]; + excludeDefinedEvents?: boolean; + excludeApiEvents?: boolean; + excludeEventTimestamps?: boolean; + excludeSelectors?: boolean; + includeSelectorTags?: boolean; + trimToLastNSelectors?: number; + includeTabIndex?: boolean; + includeDescriptions?: boolean; + enableEventCache?: boolean; +} + export interface FullStorySegment { segmentId: string; name: string; @@ -141,23 +179,35 @@ export class Client { }); } + private async request(operation: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw fullStoryApiError(error, operation); + } + } + // ===== Users API (v2) ===== async createOrUpdateUser(params: CreateUserParams): Promise { - let body: Record = {}; - if (params.uid !== undefined) body.uid = params.uid; - if (params.displayName !== undefined) body.display_name = params.displayName; - if (params.email !== undefined) body.email = params.email; - if (params.properties !== undefined) body.properties = params.properties; - if (params.schema !== undefined) body.schema = params.schema; - - let response = await this.axios.post('/v2/users', body); - return mapResponseUser(response.data); + return this.request('create or update user', async () => { + let body: Record = {}; + if (params.uid !== undefined) body.uid = params.uid; + if (params.displayName !== undefined) body.display_name = params.displayName; + if (params.email !== undefined) body.email = params.email; + if (params.properties !== undefined) body.properties = params.properties; + if (params.schema !== undefined) body.schema = params.schema; + + let response = await this.axios.post('/v2/users', body); + return mapResponseUser(response.data); + }); } async getUser(userId: string): Promise { - let response = await this.axios.get(`/v2/users/${encodeURIComponent(userId)}`); - return mapResponseUser(response.data); + return this.request('get user', async () => { + let response = await this.axios.get(`/v2/users/${encodeURIComponent(userId)}`); + return mapResponseUser(response.data); + }); } async listUsers(params?: { @@ -167,50 +217,59 @@ export class Client { isIdentified?: boolean; pageToken?: string; }): Promise<{ users: FullStoryUser[]; nextPageToken?: string }> { - let query: Record = {}; - if (params?.uid) query.uid = params.uid; - if (params?.email) query.email = params.email; - if (params?.displayName) query.display_name = params.displayName; - if (params?.isIdentified !== undefined) query.is_identified = String(params.isIdentified); - if (params?.pageToken) query.page_token = params.pageToken; - - let response = await this.axios.get('/v2/users', { params: query }); - let data = response.data; - - return { - users: (data.results || []).map(mapResponseUser), - nextPageToken: data.next_page_token || undefined - }; + return this.request('list users', async () => { + let query: Record = {}; + if (params?.uid) query.uid = params.uid; + if (params?.email) query.email = params.email; + if (params?.displayName) query.display_name = params.displayName; + if (params?.isIdentified !== undefined) + query.is_identified = String(params.isIdentified); + if (params?.pageToken) query.page_token = params.pageToken; + + let response = await this.axios.get('/v2/users', { params: query }); + let data = response.data; + + return { + users: (data.results || []).map(mapResponseUser), + nextPageToken: data.next_page_token || undefined + }; + }); } async deleteUser(uid: string): Promise<{ operationId: string }> { - let response = await this.axios.delete(`/users/v1/individual/${encodeURIComponent(uid)}`); - return { operationId: response.data?.id || response.data?.operationId || '' }; + return this.request('delete user', async () => { + let response = await this.axios.delete( + `/users/v1/individual/${encodeURIComponent(uid)}` + ); + return { operationId: response.data?.id || response.data?.operationId || '' }; + }); } // ===== Events API (v2) ===== async createEvent(params: CreateEventParams): Promise { - let body: Record = { name: params.name }; - - if (params.sessionId) { - body.session = { id: params.sessionId }; - } else if (params.useMostRecent && (params.userId || params.uid)) { - body.session = { use_most_recent: true }; - body.user = {}; - if (params.userId) body.user.id = params.userId; - if (params.uid) body.user.uid = params.uid; - } else if (params.userId || params.uid) { - body.user = {}; - if (params.userId) body.user.id = params.userId; - if (params.uid) body.user.uid = params.uid; - } + await this.request('create event', async () => { + let body: Record = { name: params.name }; + + if (params.sessionId) { + body.session = { id: params.sessionId }; + } else if (params.useMostRecent && (params.userId || params.uid)) { + body.session = { use_most_recent: true }; + body.user = {}; + if (params.userId) body.user.id = params.userId; + if (params.uid) body.user.uid = params.uid; + } else if (params.userId || params.uid) { + body.user = {}; + if (params.userId) body.user.id = params.userId; + if (params.uid) body.user.uid = params.uid; + } - if (params.timestamp) body.timestamp = params.timestamp; - if (params.properties) body.properties = params.properties; - if (params.schema) body.schema = params.schema; + if (params.timestamp) body.timestamp = params.timestamp; + if (params.properties) body.properties = params.properties; + if (params.schema) body.schema = params.schema; - await this.axios.post('/v2/events', body); + await this.axios.post('/v2/events', body); + }); } // ===== Sessions API ===== @@ -220,20 +279,152 @@ export class Client { email?: string; limit?: number; }): Promise { - let query: Record = {}; - if (params.uid) query.uid = params.uid; - if (params.email) query.email = params.email; - if (params.limit) query.limit = String(params.limit); - - let response = await this.axios.get('/sessions/v2', { params: query }); - let sessions = response.data?.sessions || []; - - return sessions.map((s: any) => ({ - userId: s.UserId || s.userId || '', - sessionId: s.SessionId || s.sessionId || '', - createdTime: s.CreatedTime || s.createdTime || '', - fsUrl: s.FsUrl || s.fsUrl || '' - })); + return this.request('list sessions', async () => { + let query: Record = {}; + if (params.uid) query.uid = params.uid; + if (params.email) query.email = params.email; + if (params.limit) query.limit = String(params.limit); + + let response = await this.axios.get('/v2/sessions', { params: query }); + let sessions = response.data?.results || []; + + return sessions.map((s: any) => ({ + sessionId: s.id || '', + createdTime: s.created_time || '', + fsUrl: s.app_url || '' + })); + }); + } + + async getSessionEvents( + sessionId: string, + params?: { enableEventCache?: boolean } + ): Promise> { + return this.request('get session events', async () => { + let query: Record = {}; + if (params?.enableEventCache !== undefined) { + query.enable_event_cache = String(params.enableEventCache); + } + + let response = await this.axios.get( + `/v2/sessions/${encodeURIComponent(sessionId)}/events`, + { params: query } + ); + return response.data; + }); + } + + async generateSessionContext( + params: GenerateSessionContextParams + ): Promise> { + return this.request('generate session context', async () => { + let body: Record = {}; + + if ( + params.sliceMode || + params.eventLimit !== undefined || + params.durationLimitMs || + params.startTimestamp || + params.endTimestamp + ) { + body.slice = {}; + if (params.sliceMode) body.slice.mode = params.sliceMode; + if (params.eventLimit !== undefined) body.slice.event_limit = params.eventLimit; + if (params.durationLimitMs) body.slice.duration_limit_ms = params.durationLimitMs; + if (params.startTimestamp) body.slice.start_timestamp = params.startTimestamp; + if (params.endTimestamp) body.slice.end_timestamp = params.endTimestamp; + } + + if ( + params.includeContext || + params.excludeContext || + params.excludeOrgContext !== undefined || + params.excludeUserContext !== undefined || + params.excludeLocation !== undefined || + params.excludeDevice !== undefined + ) { + body.context = {}; + if (params.includeContext) body.context.include = params.includeContext; + if (params.excludeContext) body.context.exclude = params.excludeContext; + if (params.excludeOrgContext !== undefined) + body.context.exclude_org_context = params.excludeOrgContext; + if (params.excludeUserContext !== undefined) + body.context.exclude_user_context = params.excludeUserContext; + if (params.excludeLocation !== undefined) + body.context.exclude_location = params.excludeLocation; + if (params.excludeDevice !== undefined) + body.context.exclude_device = params.excludeDevice; + } + + if ( + params.includeEventTypes || + params.excludeEventTypes || + params.excludeDefinedEvents !== undefined || + params.excludeApiEvents !== undefined || + params.excludeEventTimestamps !== undefined || + params.excludeSelectors !== undefined || + params.includeSelectorTags !== undefined || + params.trimToLastNSelectors !== undefined || + params.includeTabIndex !== undefined || + params.includeDescriptions !== undefined + ) { + body.events = {}; + if (params.includeEventTypes) body.events.include_types = params.includeEventTypes; + if (params.excludeEventTypes) body.events.exclude_types = params.excludeEventTypes; + if (params.excludeDefinedEvents !== undefined) + body.events.exclude_defined_events = params.excludeDefinedEvents; + if (params.excludeApiEvents !== undefined) + body.events.exclude_api_events = params.excludeApiEvents; + if (params.excludeEventTimestamps !== undefined) + body.events.exclude_event_timestamps = params.excludeEventTimestamps; + if (params.excludeSelectors !== undefined) + body.events.exclude_selectors = params.excludeSelectors; + if (params.includeSelectorTags !== undefined) + body.events.include_selector_tags = params.includeSelectorTags; + if (params.trimToLastNSelectors !== undefined) + body.events.trim_to_last_n_selectors = params.trimToLastNSelectors; + if (params.includeTabIndex !== undefined) + body.events.include_tab_index = params.includeTabIndex; + if (params.includeDescriptions !== undefined) + body.events.include_descriptions = params.includeDescriptions; + } + + if (params.enableEventCache !== undefined) { + body.cache = { enable_event_cache: params.enableEventCache }; + } + + let response = await this.axios.post( + `/v2/sessions/${encodeURIComponent(params.sessionId)}/context`, + body + ); + return response.data; + }); + } + + // ===== Organization API (v2) ===== + + async getOrganizationQuotas(): Promise { + return this.request('get organization quotas', async () => { + let response = await this.axios.get('/v2/organization/quotas'); + let data = response.data; + let mapQuota = (quota: any): FullStoryQuota | undefined => { + if (!quota) { + return undefined; + } + + return { + usage: quota.usage, + limit: quota.limit, + periodStart: quota.period_start, + periodEnd: quota.period_end + }; + }; + + return { + sessionQuota: mapQuota(data.session_quota), + serverEventQuota: mapQuota(data.server_event_quota) + }; + }); } // ===== Segments API (v1) ===== @@ -243,105 +434,117 @@ export class Client { paginationToken?: string; creator?: string; }): Promise<{ segments: FullStorySegment[]; nextPaginationToken?: string }> { - let query: Record = {}; - if (params?.limit) query.limit = String(params.limit); - if (params?.paginationToken) query.paginationToken = params.paginationToken; - if (params?.creator) query.creator = params.creator; - - let response = await this.axios.get('/segments/v1', { params: query }); - let data = response.data; + return this.request('list segments', async () => { + let query: Record = {}; + if (params?.limit) query.limit = String(params.limit); + if (params?.paginationToken) query.paginationToken = params.paginationToken; + if (params?.creator) query.creator = params.creator; + + let response = await this.axios.get('/segments/v1', { params: query }); + let data = response.data; + + return { + segments: (data.segments || []).map((s: any) => ({ + segmentId: s.id || '', + name: s.name || '', + creator: s.creator, + created: s.created, + url: s.url + })), + nextPaginationToken: data.nextPaginationToken || undefined + }; + }); + } - return { - segments: (data.segments || []).map((s: any) => ({ + async getSegment(segmentId: string): Promise { + return this.request('get segment', async () => { + let response = await this.axios.get(`/segments/v1/${encodeURIComponent(segmentId)}`); + let s = response.data; + return { segmentId: s.id || '', name: s.name || '', creator: s.creator, created: s.created, url: s.url - })), - nextPaginationToken: data.nextPaginationToken || undefined - }; - } - - async getSegment(segmentId: string): Promise { - let response = await this.axios.get(`/segments/v1/${encodeURIComponent(segmentId)}`); - let s = response.data; - return { - segmentId: s.id || '', - name: s.name || '', - creator: s.creator, - created: s.created, - url: s.url - }; + }; + }); } async createSegmentExport( params: CreateSegmentExportParams ): Promise<{ operationId: string }> { - let body: Record = { - segmentId: params.segmentId, - type: params.type, - format: params.format - }; - - if (params.startTime || params.endTime) { - body.timeRange = {}; - if (params.startTime) body.timeRange.start = params.startTime; - if (params.endTime) body.timeRange.end = params.endTime; - } + return this.request('create segment export', async () => { + let body: Record = { + segmentId: params.segmentId, + type: params.type, + format: params.format + }; + + if (params.startTime || params.endTime) { + body.timeRange = {}; + if (params.startTime) body.timeRange.start = params.startTime; + if (params.endTime) body.timeRange.end = params.endTime; + } - if (params.segmentStartTime || params.segmentEndTime) { - body.segmentTimeRange = {}; - if (params.segmentStartTime) body.segmentTimeRange.start = params.segmentStartTime; - if (params.segmentEndTime) body.segmentTimeRange.end = params.segmentEndTime; - } + if (params.segmentStartTime || params.segmentEndTime) { + body.segmentTimeRange = {}; + if (params.segmentStartTime) body.segmentTimeRange.start = params.segmentStartTime; + if (params.segmentEndTime) body.segmentTimeRange.end = params.segmentEndTime; + } - let response = await this.axios.post('/segments/v1/exports', body); - return { operationId: response.data?.id || response.data?.operationId || '' }; + let response = await this.axios.post('/segments/v1/exports', body); + return { operationId: response.data?.id || response.data?.operationId || '' }; + }); } // ===== Operations API (v1) ===== async getOperation(operationId: string): Promise { - let response = await this.axios.get(`/operations/v1/${encodeURIComponent(operationId)}`); - let data = response.data; - return { - operationId: data.id || '', - type: data.type || '', - state: data.state || '', - details: data.details, - results: data.results, - created: data.created, - finished: data.finished, - progress: data.progress, - step: data.step - }; + return this.request('get operation', async () => { + let response = await this.axios.get(`/operations/v1/${encodeURIComponent(operationId)}`); + let data = response.data; + return { + operationId: data.id || '', + type: data.type || '', + state: data.state || '', + details: data.details, + results: data.results, + created: data.created, + finished: data.finished, + progress: data.progress, + step: data.step + }; + }); } // ===== Search Export Results (v1) ===== async getExportResults(exportId: string): Promise<{ downloadUrl: string }> { - let response = await this.axios.get( - `/search/v1/exports/${encodeURIComponent(exportId)}/results` - ); - return { downloadUrl: response.data?.url || response.data?.downloadUrl || '' }; + return this.request('get export results', async () => { + let response = await this.axios.get( + `/search/v1/exports/${encodeURIComponent(exportId)}/results` + ); + return { downloadUrl: response.data?.url || response.data?.downloadUrl || '' }; + }); } // ===== Annotations API (v2) ===== async createAnnotation(params: CreateAnnotationParams): Promise { - let body: Record = { text: params.text }; - if (params.startTime) body.start_time = params.startTime; - if (params.endTime) body.end_time = params.endTime; - if (params.source) body.source = params.source; - - let response = await this.axios.post('/v2/annotations', body); - return { - text: response.data?.text || params.text, - startTime: response.data?.start_time, - endTime: response.data?.end_time, - source: response.data?.source - }; + return this.request('create annotation', async () => { + let body: Record = { text: params.text }; + if (params.startTime) body.start_time = params.startTime; + if (params.endTime) body.end_time = params.endTime; + if (params.source) body.source = params.source; + + let response = await this.axios.post('/v2/annotations', body); + return { + text: response.data?.text || params.text, + startTime: response.data?.start_time, + endTime: response.data?.end_time, + source: response.data?.source + }; + }); } // ===== Webhook Endpoints API (v1) ===== @@ -349,68 +552,78 @@ export class Client { async createWebhookEndpoint( params: CreateWebhookEndpointParams ): Promise { - let body: Record = { - url: params.url, - eventTypes: params.eventTypes.map(e => { - let mapped: Record = { eventName: e.eventName }; - if (e.subcategory) mapped.subcategory = e.subcategory; - return mapped; - }) - }; - if (params.secret) body.secret = params.secret; - - let response = await this.axios.post('/webhooks/v1/endpoints', body); - return mapResponseEndpoint(response.data); + return this.request('create webhook endpoint', async () => { + let body: Record = { + url: params.url, + eventTypes: params.eventTypes.map(e => { + let mapped: Record = { eventName: e.eventName }; + if (e.subcategory) mapped.subcategory = e.subcategory; + return mapped; + }) + }; + if (params.secret) body.secret = params.secret; + + let response = await this.axios.post('/webhooks/v1/endpoints', body); + return mapResponseEndpoint(response.data); + }); } async getWebhookEndpoint(endpointId: string): Promise { - let response = await this.axios.get( - `/webhooks/v1/endpoints/${encodeURIComponent(endpointId)}` - ); - return mapResponseEndpoint(response.data); + return this.request('get webhook endpoint', async () => { + let response = await this.axios.get( + `/webhooks/v1/endpoints/${encodeURIComponent(endpointId)}` + ); + return mapResponseEndpoint(response.data); + }); } async listWebhookEndpoints(params?: { limit?: number; paginationToken?: string; }): Promise<{ endpoints: FullStoryWebhookEndpoint[]; nextPaginationToken?: string }> { - let query: Record = {}; - if (params?.limit) query.limit = String(params.limit); - if (params?.paginationToken) query.paginationToken = params.paginationToken; - - let response = await this.axios.get('/webhooks/v1/endpoints', { params: query }); - let data = response.data; - - return { - endpoints: (data.endpoints || []).map(mapResponseEndpoint), - nextPaginationToken: data.nextPaginationToken || undefined - }; + return this.request('list webhook endpoints', async () => { + let query: Record = {}; + if (params?.limit) query.limit = String(params.limit); + if (params?.paginationToken) query.paginationToken = params.paginationToken; + + let response = await this.axios.get('/webhooks/v1/endpoints', { params: query }); + let data = response.data; + + return { + endpoints: (data.endpoints || []).map(mapResponseEndpoint), + nextPaginationToken: data.nextPaginationToken || undefined + }; + }); } async updateWebhookEndpoint( endpointId: string, params: UpdateWebhookEndpointParams ): Promise { - let body: Record = {}; - if (params.url !== undefined) body.url = params.url; - if (params.enabled !== undefined) body.enabled = params.enabled; - if (params.secret !== undefined) body.secret = params.secret; - if (params.eventTypes !== undefined) { - body.eventTypes = params.eventTypes.map(e => { - let mapped: Record = { eventName: e.eventName }; - if (e.subcategory) mapped.subcategory = e.subcategory; - return mapped; - }); - } + return this.request('update webhook endpoint', async () => { + let body: Record = {}; + if (params.url !== undefined) body.url = params.url; + if (params.enabled !== undefined) body.enabled = params.enabled; + if (params.secret !== undefined) body.secret = params.secret; + if (params.eventTypes !== undefined) { + body.eventTypes = params.eventTypes.map(e => { + let mapped: Record = { eventName: e.eventName }; + if (e.subcategory) mapped.subcategory = e.subcategory; + return mapped; + }); + } - let response = await this.axios.post( - `/webhooks/v1/endpoints/${encodeURIComponent(endpointId)}`, - body - ); - return mapResponseEndpoint(response.data); + let response = await this.axios.post( + `/webhooks/v1/endpoints/${encodeURIComponent(endpointId)}`, + body + ); + return mapResponseEndpoint(response.data); + }); } async deleteWebhookEndpoint(endpointId: string): Promise { - await this.axios.delete(`/webhooks/v1/endpoints/${encodeURIComponent(endpointId)}`); + await this.request('delete webhook endpoint', async () => { + await this.axios.delete(`/webhooks/v1/endpoints/${encodeURIComponent(endpointId)}`); + }); } } diff --git a/integrations/fullstory/src/lib/errors.ts b/integrations/fullstory/src/lib/errors.ts new file mode 100644 index 0000000000..b61f0e813d --- /dev/null +++ b/integrations/fullstory/src/lib/errors.ts @@ -0,0 +1,82 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractFullStoryMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + for (let key of ['message', 'code', 'error', 'detail']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getFullStoryErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return response?.status ?? error.status; +}; + +export let fullStoryServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let fullStoryApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getFullStoryErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = fullStoryServiceError( + `FullStory API ${operation} failed: ${statusLabel}${extractFullStoryMessage(error)}` + ); + serviceError.data.reason = 'fullstory_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/fullstory/src/tools.schema.test.ts b/integrations/fullstory/src/tools.schema.test.ts new file mode 100644 index 0000000000..7faf5a781b --- /dev/null +++ b/integrations/fullstory/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('FullStory tool input schemas', provider.actions); diff --git a/integrations/fullstory/src/tools/create-event.ts b/integrations/fullstory/src/tools/create-event.ts index 12ca7b80bb..21aac43d6b 100644 --- a/integrations/fullstory/src/tools/create-event.ts +++ b/integrations/fullstory/src/tools/create-event.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { fullStoryServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createEvent = SlateTool.create(spec, { @@ -60,6 +61,23 @@ export let createEvent = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if ( + ctx.input.sessionId && + (ctx.input.userId || ctx.input.uid || ctx.input.useMostRecent) + ) { + throw fullStoryServiceError( + 'Provide sessionId by itself. FullStory rejects events that combine session.id with user fields or useMostRecent.' + ); + } + + if (ctx.input.userId && ctx.input.uid) { + throw fullStoryServiceError('Provide only one of userId or uid for a FullStory event.'); + } + + if (ctx.input.useMostRecent && !ctx.input.userId && !ctx.input.uid) { + throw fullStoryServiceError('useMostRecent requires either userId or uid.'); + } + let client = new Client({ token: ctx.auth.token }); await client.createEvent({ diff --git a/integrations/fullstory/src/tools/generate-session-context.ts b/integrations/fullstory/src/tools/generate-session-context.ts new file mode 100644 index 0000000000..a632b2e123 --- /dev/null +++ b/integrations/fullstory/src/tools/generate-session-context.ts @@ -0,0 +1,145 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let contextElementSchema = z.enum(['user', 'org', 'location', 'device']); +let eventTypeSchema = z.enum([ + 'navigate', + 'click', + 'dead-click', + 'error-click', + 'rage-click', + 'input-change', + 'network-error', + 'console-error', + 'mouse-thrash', + 'highlight', + 'copy', + 'paste', + 'element-seen' +]); + +export let generateSessionContext = SlateTool.create(spec, { + name: 'Generate Session Context', + key: 'generate_session_context', + description: + 'Generate a structured, AI-friendly context summary for a FullStory session, including selected session metadata and transformed event details.', + constraints: ['Part of FullStory Anywhere: Activation.'], + tags: { + readOnly: true + } +}) + .input( + z.object({ + sessionId: z + .string() + .describe('Canonical FullStory session ID, usually formatted as userId:sessionId'), + sliceMode: z + .enum(['UNSPECIFIED', 'FIRST', 'LAST', 'TIMESTAMP']) + .optional() + .describe('How to slice the session event stream. Defaults to FIRST in FullStory.'), + eventLimit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of events to include when greater than zero'), + durationLimitMs: z + .string() + .optional() + .describe('Duration window in milliseconds, encoded as an int64 string'), + startTimestamp: z + .string() + .optional() + .describe('Start timestamp for TIMESTAMP slicing or bounded context'), + endTimestamp: z.string().optional().describe('Exclude events after this timestamp'), + includeContext: z + .array(contextElementSchema) + .optional() + .describe('Context elements to include: user, org, location, device'), + excludeContext: z + .array(contextElementSchema) + .optional() + .describe('Context elements to exclude: user, org, location, device'), + excludeOrgContext: z.boolean().optional().describe('Exclude organization context'), + excludeUserContext: z.boolean().optional().describe('Exclude user context'), + excludeLocation: z.boolean().optional().describe('Exclude location context'), + excludeDevice: z.boolean().optional().describe('Exclude device context'), + includeEventTypes: z + .array(eventTypeSchema) + .optional() + .describe('Only include these event types'), + excludeEventTypes: z + .array(eventTypeSchema) + .optional() + .describe('Exclude these event types'), + excludeDefinedEvents: z.boolean().optional().describe('Exclude defined events'), + excludeApiEvents: z.boolean().optional().describe('Exclude server/API events'), + excludeEventTimestamps: z.boolean().optional().describe('Exclude event timestamps'), + excludeSelectors: z.boolean().optional().describe('Exclude selector details'), + includeSelectorTags: z.boolean().optional().describe('Include selector tag names'), + trimToLastNSelectors: z + .number() + .int() + .positive() + .optional() + .describe('Trim event selectors to the last N selectors'), + includeTabIndex: z.boolean().optional().describe('Include browser tab indexes'), + includeDescriptions: z + .boolean() + .optional() + .describe('Include FullStory-generated event descriptions'), + enableEventCache: z.boolean().optional().describe('Enable FullStory event caching') + }) + ) + .output( + z.object({ + contextData: z + .record(z.string(), z.any()) + .optional() + .describe('Generated FullStory session context data'), + rawResponse: z.record(z.string(), z.any()).describe('Complete FullStory response') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.generateSessionContext({ + sessionId: ctx.input.sessionId, + sliceMode: ctx.input.sliceMode, + eventLimit: ctx.input.eventLimit, + durationLimitMs: ctx.input.durationLimitMs, + startTimestamp: ctx.input.startTimestamp, + endTimestamp: ctx.input.endTimestamp, + includeContext: ctx.input.includeContext, + excludeContext: ctx.input.excludeContext, + excludeOrgContext: ctx.input.excludeOrgContext, + excludeUserContext: ctx.input.excludeUserContext, + excludeLocation: ctx.input.excludeLocation, + excludeDevice: ctx.input.excludeDevice, + includeEventTypes: ctx.input.includeEventTypes, + excludeEventTypes: ctx.input.excludeEventTypes, + excludeDefinedEvents: ctx.input.excludeDefinedEvents, + excludeApiEvents: ctx.input.excludeApiEvents, + excludeEventTimestamps: ctx.input.excludeEventTimestamps, + excludeSelectors: ctx.input.excludeSelectors, + includeSelectorTags: ctx.input.includeSelectorTags, + trimToLastNSelectors: ctx.input.trimToLastNSelectors, + includeTabIndex: ctx.input.includeTabIndex, + includeDescriptions: ctx.input.includeDescriptions, + enableEventCache: ctx.input.enableEventCache + }); + let contextData = + typeof result.context_data === 'object' && result.context_data !== null + ? result.context_data + : undefined; + + return { + output: { + contextData, + rawResponse: result + }, + message: `Generated session context for \`${ctx.input.sessionId}\`.` + }; + }) + .build(); diff --git a/integrations/fullstory/src/tools/get-organization-quotas.ts b/integrations/fullstory/src/tools/get-organization-quotas.ts new file mode 100644 index 0000000000..5f5cee175c --- /dev/null +++ b/integrations/fullstory/src/tools/get-organization-quotas.ts @@ -0,0 +1,40 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let quotaSchema = z + .object({ + usage: z.string().optional().describe('Current quota usage in the billing cycle'), + limit: z.string().optional().describe('Quota limit for the billing cycle'), + periodStart: z.string().optional().describe('Start of the current billing cycle'), + periodEnd: z.string().optional().describe('End of the current billing cycle') + }) + .optional(); + +export let getOrganizationQuotas = SlateTool.create(spec, { + name: 'Get Organization Quotas', + key: 'get_organization_quotas', + description: + 'Retrieve FullStory organization quota usage for the current billing cycle, including session quota and server event quota.', + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + sessionQuota: quotaSchema.describe('Session quota usage'), + serverEventQuota: quotaSchema.describe('Server-side event quota usage') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let quotas = await client.getOrganizationQuotas(); + + return { + output: quotas, + message: 'Retrieved FullStory organization quotas.' + }; + }) + .build(); diff --git a/integrations/fullstory/src/tools/get-session-events.ts b/integrations/fullstory/src/tools/get-session-events.ts new file mode 100644 index 0000000000..9879206bec --- /dev/null +++ b/integrations/fullstory/src/tools/get-session-events.ts @@ -0,0 +1,48 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getSessionEvents = SlateTool.create(spec, { + name: 'Get Session Events', + key: 'get_session_events', + description: + 'Retrieve the raw captured events for a FullStory session. This is useful for inspecting the exact timeline behind a replay or downstream activation workflow.', + constraints: ['Part of FullStory Anywhere: Activation.'], + tags: { + readOnly: true + } +}) + .input( + z.object({ + sessionId: z + .string() + .describe('Canonical FullStory session ID, usually formatted as userId:sessionId'), + enableEventCache: z.boolean().optional().describe('Deprecated FullStory cache flag') + }) + ) + .output( + z.object({ + events: z + .array(z.record(z.string(), z.any())) + .optional() + .describe('Raw FullStory session events'), + rawResponse: z.record(z.string(), z.any()).describe('Complete FullStory response') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.getSessionEvents(ctx.input.sessionId, { + enableEventCache: ctx.input.enableEventCache + }); + let events = Array.isArray(result.events) ? result.events : undefined; + + return { + output: { + events, + rawResponse: result + }, + message: `Retrieved **${events?.length ?? 0}** events for session \`${ctx.input.sessionId}\`.` + }; + }) + .build(); diff --git a/integrations/fullstory/src/tools/index.ts b/integrations/fullstory/src/tools/index.ts index 6a99efcc07..bf9050df08 100644 --- a/integrations/fullstory/src/tools/index.ts +++ b/integrations/fullstory/src/tools/index.ts @@ -3,8 +3,11 @@ export * from './create-event'; export * from './create-or-update-user'; export * from './delete-user'; export * from './export-segment'; +export * from './generate-session-context'; export * from './get-operation-status'; +export * from './get-organization-quotas'; export * from './get-segment'; +export * from './get-session-events'; export * from './get-user'; export * from './list-segments'; export * from './list-sessions'; diff --git a/integrations/fullstory/src/tools/list-sessions.ts b/integrations/fullstory/src/tools/list-sessions.ts index 33fe05192b..1327999ca9 100644 --- a/integrations/fullstory/src/tools/list-sessions.ts +++ b/integrations/fullstory/src/tools/list-sessions.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { fullStoryServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listSessions = SlateTool.create(spec, { @@ -9,7 +10,8 @@ export let listSessions = SlateTool.create(spec, { description: `Retrieve a list of recorded sessions for a user. Lookup by uid or email address. Returns session replay URLs that can be embedded in support tools or other applications.`, instructions: [ 'Provide either uid or email (at least one is required).', - 'If both are provided, FullStory returns the union of results.' + 'If both are provided, FullStory returns the union of results.', + 'Pagination is not currently supported by FullStory for this endpoint.' ], tags: { readOnly: true @@ -31,13 +33,17 @@ export let listSessions = SlateTool.create(spec, { z.object({ userId: z.string().describe('FullStory user ID'), sessionId: z.string().describe('Session ID'), - createdTime: z.string().describe('When the session was created (unix timestamp)'), + createdTime: z.string().describe('When the session was created (ISO 8601)'), replayUrl: z.string().describe('URL to replay this session in FullStory') }) ) }) ) .handleInvocation(async ctx => { + if (!ctx.input.uid && !ctx.input.email) { + throw fullStoryServiceError('Provide uid or email to list FullStory sessions.'); + } + let client = new Client({ token: ctx.auth.token }); let sessions = await client.listSessions({ @@ -46,12 +52,16 @@ export let listSessions = SlateTool.create(spec, { limit: ctx.input.limit }); - let mapped = sessions.map(s => ({ - userId: s.userId, - sessionId: s.sessionId, - createdTime: s.createdTime, - replayUrl: s.fsUrl - })); + let mapped = sessions.map(s => { + let [userId = ''] = s.sessionId.split(':'); + + return { + userId, + sessionId: s.sessionId, + createdTime: s.createdTime, + replayUrl: s.fsUrl + }; + }); return { output: { diff --git a/integrations/fullstory/src/tools/manage-webhook-endpoint.ts b/integrations/fullstory/src/tools/manage-webhook-endpoint.ts index 4b4b5f00b4..350300f13f 100644 --- a/integrations/fullstory/src/tools/manage-webhook-endpoint.ts +++ b/integrations/fullstory/src/tools/manage-webhook-endpoint.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { fullStoryServiceError } from '../lib/errors'; import { spec } from '../spec'; let eventTypeSchema = z.object({ @@ -15,6 +16,20 @@ let eventTypeSchema = z.object({ .describe('Subcategory, required for "recording.event.custom" events') }); +let validateEventTypes = (eventTypes: Array<{ eventName: string; subcategory?: string }>) => { + if (eventTypes.length === 0) { + throw fullStoryServiceError('Provide at least one FullStory webhook event type.'); + } + + for (let eventType of eventTypes) { + if (eventType.eventName === 'recording.event.custom' && !eventType.subcategory) { + throw fullStoryServiceError( + 'FullStory webhook event type recording.event.custom requires subcategory.' + ); + } + } +}; + export let manageWebhookEndpoint = SlateTool.create(spec, { name: 'Manage Webhook Endpoint', key: 'manage_webhook_endpoint', @@ -65,6 +80,10 @@ export let manageWebhookEndpoint = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); + if (ctx.input.deleteEndpoint && !ctx.input.endpointId) { + throw fullStoryServiceError('endpointId is required when deleting a webhook endpoint.'); + } + if (ctx.input.deleteEndpoint && ctx.input.endpointId) { await client.deleteWebhookEndpoint(ctx.input.endpointId); return { @@ -77,6 +96,10 @@ export let manageWebhookEndpoint = SlateTool.create(spec, { } if (ctx.input.endpointId) { + if (ctx.input.eventTypes) { + validateEventTypes(ctx.input.eventTypes); + } + let endpoint = await client.updateWebhookEndpoint(ctx.input.endpointId, { url: ctx.input.url, eventTypes: ctx.input.eventTypes, @@ -98,8 +121,11 @@ export let manageWebhookEndpoint = SlateTool.create(spec, { } if (!ctx.input.url || !ctx.input.eventTypes) { - throw new Error('url and eventTypes are required when creating a new webhook endpoint'); + throw fullStoryServiceError( + 'url and eventTypes are required when creating a new webhook endpoint.' + ); } + validateEventTypes(ctx.input.eventTypes); let endpoint = await client.createWebhookEndpoint({ url: ctx.input.url, diff --git a/integrations/fullstory/vitest.config.ts b/integrations/fullstory/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/fullstory/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/grafana/package.json b/integrations/grafana/package.json index b13b040292..8fbb2343af 100644 --- a/integrations/grafana/package.json +++ b/integrations/grafana/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/grafana/src/auth.ts b/integrations/grafana/src/auth.ts index dcf689b261..67ca74bd03 100644 --- a/integrations/grafana/src/auth.ts +++ b/integrations/grafana/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { grafanaApiError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -34,19 +35,23 @@ export let auth = SlateAuth.create() }) => { let baseUrl = ctx.input.instanceUrl.replace(/\/+$/, ''); let axios = createAxios(); - let response = await axios.get(`${baseUrl}/api/org`, { - headers: { - Authorization: `Bearer ${ctx.output.token}`, - 'Content-Type': 'application/json' - } - }); + try { + let response = await axios.get(`${baseUrl}/api/org`, { + headers: { + Authorization: `Bearer ${ctx.output.token}`, + 'Content-Type': 'application/json' + } + }); - return { - profile: { - id: String(response.data.id), - name: response.data.name - } - }; + return { + profile: { + id: String(response.data.id), + name: response.data.name + } + }; + } catch (error) { + throw grafanaApiError(error, 'profile lookup'); + } } }) .addCustomAuth({ @@ -76,19 +81,23 @@ export let auth = SlateAuth.create() }) => { let baseUrl = ctx.input.instanceUrl.replace(/\/+$/, ''); let axios = createAxios(); - let response = await axios.get(`${baseUrl}/api/user`, { - headers: { - Authorization: ctx.output.token, - 'Content-Type': 'application/json' - } - }); + try { + let response = await axios.get(`${baseUrl}/api/user`, { + headers: { + Authorization: ctx.output.token, + 'Content-Type': 'application/json' + } + }); - return { - profile: { - id: String(response.data.id), - email: response.data.email, - name: response.data.name || response.data.login - } - }; + return { + profile: { + id: String(response.data.id), + email: response.data.email, + name: response.data.name || response.data.login + } + }; + } catch (error) { + throw grafanaApiError(error, 'profile lookup'); + } } }); diff --git a/integrations/grafana/src/config.ts b/integrations/grafana/src/config.ts index 0fac280974..50a1e971e1 100644 --- a/integrations/grafana/src/config.ts +++ b/integrations/grafana/src/config.ts @@ -13,6 +13,12 @@ export let config = SlateConfig.create( .optional() .describe( 'Organization ID to scope API requests to. If not set, the default organization context is used.' + ), + apiNamespace: z + .string() + .optional() + .describe( + 'Grafana App Platform namespace for /apis endpoints. Defaults to "default" for org 1 or org- when organizationId is set. Grafana Cloud stacks can use stacks-.' ) }) ); diff --git a/integrations/grafana/src/index.ts b/integrations/grafana/src/index.ts index ae3efc1702..3479ad2a95 100644 --- a/integrations/grafana/src/index.ts +++ b/integrations/grafana/src/index.ts @@ -8,6 +8,8 @@ import { createContactPoint, createDataSource, createFolder, + createMuteTiming, + createPlaylist, createSnapshot, createTeam, deleteAlertRule, @@ -16,19 +18,27 @@ import { deleteDashboard, deleteDataSource, deleteFolder, + deleteMuteTiming, + deletePlaylist, deleteSnapshot, deleteTeam, findAnnotations, getAlertRule, getDashboard, getDataSource, + getFolder, + getMuteTiming, getNotificationPolicies, + getPlaylist, + getTeam, getTeamMembers, listAlertRules, listContactPoints, listDataSources, listFolders, + listMuteTimings, listOrgUsers, + listPlaylists, listSnapshots, removeOrgUser, removeTeamMember, @@ -37,10 +47,14 @@ import { searchTeams, updateAlertRule, updateAnnotation, + updateContactPoint, updateDataSource, updateFolder, + updateMuteTiming, updateNotificationPolicies, - updateOrgUserRole + updateOrgUserRole, + updatePlaylist, + updateTeam } from './tools'; import { alertNotification, annotationEvents } from './triggers'; @@ -52,6 +66,7 @@ export let provider = Slate.create({ saveDashboard, deleteDashboard, listFolders, + getFolder, createFolder, updateFolder, deleteFolder, @@ -67,9 +82,15 @@ export let provider = Slate.create({ deleteAlertRule, listContactPoints, createContactPoint, + updateContactPoint, deleteContactPoint, getNotificationPolicies, updateNotificationPolicies, + listMuteTimings, + getMuteTiming, + createMuteTiming, + updateMuteTiming, + deleteMuteTiming, findAnnotations, createAnnotation, updateAnnotation, @@ -77,8 +98,15 @@ export let provider = Slate.create({ listSnapshots, createSnapshot, deleteSnapshot, + listPlaylists, + getPlaylist, + createPlaylist, + updatePlaylist, + deletePlaylist, searchTeams, createTeam, + getTeam, + updateTeam, getTeamMembers, addTeamMember, removeTeamMember, diff --git a/integrations/grafana/src/lib/client.ts b/integrations/grafana/src/lib/client.ts index 856280b2d2..1e7fa88542 100644 --- a/integrations/grafana/src/lib/client.ts +++ b/integrations/grafana/src/lib/client.ts @@ -1,16 +1,20 @@ import { createAxios } from 'slates'; +import { grafanaApiError } from './errors'; export interface GrafanaClientConfig { instanceUrl: string; token: string; organizationId?: string; + apiNamespace?: string; } export class GrafanaClient { private axios: ReturnType; + private apiNamespace: string; constructor(config: GrafanaClientConfig) { let baseUrl = config.instanceUrl.replace(/\/+$/, ''); + let organizationId = config.organizationId?.trim(); let headers: Record = { 'Content-Type': 'application/json' }; @@ -21,14 +25,22 @@ export class GrafanaClient { headers.Authorization = `Bearer ${config.token}`; } - if (config.organizationId) { - headers['X-Grafana-Org-Id'] = config.organizationId; + if (organizationId) { + headers['X-Grafana-Org-Id'] = organizationId; } this.axios = createAxios({ baseURL: baseUrl, headers }); + this.apiNamespace = + config.apiNamespace?.trim() || + (organizationId && organizationId !== '1' ? `org-${organizationId}` : 'default'); + + this.axios.interceptors?.response?.use( + (response: any) => response, + (error: unknown) => Promise.reject(grafanaApiError(error)) + ); } // ---- Dashboards ---- @@ -141,6 +153,7 @@ export class GrafanaClient { access?: string; url?: string; isDefault?: boolean; + database?: string; jsonData?: Record; secureJsonData?: Record; }): Promise { @@ -244,6 +257,13 @@ export class GrafanaClient { return response.data; } + async getMuteTiming(name: string): Promise { + let response = await this.axios.get( + `/api/v1/provisioning/mute-timings/${encodeURIComponent(name)}` + ); + return response.data; + } + async createMuteTiming(timing: Record): Promise { let response = await this.axios.post('/api/v1/provisioning/mute-timings', timing, { headers: { 'X-Disable-Provenance': 'true' } @@ -444,22 +464,41 @@ export class GrafanaClient { // ---- Playlists ---- - async listPlaylists(): Promise { - let response = await this.axios.get('/api/playlists'); + async listPlaylists(): Promise { + let response = await this.axios.get( + `/apis/playlist.grafana.app/v1/namespaces/${encodeURIComponent(this.apiNamespace)}/playlists` + ); return response.data; } async getPlaylist(uid: string): Promise { - let response = await this.axios.get(`/api/playlists/${uid}`); + let response = await this.axios.get( + `/apis/playlist.grafana.app/v1/namespaces/${encodeURIComponent(this.apiNamespace)}/playlists/${encodeURIComponent(uid)}` + ); return response.data; } async createPlaylist(playlist: { + uid: string; name: string; interval: string; items: Array<{ type: string; value: string }>; }): Promise { - let response = await this.axios.post('/api/playlists', playlist); + let response = await this.axios.post( + `/apis/playlist.grafana.app/v1/namespaces/${encodeURIComponent(this.apiNamespace)}/playlists`, + { + kind: 'Playlist', + apiVersion: 'playlist.grafana.app/v1', + metadata: { + name: playlist.uid + }, + spec: { + title: playlist.name, + interval: playlist.interval, + items: playlist.items + } + } + ); return response.data; } @@ -469,14 +508,32 @@ export class GrafanaClient { name: string; interval: string; items: Array<{ type: string; value: string }>; + resourceVersion?: string; } ): Promise { - let response = await this.axios.put(`/api/playlists/${uid}`, playlist); + let metadata: Record = { name: uid }; + if (playlist.resourceVersion) metadata.resourceVersion = playlist.resourceVersion; + + let response = await this.axios.put( + `/apis/playlist.grafana.app/v1/namespaces/${encodeURIComponent(this.apiNamespace)}/playlists/${encodeURIComponent(uid)}`, + { + kind: 'Playlist', + apiVersion: 'playlist.grafana.app/v1', + metadata, + spec: { + title: playlist.name, + interval: playlist.interval, + items: playlist.items + } + } + ); return response.data; } async deletePlaylist(uid: string): Promise { - let response = await this.axios.delete(`/api/playlists/${uid}`); + let response = await this.axios.delete( + `/apis/playlist.grafana.app/v1/namespaces/${encodeURIComponent(this.apiNamespace)}/playlists/${encodeURIComponent(uid)}` + ); return response.data; } diff --git a/integrations/grafana/src/lib/errors.ts b/integrations/grafana/src/lib/errors.ts new file mode 100644 index 0000000000..5ca3f88961 --- /dev/null +++ b/integrations/grafana/src/lib/errors.ts @@ -0,0 +1,98 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.error); + pushDetail(details, value.error_description); + pushDetail(details, value.detail); + pushDetail(details, value.title); + pushDetail(details, value.status); + collectDetails(value.errors, details); +}; + +let extractGrafanaMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + let code = response.data.status ?? response.data.error; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +}; + +export let grafanaServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let grafanaApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = grafanaServiceError( + `Grafana API ${operation} failed: ${statusLabelFor(response)}${extractGrafanaMessage(error)}` + ); + + serviceError.data.reason = 'grafana_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/grafana/src/tools/index.ts b/integrations/grafana/src/tools/index.ts index 7ae3865266..098c91de9d 100644 --- a/integrations/grafana/src/tools/index.ts +++ b/integrations/grafana/src/tools/index.ts @@ -5,8 +5,10 @@ export * from './manage-annotations'; export * from './manage-contact-points'; export * from './manage-datasources'; export * from './manage-folders'; +export * from './manage-mute-timings'; export * from './manage-notification-policies'; export * from './manage-org-users'; +export * from './manage-playlists'; export * from './manage-snapshots'; export * from './manage-teams'; export * from './save-dashboard'; diff --git a/integrations/grafana/src/tools/manage-contact-points.ts b/integrations/grafana/src/tools/manage-contact-points.ts index caeb31c0bf..6eb7be28f7 100644 --- a/integrations/grafana/src/tools/manage-contact-points.ts +++ b/integrations/grafana/src/tools/manage-contact-points.ts @@ -118,6 +118,71 @@ export let createContactPoint = SlateTool.create(spec, { }) .build(); +export let updateContactPoint = SlateTool.create(spec, { + name: 'Update Contact Point', + key: 'update_contact_point', + description: `Update an existing alert notification contact point by UID. Provide the full receiver configuration, including name, integration type, and settings.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + contactPointUid: z.string().describe('UID of the contact point to update'), + name: z.string().describe('Updated contact point name'), + type: z + .string() + .describe( + 'Integration type (e.g. slack, email, pagerduty, webhook, teams, opsgenie, discord)' + ), + settings: z + .record(z.string(), z.any()) + .describe('Full integration-specific settings for the contact point'), + disableResolveMessage: z + .boolean() + .optional() + .describe('Disable sending resolve notifications') + }) + ) + .output( + z.object({ + contactPointUid: z.string().describe('UID of the updated contact point'), + name: z.string().describe('Updated contact point name'), + type: z.string().describe('Integration type'), + message: z.string().describe('Confirmation message') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId + }); + + let body: Record = { + uid: ctx.input.contactPointUid, + name: ctx.input.name, + type: ctx.input.type, + settings: ctx.input.settings + }; + if (ctx.input.disableResolveMessage !== undefined) { + body.disableResolveMessage = ctx.input.disableResolveMessage; + } + + let result = await client.updateContactPoint(ctx.input.contactPointUid, body); + + return { + output: { + contactPointUid: ctx.input.contactPointUid, + name: ctx.input.name, + type: ctx.input.type, + message: result.message || 'Contact point updated.' + }, + message: `Contact point **${ctx.input.name}** (${ctx.input.type}) updated.` + }; + }) + .build(); + export let deleteContactPoint = SlateTool.create(spec, { name: 'Delete Contact Point', key: 'delete_contact_point', diff --git a/integrations/grafana/src/tools/manage-datasources.ts b/integrations/grafana/src/tools/manage-datasources.ts index 249a517179..902467a74c 100644 --- a/integrations/grafana/src/tools/manage-datasources.ts +++ b/integrations/grafana/src/tools/manage-datasources.ts @@ -170,6 +170,7 @@ export let createDataSource = SlateTool.create(spec, { url: ctx.input.url, access: ctx.input.access, isDefault: ctx.input.isDefault, + database: ctx.input.database, jsonData: ctx.input.jsonData, secureJsonData: ctx.input.secureJsonData }); @@ -203,6 +204,7 @@ export let updateDataSource = SlateTool.create(spec, { url: z.string().optional().describe('New URL for the data source'), access: z.enum(['proxy', 'direct']).optional().describe('Updated access mode'), isDefault: z.boolean().optional().describe('Whether to set as default data source'), + database: z.string().optional().describe('Updated database name if applicable'), jsonData: z .record(z.string(), z.any()) .optional() @@ -237,6 +239,7 @@ export let updateDataSource = SlateTool.create(spec, { if (ctx.input.url !== undefined) updated.url = ctx.input.url; if (ctx.input.access !== undefined) updated.access = ctx.input.access; if (ctx.input.isDefault !== undefined) updated.isDefault = ctx.input.isDefault; + if (ctx.input.database !== undefined) updated.database = ctx.input.database; if (ctx.input.jsonData !== undefined) updated.jsonData = { ...current.jsonData, ...ctx.input.jsonData }; if (ctx.input.secureJsonData !== undefined) diff --git a/integrations/grafana/src/tools/manage-folders.ts b/integrations/grafana/src/tools/manage-folders.ts index 8123aabad6..ca58ea1c3b 100644 --- a/integrations/grafana/src/tools/manage-folders.ts +++ b/integrations/grafana/src/tools/manage-folders.ts @@ -56,6 +56,52 @@ export let listFolders = SlateTool.create(spec, { }) .build(); +export let getFolder = SlateTool.create(spec, { + name: 'Get Folder', + key: 'get_folder', + description: `Retrieve a folder by UID, including its title, URL, parent folder, and version metadata.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + folderUid: z.string().describe('UID of the folder to retrieve') + }) + ) + .output( + z.object({ + folderUid: z.string().describe('UID of the folder'), + title: z.string().describe('Folder title'), + folderUrl: z.string().optional().describe('URL to the folder'), + parentUid: z.string().optional().describe('UID of the parent folder'), + version: z.number().optional().describe('Folder version') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId, + apiNamespace: ctx.config.apiNamespace + }); + + let folder = await client.getFolder(ctx.input.folderUid); + + return { + output: { + folderUid: folder.uid, + title: folder.title, + folderUrl: folder.url, + parentUid: folder.parentUid, + version: folder.version + }, + message: `Retrieved folder **${folder.title || ctx.input.folderUid}**.` + }; + }) + .build(); + export let createFolder = SlateTool.create(spec, { name: 'Create Folder', key: 'create_folder', @@ -85,7 +131,8 @@ export let createFolder = SlateTool.create(spec, { let client = new GrafanaClient({ instanceUrl: ctx.config.instanceUrl, token: ctx.auth.token, - organizationId: ctx.config.organizationId + organizationId: ctx.config.organizationId, + apiNamespace: ctx.config.apiNamespace }); let result = await client.createFolder( @@ -134,7 +181,8 @@ export let updateFolder = SlateTool.create(spec, { let client = new GrafanaClient({ instanceUrl: ctx.config.instanceUrl, token: ctx.auth.token, - organizationId: ctx.config.organizationId + organizationId: ctx.config.organizationId, + apiNamespace: ctx.config.apiNamespace }); let result = await client.updateFolder( @@ -180,7 +228,8 @@ export let deleteFolder = SlateTool.create(spec, { let client = new GrafanaClient({ instanceUrl: ctx.config.instanceUrl, token: ctx.auth.token, - organizationId: ctx.config.organizationId + organizationId: ctx.config.organizationId, + apiNamespace: ctx.config.apiNamespace }); await client.deleteFolder(ctx.input.folderUid, ctx.input.forceDeleteRules); diff --git a/integrations/grafana/src/tools/manage-mute-timings.ts b/integrations/grafana/src/tools/manage-mute-timings.ts new file mode 100644 index 0000000000..a2e21962cd --- /dev/null +++ b/integrations/grafana/src/tools/manage-mute-timings.ts @@ -0,0 +1,219 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { GrafanaClient } from '../lib/client'; +import { spec } from '../spec'; + +let muteTimeIntervalSchema = z + .record(z.string(), z.any()) + .describe( + 'Grafana mute time interval object, such as {"weekdays":["saturday","sunday"]} or {"times":[{"start_time":"22:00","end_time":"23:59"}]}.' + ); + +let mapMuteTiming = (timing: any) => ({ + name: timing.name, + timeIntervals: timing.time_intervals || timing.timeIntervals || [], + version: timing.version, + provenance: timing.provenance +}); + +export let listMuteTimings = SlateTool.create(spec, { + name: 'List Mute Timings', + key: 'list_mute_timings', + description: `List alert notification mute timings configured in Grafana. Mute timings define recurring time windows that notification policies can use to suppress alerts.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + muteTimings: z.array( + z.object({ + name: z.string().describe('Mute timing name'), + timeIntervals: z.array(z.any()).describe('Configured mute intervals'), + version: z.string().optional().describe('Version identifier'), + provenance: z.string().optional().describe('Provisioning provenance') + }) + ) + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId + }); + + let muteTimings = (await client.listMuteTimings()).map(mapMuteTiming); + + return { + output: { muteTimings }, + message: `Found **${muteTimings.length}** mute timing(s).` + }; + }) + .build(); + +export let getMuteTiming = SlateTool.create(spec, { + name: 'Get Mute Timing', + key: 'get_mute_timing', + description: `Retrieve a mute timing by name, including its configured recurring time intervals and provenance.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + name: z.string().describe('Name of the mute timing to retrieve') + }) + ) + .output( + z.object({ + name: z.string().describe('Mute timing name'), + timeIntervals: z.array(z.any()).describe('Configured mute intervals'), + version: z.string().optional().describe('Version identifier'), + provenance: z.string().optional().describe('Provisioning provenance') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId + }); + + let muteTiming = mapMuteTiming(await client.getMuteTiming(ctx.input.name)); + + return { + output: muteTiming, + message: `Retrieved mute timing **${muteTiming.name}**.` + }; + }) + .build(); + +export let createMuteTiming = SlateTool.create(spec, { + name: 'Create Mute Timing', + key: 'create_mute_timing', + description: `Create a mute timing for Grafana alert notification policies. Use this to define reusable quiet hours or maintenance windows.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + name: z.string().describe('Unique mute timing name'), + timeIntervals: z + .array(muteTimeIntervalSchema) + .describe('One or more Grafana mute time interval objects') + }) + ) + .output( + z.object({ + name: z.string().describe('Created mute timing name'), + timeIntervals: z.array(z.any()).describe('Configured mute intervals'), + version: z.string().optional().describe('Version identifier'), + provenance: z.string().optional().describe('Provisioning provenance') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId + }); + + let muteTiming = mapMuteTiming( + await client.createMuteTiming({ + name: ctx.input.name, + time_intervals: ctx.input.timeIntervals + }) + ); + + return { + output: muteTiming, + message: `Mute timing **${ctx.input.name}** created.` + }; + }) + .build(); + +export let updateMuteTiming = SlateTool.create(spec, { + name: 'Update Mute Timing', + key: 'update_mute_timing', + description: `Replace an existing mute timing by name. The provided intervals replace the current mute timing definition.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + name: z.string().describe('Name of the mute timing to replace'), + timeIntervals: z + .array(muteTimeIntervalSchema) + .describe('Replacement Grafana mute time interval objects') + }) + ) + .output( + z.object({ + name: z.string().describe('Updated mute timing name'), + timeIntervals: z.array(z.any()).describe('Configured mute intervals'), + version: z.string().optional().describe('Version identifier'), + provenance: z.string().optional().describe('Provisioning provenance') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId + }); + + let muteTiming = mapMuteTiming( + await client.updateMuteTiming(ctx.input.name, { + name: ctx.input.name, + time_intervals: ctx.input.timeIntervals + }) + ); + + return { + output: muteTiming, + message: `Mute timing **${ctx.input.name}** updated.` + }; + }) + .build(); + +export let deleteMuteTiming = SlateTool.create(spec, { + name: 'Delete Mute Timing', + key: 'delete_mute_timing', + description: `Delete a mute timing by name. Notification policies referencing it should be updated first.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + name: z.string().describe('Name of the mute timing to delete') + }) + ) + .output( + z.object({ + message: z.string().describe('Confirmation message') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId + }); + + await client.deleteMuteTiming(ctx.input.name); + + return { + output: { + message: `Mute timing ${ctx.input.name} deleted.` + }, + message: `Mute timing **${ctx.input.name}** has been deleted.` + }; + }) + .build(); diff --git a/integrations/grafana/src/tools/manage-playlists.ts b/integrations/grafana/src/tools/manage-playlists.ts new file mode 100644 index 0000000000..37d05fe2ab --- /dev/null +++ b/integrations/grafana/src/tools/manage-playlists.ts @@ -0,0 +1,249 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { GrafanaClient } from '../lib/client'; +import { spec } from '../spec'; + +let playlistItemSchema = z.object({ + type: z + .enum(['dashboard_by_uid', 'dashboard_by_tag', 'dashboard_by_id']) + .describe('Playlist item selector type. dashboard_by_id is deprecated by Grafana.'), + value: z.string().describe('Dashboard UID, dashboard tag, or deprecated dashboard ID') +}); + +let mapPlaylist = (playlist: any) => { + let metadata = playlist.metadata || {}; + let spec = playlist.spec || playlist; + + return { + playlistUid: metadata.name || playlist.uid, + title: spec.title || spec.name || playlist.name, + interval: spec.interval || playlist.interval, + items: spec.items || playlist.items || [], + resourceVersion: metadata.resourceVersion, + created: metadata.creationTimestamp || playlist.created, + updated: metadata.annotations?.['grafana.app/updatedTimestamp'] || playlist.updated + }; +}; + +let getPlaylistItems = (response: any) => + Array.isArray(response?.items) ? response.items : Array.isArray(response) ? response : []; + +export let listPlaylists = SlateTool.create(spec, { + name: 'List Playlists', + key: 'list_playlists', + description: `List Grafana playlists. Playlists rotate through dashboards by UID or tag for wallboards, NOC displays, and recurring operational reviews.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + playlists: z.array( + z.object({ + playlistUid: z.string().optional().describe('Playlist UID'), + title: z.string().optional().describe('Playlist title'), + interval: z.string().optional().describe('Rotation interval'), + items: z.array(z.any()).optional().describe('Playlist items'), + resourceVersion: z.string().optional().describe('Resource version') + }) + ) + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId, + apiNamespace: ctx.config.apiNamespace + }); + + let playlists = getPlaylistItems(await client.listPlaylists()).map(mapPlaylist); + + return { + output: { playlists }, + message: `Found **${playlists.length}** playlist(s).` + }; + }) + .build(); + +export let getPlaylist = SlateTool.create(spec, { + name: 'Get Playlist', + key: 'get_playlist', + description: `Retrieve a Grafana playlist by UID, including its rotation interval and dashboard selectors.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + playlistUid: z.string().describe('UID of the playlist to retrieve') + }) + ) + .output( + z.object({ + playlistUid: z.string().optional().describe('Playlist UID'), + title: z.string().optional().describe('Playlist title'), + interval: z.string().optional().describe('Rotation interval'), + items: z.array(z.any()).optional().describe('Playlist items'), + resourceVersion: z.string().optional().describe('Resource version'), + created: z.string().optional().describe('Creation timestamp'), + updated: z.string().optional().describe('Last update timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId, + apiNamespace: ctx.config.apiNamespace + }); + + let playlist = mapPlaylist(await client.getPlaylist(ctx.input.playlistUid)); + + return { + output: playlist, + message: `Retrieved playlist **${playlist.title || ctx.input.playlistUid}**.` + }; + }) + .build(); + +export let createPlaylist = SlateTool.create(spec, { + name: 'Create Playlist', + key: 'create_playlist', + description: `Create a Grafana playlist that cycles through dashboards selected by UID or tag.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + playlistUid: z.string().describe('Unique playlist UID'), + title: z.string().describe('Playlist title'), + interval: z.string().describe('Dashboard rotation interval, e.g. "5m"'), + items: z.array(playlistItemSchema).describe('Dashboard selectors in playlist order') + }) + ) + .output( + z.object({ + playlistUid: z.string().optional().describe('Created playlist UID'), + title: z.string().optional().describe('Playlist title'), + interval: z.string().optional().describe('Rotation interval'), + items: z.array(z.any()).optional().describe('Playlist items'), + resourceVersion: z.string().optional().describe('Resource version') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId, + apiNamespace: ctx.config.apiNamespace + }); + + let playlist = mapPlaylist( + await client.createPlaylist({ + uid: ctx.input.playlistUid, + name: ctx.input.title, + interval: ctx.input.interval, + items: ctx.input.items + }) + ); + + return { + output: playlist, + message: `Playlist **${ctx.input.title}** created.` + }; + }) + .build(); + +export let updatePlaylist = SlateTool.create(spec, { + name: 'Update Playlist', + key: 'update_playlist', + description: `Replace an existing Grafana playlist definition. Provide the full desired title, interval, and item list.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + playlistUid: z.string().describe('UID of the playlist to update'), + title: z.string().describe('Updated playlist title'), + interval: z.string().describe('Updated dashboard rotation interval, e.g. "10m"'), + items: z.array(playlistItemSchema).describe('Replacement playlist items'), + resourceVersion: z.string().optional().describe('Current resource version') + }) + ) + .output( + z.object({ + playlistUid: z.string().optional().describe('Updated playlist UID'), + title: z.string().optional().describe('Playlist title'), + interval: z.string().optional().describe('Rotation interval'), + items: z.array(z.any()).optional().describe('Playlist items'), + resourceVersion: z.string().optional().describe('Resource version') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId, + apiNamespace: ctx.config.apiNamespace + }); + + let playlist = mapPlaylist( + await client.updatePlaylist(ctx.input.playlistUid, { + name: ctx.input.title, + interval: ctx.input.interval, + items: ctx.input.items, + resourceVersion: ctx.input.resourceVersion + }) + ); + + return { + output: playlist, + message: `Playlist **${ctx.input.title}** updated.` + }; + }) + .build(); + +export let deletePlaylist = SlateTool.create(spec, { + name: 'Delete Playlist', + key: 'delete_playlist', + description: `Delete a Grafana playlist by UID.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + playlistUid: z.string().describe('UID of the playlist to delete') + }) + ) + .output( + z.object({ + status: z.string().optional().describe('Grafana deletion status'), + message: z.string().describe('Confirmation message') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId, + apiNamespace: ctx.config.apiNamespace + }); + + let result = await client.deletePlaylist(ctx.input.playlistUid); + + return { + output: { + status: result.status, + message: `Playlist ${ctx.input.playlistUid} deleted.` + }, + message: `Playlist **${ctx.input.playlistUid}** has been deleted.` + }; + }) + .build(); diff --git a/integrations/grafana/src/tools/manage-teams.ts b/integrations/grafana/src/tools/manage-teams.ts index e6ddd8bfee..e043f3ea33 100644 --- a/integrations/grafana/src/tools/manage-teams.ts +++ b/integrations/grafana/src/tools/manage-teams.ts @@ -101,6 +101,89 @@ export let createTeam = SlateTool.create(spec, { }) .build(); +export let getTeam = SlateTool.create(spec, { + name: 'Get Team', + key: 'get_team', + description: `Retrieve a Grafana team by ID, including its name, email, and timestamps.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + teamId: z.number().describe('ID of the team to retrieve') + }) + ) + .output( + z.object({ + teamId: z.number().describe('Team ID'), + name: z.string().describe('Team name'), + email: z.string().optional().describe('Team email'), + created: z.string().optional().describe('Creation timestamp'), + updated: z.string().optional().describe('Last update timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId + }); + + let team = await client.getTeam(ctx.input.teamId); + + return { + output: { + teamId: team.id, + name: team.name, + email: team.email, + created: team.created, + updated: team.updated + }, + message: `Retrieved team **${team.name || ctx.input.teamId}**.` + }; + }) + .build(); + +export let updateTeam = SlateTool.create(spec, { + name: 'Update Team', + key: 'update_team', + description: `Update a Grafana team's name and optional email address by ID.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + teamId: z.number().describe('ID of the team to update'), + name: z.string().describe('Updated team name'), + email: z.string().optional().describe('Updated team email') + }) + ) + .output( + z.object({ + message: z.string().describe('Confirmation message') + }) + ) + .handleInvocation(async ctx => { + let client = new GrafanaClient({ + instanceUrl: ctx.config.instanceUrl, + token: ctx.auth.token, + organizationId: ctx.config.organizationId + }); + + let result = await client.updateTeam(ctx.input.teamId, ctx.input.name, ctx.input.email); + + return { + output: { + message: result.message || `Team ${ctx.input.teamId} updated.` + }, + message: `Team **${ctx.input.teamId}** updated.` + }; + }) + .build(); + export let getTeamMembers = SlateTool.create(spec, { name: 'Get Team Members', key: 'get_team_members', diff --git a/integrations/groqcloud/README.md b/integrations/groqcloud/README.md index 018e7d8705..f110baae75 100644 --- a/integrations/groqcloud/README.md +++ b/integrations/groqcloud/README.md @@ -1,6 +1,6 @@ # Groq Cloud -Run AI inference on open-source language models with ultra-low latency using Groq's LPU hardware. Generate text via chat completions, produce structured JSON outputs, and perform function calling with built-in, remote (MCP), or local tools. Transcribe and translate audio using Whisper models, convert text to speech, and analyze images with multimodal vision models. Support chain-of-thought reasoning, content moderation with custom policies, and asynchronous batch processing for large-scale workloads. List and query available hosted models. +Run AI inference on open-source language models with ultra-low latency using Groq's LPU hardware. Generate text via chat completions and the Responses API, produce structured JSON outputs, and perform function calling with built-in, remote (MCP), or local tools. Transcribe and translate audio using Whisper models, convert text to speech, and analyze images with multimodal vision models. Support chain-of-thought reasoning, content moderation with custom policies, asynchronous chat-completions batch processing, and Groq file upload/download workflows. List and query available hosted models. ## License diff --git a/integrations/groqcloud/docs/SPEC.md b/integrations/groqcloud/docs/SPEC.md index 181aa46d62..ae19316a21 100644 --- a/integrations/groqcloud/docs/SPEC.md +++ b/integrations/groqcloud/docs/SPEC.md @@ -58,7 +58,11 @@ User prompts can include harmful or policy-violating content, and safeguard mode ### Batch Processing -The Batch API allows processing large-scale workloads asynchronously by submitting thousands of API requests as a batch, with 50% lower cost, no impact to standard rate limits, and a 24-hour to 7-day processing window. Supports chat completions, audio transcriptions, and audio translations. Input is provided as a JSONL file. +The Batch API allows processing large-scale workloads asynchronously by submitting many chat completion requests as a batch, with 50% lower cost, no impact to standard rate limits, and a 24-hour to 7-day processing window. Input is provided as a JSONL file uploaded through the Files API. Completed batches expose output and error file IDs that can be downloaded through the Files API. + +### Files + +GroqCloud exposes an OpenAI-compatible Files API for Batch API workflows. Files can be uploaded with purpose `batch`, listed, retrieved by ID, deleted, and downloaded. Downloaded file contents are returned as Slate attachments instead of inline output. ### Model Listing diff --git a/integrations/groqcloud/package.json b/integrations/groqcloud/package.json index 77c7fd7a61..68bd1e4bd2 100644 --- a/integrations/groqcloud/package.json +++ b/integrations/groqcloud/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/groqcloud/slate.json b/integrations/groqcloud/slate.json index a2e09079fa..124171858d 100644 --- a/integrations/groqcloud/slate.json +++ b/integrations/groqcloud/slate.json @@ -1,9 +1,10 @@ { "name": "@metorial/groqcloud", - "description": "Run AI inference on open-source language models with ultra-low latency using Groq's LPU hardware. Generate text via chat completions, produce structured JSON outputs, and perform function calling with built-in, remote (MCP), or local tools. Transcribe and translate audio using Whisper models, convert text to speech, and analyze images with multimodal vision models. Support chain-of-thought reasoning, content moderation with custom policies, and asynchronous batch processing for large-scale workloads. List and query available hosted models.", + "description": "Run AI inference on open-source language models with ultra-low latency using Groq's LPU hardware. Generate text via chat completions and the Responses API, produce structured JSON outputs, and perform function calling with built-in, remote (MCP), or local tools. Transcribe and translate audio using Whisper models, convert text to speech, and analyze images with multimodal vision models. Support chain-of-thought reasoning, content moderation with custom policies, asynchronous chat-completions batch processing, and Groq file upload/download workflows. List and query available hosted models.", "categories": ["apis-and-http-requests", "speech-recognition-and-synthesis"], "skills": [ "generate text completions", + "create model responses", "produce structured JSON outputs", "call functions and tools", "transcribe audio to text", @@ -12,6 +13,7 @@ "analyze images with vision", "moderate content", "batch process requests", + "manage batch files", "list available models" ], "logoUrl": "https://provider-logos.metorial-cdn.com/groqcloud.svg" diff --git a/integrations/groqcloud/src/index.ts b/integrations/groqcloud/src/index.ts index 0fec34f19d..82d6e73789 100644 --- a/integrations/groqcloud/src/index.ts +++ b/integrations/groqcloud/src/index.ts @@ -4,15 +4,21 @@ import { analyzeImage, cancelBatch, createBatch, + createResponse, + deleteFile, + downloadFile, generateSpeech, generateText, getBatch, + getFile, getModel, listBatches, + listFiles, listModels, moderateContent, transcribeAudio, - translateAudio + translateAudio, + uploadFile } from './tools'; import { batchStatus, inboundWebhook } from './triggers'; @@ -25,8 +31,14 @@ export let provider = Slate.create({ generateSpeech, analyzeImage, moderateContent, + createResponse, listModels, getModel, + uploadFile, + listFiles, + getFile, + downloadFile, + deleteFile, createBatch, getBatch, listBatches, diff --git a/integrations/groqcloud/src/lib/client.ts b/integrations/groqcloud/src/lib/client.ts index 1238bc0d5f..16c55671e1 100644 --- a/integrations/groqcloud/src/lib/client.ts +++ b/integrations/groqcloud/src/lib/client.ts @@ -1,7 +1,92 @@ import { createAxios } from 'slates'; +import { groqCloudApiError, groqCloudServiceError } from './errors'; let BASE_URL = 'https://api.groq.com/openai/v1'; +let sanitizeMultipartHeader = (value: string) => value.replace(/[\r\n"]/g, '_'); + +let appendMultipartField = ( + parts: Buffer[], + boundary: string, + name: string, + value: string | number | boolean +) => { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${sanitizeMultipartHeader(name)}"\r\n\r\n${String(value)}\r\n` + ) + ); +}; + +let appendMultipartFile = ( + parts: Buffer[], + boundary: string, + name: string, + filename: string, + content: Buffer, + mimeType: string +) => { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${sanitizeMultipartHeader(name)}"; filename="${sanitizeMultipartHeader(filename)}"\r\nContent-Type: ${mimeType}\r\n\r\n` + ) + ); + parts.push(content); + parts.push(Buffer.from('\r\n')); +}; + +let buildMultipartBody = (params: { + fields: Record; + fileField: string; + filename: string; + fileContent: Buffer; + mimeType?: string; +}) => { + let boundary = `----SlatesGroqCloudBoundary${Date.now()}${Math.random().toString(16).slice(2)}`; + let parts: Buffer[] = []; + + for (let [name, value] of Object.entries(params.fields)) { + if (value !== undefined) { + appendMultipartField(parts, boundary, name, value); + } + } + + appendMultipartFile( + parts, + boundary, + params.fileField, + params.filename, + params.fileContent, + params.mimeType ?? 'application/octet-stream' + ); + parts.push(Buffer.from(`--${boundary}--\r\n`)); + + return { + body: Buffer.concat(parts), + contentType: `multipart/form-data; boundary=${boundary}` + }; +}; + +let responseDataToBuffer = (data: unknown, failureMessage: string) => { + if (Buffer.isBuffer(data)) { + return data; + } + + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + + if (typeof data === 'string') { + return Buffer.from(data, 'binary'); + } + + throw groqCloudServiceError(failureMessage); +}; + export interface ChatMessage { role: 'system' | 'user' | 'assistant' | 'tool'; content: @@ -42,6 +127,42 @@ export interface ChatCompletionRequest { reasoningEffort?: string; } +export interface ResponseRequest { + model: string; + input: string | Record[]; + instructions?: string; + temperature?: number; + topP?: number; + maxOutputTokens?: number; + tools?: unknown[]; + toolChoice?: unknown; + text?: Record; + reasoning?: Record; + parallelToolCalls?: boolean; + serviceTier?: string; + truncation?: string; + store?: boolean; + metadata?: Record; + user?: string; +} + +export interface ResponseObject { + id: string; + object: string; + status: string; + created_at: number; + model?: string | null; + output?: unknown[]; + output_text?: string; + error?: unknown; + incomplete_details?: unknown; + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; +} + export interface ChatCompletionChoice { index: number; message: { @@ -153,8 +274,28 @@ export interface BatchListResponse { has_more?: boolean; } +export interface FileInfo { + id: string; + object: string; + bytes: number; + created_at: number; + filename: string; + purpose: string; +} + +export interface FilesListResponse { + object: string; + data: FileInfo[]; +} + +export interface FileDeleteResponse { + id: string; + object: string; + deleted: boolean; +} + export class Client { - private axios; + private axios: any; constructor(private token: string) { this.axios = createAxios({ @@ -164,6 +305,26 @@ export class Client { 'Content-Type': 'application/json' } }); + + this.axios.interceptors?.response?.use( + (response: any) => response, + (error: unknown) => Promise.reject(groqCloudApiError(error)) + ); + + for (let method of ['get', 'post', 'delete'] as const) { + let request = this.axios[method]?.bind(this.axios); + if (!request) { + continue; + } + + this.axios[method] = async (...args: any[]) => { + try { + return await request(...args); + } catch (error) { + throw groqCloudApiError(error); + } + }; + } } async createChatCompletion(req: ChatCompletionRequest): Promise { @@ -190,6 +351,31 @@ export class Client { return response.data; } + async createResponse(req: ResponseRequest): Promise { + let body: Record = { + model: req.model, + input: req.input + }; + + if (req.instructions !== undefined) body.instructions = req.instructions; + if (req.temperature !== undefined) body.temperature = req.temperature; + if (req.topP !== undefined) body.top_p = req.topP; + if (req.maxOutputTokens !== undefined) body.max_output_tokens = req.maxOutputTokens; + if (req.tools !== undefined) body.tools = req.tools; + if (req.toolChoice !== undefined) body.tool_choice = req.toolChoice; + if (req.text !== undefined) body.text = req.text; + if (req.reasoning !== undefined) body.reasoning = req.reasoning; + if (req.parallelToolCalls !== undefined) body.parallel_tool_calls = req.parallelToolCalls; + if (req.serviceTier !== undefined) body.service_tier = req.serviceTier; + if (req.truncation !== undefined) body.truncation = req.truncation; + if (req.store !== undefined) body.store = req.store; + if (req.metadata !== undefined) body.metadata = req.metadata; + if (req.user !== undefined) body.user = req.user; + + let response = await this.axios.post('/responses', body); + return response.data; + } + async listModels(): Promise { let response = await this.axios.get('/models'); return response.data; @@ -235,7 +421,11 @@ export class Client { return response.data; } - async createSpeech(req: SpeechRequest): Promise { + async createSpeech(req: SpeechRequest): Promise<{ + audioBase64: string; + contentType?: string; + sizeBytes: number; + }> { let body: Record = { model: req.model, input: req.input, @@ -250,10 +440,16 @@ export class Client { responseType: 'arraybuffer' }); - // Return base64 encoded audio + let buffer = responseDataToBuffer( + response.data, + 'Groq Cloud returned speech audio in an unsupported format.' + ); - let buffer = Buffer.from(response.data); - return buffer.toString('base64'); + return { + audioBase64: buffer.toString('base64'), + contentType: response.headers?.['content-type'], + sizeBytes: buffer.byteLength + }; } async createBatch(req: BatchRequest): Promise { @@ -284,38 +480,54 @@ export class Client { return response.data; } - async uploadFile( - content: string, - filename: string - ): Promise<{ - id: string; - object: string; - bytes: number; - created_at: number; - filename: string; - purpose: string; - }> { - // For batch files, we send as multipart form data - let boundary = `----SlatesBoundary${Date.now()}`; - let body = [ - `--${boundary}`, - `Content-Disposition: form-data; name="purpose"`, - '', - 'batch', - `--${boundary}`, - `Content-Disposition: form-data; name="file"; filename="${filename}"`, - 'Content-Type: application/jsonl', - '', - content, - `--${boundary}--` - ].join('\r\n'); + async uploadFile(content: string, filename: string): Promise { + let multipart = buildMultipartBody({ + fields: { purpose: 'batch' }, + fileField: 'file', + filename, + fileContent: Buffer.from(content, 'utf8'), + mimeType: 'application/jsonl' + }); - let response = await this.axios.post('/files', body, { + let response = await this.axios.post('/files', multipart.body, { headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Type': multipart.contentType, Authorization: `Bearer ${this.token}` } }); return response.data; } + + async listFiles(): Promise { + let response = await this.axios.get('/files'); + return response.data; + } + + async getFile(fileId: string): Promise { + let response = await this.axios.get(`/files/${fileId}`); + return response.data; + } + + async deleteFile(fileId: string): Promise { + let response = await this.axios.delete(`/files/${fileId}`); + return response.data; + } + + async downloadFile(fileId: string): Promise<{ + content: string; + contentType?: string; + sizeBytes: number; + }> { + let response = await this.axios.get(`/files/${fileId}/content`, { + responseType: 'text' + }); + let content = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data); + + return { + content, + contentType: response.headers?.['content-type'], + sizeBytes: Buffer.byteLength(content, 'utf8') + }; + } } diff --git a/integrations/groqcloud/src/lib/errors.ts b/integrations/groqcloud/src/lib/errors.ts new file mode 100644 index 0000000000..6c03efc12c --- /dev/null +++ b/integrations/groqcloud/src/lib/errors.ts @@ -0,0 +1,89 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractGroqCloudMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + let groqError = isRecord(data.error) ? data.error : undefined; + for (let key of ['message', 'type', 'code', 'param']) { + addDetail(details, groqError?.[key]); + } + + for (let key of ['message', 'error_description', 'error']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getGroqCloudErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let groqCloudServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let groqCloudApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getGroqCloudErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = groqCloudServiceError( + `Groq Cloud API ${operation} failed: ${statusLabel}${extractGroqCloudMessage(error)}` + ); + serviceError.data.reason = 'groqcloud_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/groqcloud/src/tools.schema.test.ts b/integrations/groqcloud/src/tools.schema.test.ts new file mode 100644 index 0000000000..bf75444186 --- /dev/null +++ b/integrations/groqcloud/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Groq Cloud tool input schemas', provider.actions); diff --git a/integrations/groqcloud/src/tools/create-batch.ts b/integrations/groqcloud/src/tools/create-batch.ts index a43ed0d465..1204653f86 100644 --- a/integrations/groqcloud/src/tools/create-batch.ts +++ b/integrations/groqcloud/src/tools/create-batch.ts @@ -1,20 +1,22 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { groqCloudServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createBatch = SlateTool.create(spec, { name: 'Create Batch', key: 'create_batch', - description: `Create a batch processing job on GroqCloud. Upload a JSONL file of API requests to be processed asynchronously at 50% lower cost. Supports chat completions, audio transcriptions, and translations. Each line in the JSONL content should contain a request object with custom_id, method, url, and body fields.`, + description: `Create a batch processing job on GroqCloud. Use an uploaded batch JSONL file or provide JSONL content to upload and create the batch in one step. Each line should contain a request object with custom_id, method, url, and body fields for the chat completions endpoint.`, instructions: [ - 'Provide JSONL content where each line is a JSON object with: custom_id, method ("POST"), url (e.g., "/v1/chat/completions"), and body (the request payload).', + 'Provide exactly one of inputFileId or jsonlContent.', + 'JSONL lines must be objects with custom_id, method ("POST"), url "/v1/chat/completions", and body.', 'The batch will be processed within the specified completion window (24h to 7d).', - 'Use the Get Batch tool to poll for completion status.' + 'Use Get Batch to poll for status and Download File to retrieve outputFileId or errorFileId contents.' ], constraints: [ 'Maximum file size: 100 MB', - 'Supported endpoints: /v1/chat/completions, /v1/audio/transcriptions, /v1/audio/translations', + 'Supported endpoint: /v1/chat/completions', 'Completion window: 24h to 7d' ], tags: { @@ -23,13 +25,25 @@ export let createBatch = SlateTool.create(spec, { }) .input( z.object({ + inputFileId: z + .string() + .optional() + .describe( + 'Existing uploaded batch file ID. Provide either inputFileId or jsonlContent' + ), jsonlContent: z .string() + .optional() .describe( - 'JSONL content where each line is a batch request object with custom_id, method, url, and body fields' + 'JSONL content to upload before creating the batch. Provide either jsonlContent or inputFileId' ), + filename: z + .string() + .optional() + .default('batch_input.jsonl') + .describe('Filename to use when uploading jsonlContent'), endpoint: z - .enum(['/v1/chat/completions', '/v1/audio/transcriptions', '/v1/audio/translations']) + .enum(['/v1/chat/completions']) .default('/v1/chat/completions') .describe('API endpoint for the batch requests'), completionWindow: z @@ -54,13 +68,28 @@ export let createBatch = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = new Client(ctx.auth.token); + let hasInputFileId = + ctx.input.inputFileId !== undefined && ctx.input.inputFileId.trim().length > 0; + let hasJsonlContent = + ctx.input.jsonlContent !== undefined && ctx.input.jsonlContent.trim().length > 0; + + if (hasInputFileId === hasJsonlContent) { + throw groqCloudServiceError( + 'Provide exactly one of inputFileId or jsonlContent when creating a batch.' + ); + } + + let inputFileId = ctx.input.inputFileId; - ctx.info('Uploading batch file...'); - let file = await client.uploadFile(ctx.input.jsonlContent, 'batch_input.jsonl'); - ctx.info(`File uploaded: ${file.id}`); + if (hasJsonlContent) { + ctx.info('Uploading batch file...'); + let file = await client.uploadFile(ctx.input.jsonlContent!, ctx.input.filename); + ctx.info(`File uploaded: ${file.id}`); + inputFileId = file.id; + } let batch = await client.createBatch({ - inputFileId: file.id, + inputFileId: inputFileId!, endpoint: ctx.input.endpoint, completionWindow: ctx.input.completionWindow, metadata: ctx.input.metadata diff --git a/integrations/groqcloud/src/tools/create-response.ts b/integrations/groqcloud/src/tools/create-response.ts new file mode 100644 index 0000000000..55b922493d --- /dev/null +++ b/integrations/groqcloud/src/tools/create-response.ts @@ -0,0 +1,237 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { groqCloudServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let responseMessageSchema = z.object({ + role: z + .enum(['system', 'developer', 'user', 'assistant']) + .describe('Role of the response input item'), + content: z.string().describe('Text content for this response input item') +}); + +let responseOutputSchema = z.object({ + responseId: z.string().describe('Unique identifier for this response'), + status: z.string().describe('Response status, such as completed, failed, or incomplete'), + model: z.string().nullable().describe('Model used for the response'), + outputText: z.string().nullable().describe('Generated text output, when present'), + outputItems: z.array(z.unknown()).describe('Raw response output items'), + inputTokens: z.number().describe('Number of input tokens used'), + outputTokens: z.number().describe('Number of output tokens generated'), + totalTokens: z.number().describe('Total tokens used') +}); + +let buildInput = (params: { + input?: string; + messages?: Array<{ role: string; content: string }>; +}) => { + let hasInput = params.input !== undefined && params.input.trim().length > 0; + let hasMessages = params.messages !== undefined && params.messages.length > 0; + + if (hasInput === hasMessages) { + throw groqCloudServiceError( + 'Provide exactly one of input or messages for create_response.' + ); + } + + return hasInput ? params.input! : params.messages!; +}; + +let buildTextConfig = (responseFormat?: string, jsonSchema?: Record) => { + if (!responseFormat) { + return undefined; + } + + if (responseFormat === 'text') { + return { format: { type: 'text' } }; + } + + if (responseFormat === 'json_object') { + return { format: { type: 'json_object' } }; + } + + if (!jsonSchema) { + throw groqCloudServiceError( + 'jsonSchema is required when responseFormat is "json_schema".' + ); + } + + let hasFullConfig = typeof jsonSchema.name === 'string' && jsonSchema.schema !== undefined; + + return { + format: { + type: 'json_schema', + ...(hasFullConfig + ? jsonSchema + : { + name: 'response_schema', + schema: jsonSchema, + strict: true + }) + } + }; +}; + +let mapResponseOutput = (result: { + id: string; + status: string; + model?: string | null; + output?: unknown[]; + output_text?: string; + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; +}) => { + let outputText: string | null = + typeof result.output_text === 'string' ? result.output_text : null; + + if (!outputText && result.output) { + for (let item of result.output) { + if (typeof item !== 'object' || item === null) { + continue; + } + + let typedItem = item as { + type?: string; + content?: Array<{ type?: string; text?: unknown }>; + }; + if (typedItem.type !== 'message' || !Array.isArray(typedItem.content)) { + continue; + } + + for (let content of typedItem.content) { + if (content.type === 'output_text' && typeof content.text === 'string') { + outputText = content.text; + break; + } + } + + if (outputText) { + break; + } + } + } + + let inputTokens = result.usage?.input_tokens ?? 0; + let outputTokens = result.usage?.output_tokens ?? 0; + + return { + responseId: result.id, + status: result.status, + model: result.model ?? null, + outputText, + outputItems: result.output ?? [], + inputTokens, + outputTokens, + totalTokens: result.usage?.total_tokens ?? inputTokens + outputTokens + }; +}; + +export let createResponse = SlateTool.create(spec, { + name: 'Create Response', + key: 'create_response', + description: `Generate a model response using Groq's Responses API. Supports simple text input, message arrays, structured outputs, tool definitions, and reasoning controls through the OpenAI-compatible /responses endpoint.`, + instructions: [ + 'Provide either input for a single-turn prompt or messages for a message-array request.', + 'For structured JSON output, set responseFormat to "json_object" or "json_schema".', + 'For reasoning models, set reasoningEffort to a value supported by the selected model.' + ], + tags: { + readOnly: true, + destructive: false + } +}) + .input( + z.object({ + model: z.string().describe('Model ID to use, such as "openai/gpt-oss-20b"'), + input: z + .string() + .optional() + .describe('Single-turn text input. Provide either input or messages, not both'), + messages: z + .array(responseMessageSchema) + .optional() + .describe('Message-array input. Provide either messages or input, not both'), + instructions: z.string().optional().describe('System or developer instructions'), + temperature: z.number().min(0).max(2).optional().describe('Sampling temperature'), + topP: z.number().min(0).max(1).optional().describe('Nucleus sampling parameter'), + maxOutputTokens: z.number().optional().describe('Maximum output tokens to generate'), + tools: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe('Tools available to the model, such as function definitions'), + toolChoice: z + .unknown() + .optional() + .describe('Tool-choice control, such as "auto", "none", "required", or an object'), + responseFormat: z + .enum(['text', 'json_object', 'json_schema']) + .optional() + .describe('Response text format'), + jsonSchema: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'JSON Schema or full json_schema config when responseFormat is "json_schema"' + ), + reasoningEffort: z + .enum(['none', 'default', 'low', 'medium', 'high']) + .optional() + .describe('Reasoning effort for models that support reasoning'), + parallelToolCalls: z.boolean().optional().describe('Enable parallel tool calls'), + serviceTier: z + .enum(['auto', 'default', 'flex']) + .optional() + .describe('Latency tier for processing the request'), + truncation: z + .enum(['auto', 'disabled']) + .optional() + .describe('Context truncation behavior'), + store: z + .boolean() + .optional() + .describe('Response storage flag. Groq currently supports false or null'), + metadata: z.record(z.string(), z.string()).optional().describe('Response metadata'), + user: z.string().optional().describe('End-user identifier for monitoring') + }) + ) + .output(responseOutputSchema) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth.token); + let text = buildTextConfig(ctx.input.responseFormat, ctx.input.jsonSchema); + let reasoning = ctx.input.reasoningEffort + ? { effort: ctx.input.reasoningEffort } + : undefined; + + let result = await client.createResponse({ + model: ctx.input.model, + input: buildInput({ + input: ctx.input.input, + messages: ctx.input.messages + }), + instructions: ctx.input.instructions, + temperature: ctx.input.temperature, + topP: ctx.input.topP, + maxOutputTokens: ctx.input.maxOutputTokens, + tools: ctx.input.tools, + toolChoice: ctx.input.toolChoice, + text, + reasoning, + parallelToolCalls: ctx.input.parallelToolCalls, + serviceTier: ctx.input.serviceTier, + truncation: ctx.input.truncation, + store: ctx.input.store, + metadata: ctx.input.metadata, + user: ctx.input.user + }); + let output = mapResponseOutput(result); + + return { + output, + message: `Response **${result.id}** generated using **${output.model ?? ctx.input.model}**. Status: ${output.status}. Tokens: ${output.totalTokens}.` + }; + }) + .build(); diff --git a/integrations/groqcloud/src/tools/generate-speech.ts b/integrations/groqcloud/src/tools/generate-speech.ts index 855c79d7bd..dc5361919a 100644 --- a/integrations/groqcloud/src/tools/generate-speech.ts +++ b/integrations/groqcloud/src/tools/generate-speech.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; @@ -10,7 +10,7 @@ export let generateSpeech = SlateTool.create(spec, { instructions: [ 'Specify the text to convert, a model, and a voice.', 'For expressive output with the Orpheus English model, embed vocal direction tags in the input text (e.g., "[cheerful] Hello there!").', - 'Returns base64-encoded audio data.' + 'Returns generated audio as a Slate attachment.' ], tags: { readOnly: true @@ -32,7 +32,7 @@ export let generateSpeech = SlateTool.create(spec, { voice: z.string().describe('Voice to use for speech generation'), responseFormat: z .enum(['flac', 'mp3', 'mulaw', 'ogg', 'wav']) - .optional() + .default('wav') .describe('Audio output format'), sampleRate: z .enum(['8000', '16000', '22050', '24000', '32000', '44100', '48000']) @@ -48,14 +48,16 @@ export let generateSpeech = SlateTool.create(spec, { ) .output( z.object({ - audioBase64: z.string().describe('Base64-encoded audio data'), - format: z.string().describe('Audio format of the output') + format: z.string().describe('Audio format of the output'), + mimeType: z.string().describe('MIME type of the returned audio attachment'), + sizeBytes: z.number().describe('Size of the generated audio in bytes'), + attachmentCount: z.number().describe('Number of audio attachments returned') }) ) .handleInvocation(async ctx => { let client = new Client(ctx.auth.token); - let audioBase64 = await client.createSpeech({ + let audio = await client.createSpeech({ model: ctx.input.model, input: ctx.input.text, voice: ctx.input.voice, @@ -64,13 +66,17 @@ export let generateSpeech = SlateTool.create(spec, { speed: ctx.input.speed }); - let format = ctx.input.responseFormat ?? 'wav'; + let format = ctx.input.responseFormat; + let mimeType = audio.contentType ?? `audio/${format === 'mulaw' ? 'mulaw' : format}`; return { output: { - audioBase64, - format + format, + mimeType, + sizeBytes: audio.sizeBytes, + attachmentCount: 1 }, + attachments: [createBase64Attachment(audio.audioBase64, mimeType)], message: `Generated speech audio in **${format}** format using **${ctx.input.model}** with voice "${ctx.input.voice}".` }; }) diff --git a/integrations/groqcloud/src/tools/index.ts b/integrations/groqcloud/src/tools/index.ts index 95cefef668..79b4bd2749 100644 --- a/integrations/groqcloud/src/tools/index.ts +++ b/integrations/groqcloud/src/tools/index.ts @@ -1,12 +1,14 @@ export * from './analyze-image'; export * from './cancel-batch'; export * from './create-batch'; +export * from './create-response'; export * from './generate-speech'; export * from './generate-text'; export * from './get-batch'; export * from './get-model'; export * from './list-batches'; export * from './list-models'; +export * from './manage-files'; export * from './moderate-content'; export * from './transcribe-audio'; export * from './translate-audio'; diff --git a/integrations/groqcloud/src/tools/manage-files.ts b/integrations/groqcloud/src/tools/manage-files.ts new file mode 100644 index 0000000000..e6ef7a2ee6 --- /dev/null +++ b/integrations/groqcloud/src/tools/manage-files.ts @@ -0,0 +1,196 @@ +import { createTextAttachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let fileOutputSchema = z.object({ + fileId: z.string().describe('File identifier'), + filename: z.string().describe('Filename stored by Groq Cloud'), + bytes: z.number().describe('File size in bytes'), + purpose: z.string().describe('File purpose, such as batch or batch_output'), + createdAt: z.number().describe('Unix timestamp when the file was created') +}); + +let mapFile = (file: { + id: string; + filename: string; + bytes: number; + purpose: string; + created_at: number; +}) => ({ + fileId: file.id, + filename: file.filename, + bytes: file.bytes, + purpose: file.purpose, + createdAt: file.created_at +}); + +export let uploadFile = SlateTool.create(spec, { + name: 'Upload File', + key: 'upload_file', + description: `Upload a JSONL file to Groq Cloud for Batch API processing. Uploaded files use purpose "batch" and can be passed to Create Batch by file ID.`, + instructions: [ + 'Provide JSONL content matching Groq Batch API request format.', + 'The Batch API accepts .jsonl files up to 100 MB.' + ], + constraints: ['Only purpose "batch" is supported by Groq file uploads.'], + tags: { + readOnly: false, + destructive: false + } +}) + .input( + z.object({ + filename: z + .string() + .default('batch_input.jsonl') + .describe('Filename to assign to the uploaded JSONL file'), + jsonlContent: z + .string() + .describe('JSONL content where each line is a valid batch request object') + }) + ) + .output(fileOutputSchema) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth.token); + let file = await client.uploadFile(ctx.input.jsonlContent, ctx.input.filename); + let output = mapFile(file); + + return { + output, + message: `Uploaded file **${output.filename}** (${output.fileId}) for Groq Batch API processing.` + }; + }) + .build(); + +export let listFiles = SlateTool.create(spec, { + name: 'List Files', + key: 'list_files', + description: `List files uploaded to Groq Cloud, including batch input and batch output files.`, + tags: { + readOnly: true, + destructive: false + } +}) + .input(z.object({})) + .output( + z.object({ + files: z.array(fileOutputSchema).describe('Uploaded files'), + totalCount: z.number().describe('Number of files returned') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth.token); + let result = await client.listFiles(); + let files = result.data.map(mapFile); + + return { + output: { + files, + totalCount: files.length + }, + message: `Found **${files.length}** Groq Cloud file(s).` + }; + }) + .build(); + +export let getFile = SlateTool.create(spec, { + name: 'Get File', + key: 'get_file', + description: `Retrieve metadata for a Groq Cloud file by ID.`, + tags: { + readOnly: true, + destructive: false + } +}) + .input( + z.object({ + fileId: z.string().describe('File ID to retrieve') + }) + ) + .output(fileOutputSchema) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth.token); + let file = await client.getFile(ctx.input.fileId); + let output = mapFile(file); + + return { + output, + message: `File **${output.filename}** (${output.fileId}), ${output.bytes} bytes, purpose: ${output.purpose}.` + }; + }) + .build(); + +export let downloadFile = SlateTool.create(spec, { + name: 'Download File', + key: 'download_file', + description: `Download the content of a Groq Cloud file, such as a batch output JSONL file. File content is returned as a Slate text attachment.`, + tags: { + readOnly: true, + destructive: false + } +}) + .input( + z.object({ + fileId: z.string().describe('File ID whose content should be downloaded') + }) + ) + .output( + z.object({ + fileId: z.string().describe('Downloaded file ID'), + sizeBytes: z.number().describe('Downloaded content size in bytes'), + mimeType: z.string().optional().describe('Attachment MIME type'), + attachmentCount: z.number().describe('Number of attachments returned') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth.token); + let file = await client.downloadFile(ctx.input.fileId); + let mimeType = file.contentType ?? 'application/jsonl'; + + return { + output: { + fileId: ctx.input.fileId, + sizeBytes: file.sizeBytes, + mimeType, + attachmentCount: 1 + }, + attachments: [createTextAttachment(file.content, mimeType)], + message: `Downloaded file **${ctx.input.fileId}** (${file.sizeBytes} bytes).` + }; + }) + .build(); + +export let deleteFile = SlateTool.create(spec, { + name: 'Delete File', + key: 'delete_file', + description: `Delete a Groq Cloud file by ID.`, + tags: { + readOnly: false, + destructive: true + } +}) + .input( + z.object({ + fileId: z.string().describe('File ID to delete') + }) + ) + .output( + z.object({ + fileId: z.string().describe('Deleted file ID'), + deleted: z.boolean().describe('Whether the file was deleted') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth.token); + let result = await client.deleteFile(ctx.input.fileId); + + return { + output: { + fileId: result.id, + deleted: result.deleted + }, + message: `Deleted file **${result.id}**.` + }; + }) + .build(); diff --git a/integrations/groqcloud/src/tools/translate-audio.ts b/integrations/groqcloud/src/tools/translate-audio.ts index b7775608c7..8db5b3925b 100644 --- a/integrations/groqcloud/src/tools/translate-audio.ts +++ b/integrations/groqcloud/src/tools/translate-audio.ts @@ -20,8 +20,8 @@ export let translateAudio = SlateTool.create(spec, { audioUrl: z.string().describe('URL of the audio file to translate'), model: z .enum(['whisper-large-v3', 'whisper-large-v3-turbo']) - .default('whisper-large-v3-turbo') - .describe('Whisper model to use'), + .default('whisper-large-v3') + .describe('Whisper model to use. whisper-large-v3 is the translation-capable default'), prompt: z .string() .optional() diff --git a/integrations/groqcloud/vitest.config.ts b/integrations/groqcloud/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/groqcloud/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/gumroad/package.json b/integrations/gumroad/package.json index 212b7e86f7..f568f01b03 100644 --- a/integrations/gumroad/package.json +++ b/integrations/gumroad/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/gumroad/src/auth.ts b/integrations/gumroad/src/auth.ts index d3464be9ec..e5501bf22f 100644 --- a/integrations/gumroad/src/auth.ts +++ b/integrations/gumroad/src/auth.ts @@ -1,5 +1,21 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { GumroadClient } from './lib/client'; +import { gumroadApiError, gumroadServiceError } from './lib/errors'; + +let getGumroadProfile = async (token: string) => { + let client = new GumroadClient({ token }); + let user = await client.getUser(); + + return { + profile: { + id: user.user_id || user.id, + email: user.email, + name: user.name || user.display_name, + imageUrl: user.profile_url + } + }; +}; export let auth = SlateAuth.create() .output( @@ -13,9 +29,15 @@ export let auth = SlateAuth.create() key: 'oauth', scopes: [ + { + title: 'View Profile', + description: 'Read public profile and product information', + scope: 'view_profile' + }, { title: 'Edit Products', - description: 'Create and manage products, offer codes, variants, and custom fields', + description: + 'Create and manage products, offer codes, variants, custom fields, and files', scope: 'edit_products' }, { @@ -23,15 +45,25 @@ export let auth = SlateAuth.create() description: 'View sales data, subscribers, and transaction information', scope: 'view_sales' }, + { + title: 'Edit Sales', + description: 'Refund sales and resend purchase receipts', + scope: 'edit_sales' + }, { title: 'Mark Sales as Shipped', description: 'Mark sales as shipped with tracking information', scope: 'mark_sales_as_shipped' }, { - title: 'Revenue Share', - description: 'Revenue sharing access', - scope: 'revenue_share' + title: 'View Payouts', + description: 'View payout information', + scope: 'view_payouts' + }, + { + title: 'View Tax Data', + description: 'View annual earnings and tax data', + scope: 'view_tax_data' } ], @@ -52,40 +84,33 @@ export let auth = SlateAuth.create() handleCallback: async ctx => { let axios = createAxios(); - let response = await axios.post('https://gumroad.com/oauth/token', { - code: ctx.code, - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - redirect_uri: ctx.redirectUri, - grant_type: 'authorization_code' - }); + let response: any; + try { + response = await axios.post('https://gumroad.com/oauth/token', { + code: ctx.code, + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + redirect_uri: ctx.redirectUri, + grant_type: 'authorization_code' + }); + } catch (error) { + throw gumroadApiError(error, 'exchange OAuth code'); + } + + let token = response.data?.access_token; + if (!token) { + throw gumroadServiceError('Gumroad OAuth did not return an access token.'); + } return { output: { - token: response.data.access_token + token } }; }, getProfile: async (ctx: { output: { token: string }; input: {}; scopes: string[] }) => { - let axios = createAxios({ - baseURL: 'https://api.gumroad.com/v2', - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); - - let response = await axios.get('/user'); - let user = response.data.user; - - return { - profile: { - id: user.user_id, - email: user.email, - name: user.name || user.display_name, - imageUrl: user.profile_url - } - }; + return getGumroadProfile(ctx.output.token); } }) .addTokenAuth({ @@ -108,23 +133,6 @@ export let auth = SlateAuth.create() }, getProfile: async (ctx: { output: { token: string }; input: { token: string } }) => { - let axios = createAxios({ - baseURL: 'https://api.gumroad.com/v2', - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); - - let response = await axios.get('/user'); - let user = response.data.user; - - return { - profile: { - id: user.user_id, - email: user.email, - name: user.name || user.display_name, - imageUrl: user.profile_url - } - }; + return getGumroadProfile(ctx.output.token); } }); diff --git a/integrations/gumroad/src/index.ts b/integrations/gumroad/src/index.ts index 0136595b8d..d920dca290 100644 --- a/integrations/gumroad/src/index.ts +++ b/integrations/gumroad/src/index.ts @@ -1,10 +1,15 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + getEarnings, + getPayout, getProduct, getSale, getSubscriber, + getUpcomingPayouts, getUser, + listCategories, + listPayouts, listProducts, listSales, listSubscribers, @@ -20,6 +25,7 @@ import { saleEvents, subscriptionEvents } from './triggers'; export let provider = Slate.create({ spec, tools: [ + listCategories, listProducts, getProduct, manageProduct, @@ -32,6 +38,10 @@ export let provider = Slate.create({ listSubscribers, getSubscriber, manageLicense, + listPayouts, + getPayout, + getUpcomingPayouts, + getEarnings, getUser ], triggers: [saleEvents, subscriptionEvents] diff --git a/integrations/gumroad/src/lib/client.ts b/integrations/gumroad/src/lib/client.ts index e3787861ea..2ce7f7c9f3 100644 --- a/integrations/gumroad/src/lib/client.ts +++ b/integrations/gumroad/src/lib/client.ts @@ -1,4 +1,69 @@ -import { createAxios } from 'slates'; +import { createAxios, setIfDefined } from 'slates'; +import { gumroadApiError, gumroadServiceError } from './errors'; + +type GumroadData = Record; + +export type ProductMutationParams = { + nativeType?: string; + name?: string; + description?: string; + customPermalink?: string; + priceCents?: number; + currency?: string; + subscriptionDuration?: string; + customizablePrice?: boolean; + suggestedPriceCents?: number; + maxPurchaseCount?: number; + quantityEnabled?: boolean; + isAdult?: boolean; + displayProductReviews?: boolean; + shouldShowSalesCount?: boolean; + category?: string; + taxonomyId?: number; + tags?: string[]; + customReceipt?: string; + customSummary?: string; + customHtml?: string; + coverIds?: string[]; + richContent?: unknown[]; + files?: unknown[]; + hasSameRichContentForAllVariants?: boolean; +}; + +let buildProductBody = (params: ProductMutationParams) => { + let body: Record = {}; + + setIfDefined(body, 'native_type', params.nativeType); + setIfDefined(body, 'name', params.name); + setIfDefined(body, 'description', params.description); + setIfDefined(body, 'custom_permalink', params.customPermalink); + setIfDefined(body, 'price', params.priceCents); + setIfDefined(body, 'price_currency_type', params.currency); + setIfDefined(body, 'subscription_duration', params.subscriptionDuration); + setIfDefined(body, 'customizable_price', params.customizablePrice); + setIfDefined(body, 'suggested_price_cents', params.suggestedPriceCents); + setIfDefined(body, 'max_purchase_count', params.maxPurchaseCount); + setIfDefined(body, 'quantity_enabled', params.quantityEnabled); + setIfDefined(body, 'is_adult', params.isAdult); + setIfDefined(body, 'display_product_reviews', params.displayProductReviews); + setIfDefined(body, 'should_show_sales_count', params.shouldShowSalesCount); + setIfDefined(body, 'category', params.category); + setIfDefined(body, 'taxonomy_id', params.taxonomyId); + setIfDefined(body, 'tags', params.tags); + setIfDefined(body, 'custom_receipt', params.customReceipt); + setIfDefined(body, 'custom_summary', params.customSummary); + setIfDefined(body, 'custom_html', params.customHtml); + setIfDefined(body, 'cover_ids', params.coverIds); + setIfDefined(body, 'rich_content', params.richContent); + setIfDefined(body, 'files', params.files); + setIfDefined( + body, + 'has_same_rich_content_for_all_variants', + params.hasSameRichContentForAllVariants + ); + + return body; +}; export class GumroadClient { private axios: ReturnType; @@ -12,49 +77,122 @@ export class GumroadClient { }); } - // ── User ────────────────────────────────────────────────── + private async request(operation: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw gumroadApiError(error, operation); + } + } + + private unwrap(data: GumroadData | undefined, operation: string): T { + if (!data || typeof data !== 'object') { + throw gumroadServiceError( + `Gumroad API ${operation} failed: Response did not include a JSON object.` + ); + } + + if (data.success === false) { + throw gumroadServiceError( + `Gumroad API ${operation} failed: ${data.message || 'Request was not successful.'}` + ); + } + + return data as T; + } + + // -- User -------------------------------------------------- async getUser(): Promise { - let response = await this.axios.get('/user'); - return response.data.user; + return this.request('get user', async () => { + let response = await this.axios.get('/user'); + return this.unwrap(response.data, 'get user').user; + }); } - // ── Products ────────────────────────────────────────────── + // -- Products ---------------------------------------------- - async listProducts(): Promise { - let response = await this.axios.get('/products'); - return response.data.products || []; + async listCategories(): Promise { + return this.request('list categories', async () => { + let response = await this.axios.get('/categories'); + return this.unwrap(response.data, 'list categories').categories || []; + }); + } + + async listProducts(params?: { + pageKey?: string; + }): Promise<{ products: any[]; nextPageKey?: string; nextPageUrl?: string }> { + return this.request('list products', async () => { + let queryParams: Record = {}; + if (params?.pageKey) queryParams.page_key = params.pageKey; + + let response = await this.axios.get('/products', { params: queryParams }); + let data = this.unwrap(response.data, 'list products'); + + return { + products: data.products || [], + nextPageKey: data.next_page_key, + nextPageUrl: data.next_page_url + }; + }); } async getProduct(productId: string): Promise { - let response = await this.axios.get(`/products/${productId}`); - return response.data.product; + return this.request('get product', async () => { + let response = await this.axios.get(`/products/${productId}`); + return this.unwrap(response.data, 'get product').product; + }); + } + + async createProduct(params: ProductMutationParams): Promise { + return this.request('create product', async () => { + let response = await this.axios.post('/products', buildProductBody(params)); + return this.unwrap(response.data, 'create product').product; + }); + } + + async updateProduct(productId: string, params: ProductMutationParams): Promise { + return this.request('update product', async () => { + let response = await this.axios.put(`/products/${productId}`, buildProductBody(params)); + return this.unwrap(response.data, 'update product').product; + }); } async deleteProduct(productId: string): Promise { - await this.axios.delete(`/products/${productId}`); + await this.request('delete product', async () => { + let response = await this.axios.delete(`/products/${productId}`); + this.unwrap(response.data, 'delete product'); + }); } async enableProduct(productId: string): Promise { - let response = await this.axios.put(`/products/${productId}/enable`); - return response.data.product; + return this.request('enable product', async () => { + let response = await this.axios.put(`/products/${productId}/enable`); + return this.unwrap(response.data, 'enable product').product; + }); } async disableProduct(productId: string): Promise { - let response = await this.axios.put(`/products/${productId}/disable`); - return response.data.product; + return this.request('disable product', async () => { + let response = await this.axios.put(`/products/${productId}/disable`); + return this.unwrap(response.data, 'disable product').product; + }); } - // ── Offer Codes ─────────────────────────────────────────── + // -- Offer Codes ------------------------------------------- async listOfferCodes(productId: string): Promise { - let response = await this.axios.get(`/products/${productId}/offer_codes`); - return response.data.offer_codes || []; + return this.request('list offer codes', async () => { + let response = await this.axios.get(`/products/${productId}/offer_codes`); + return this.unwrap(response.data, 'list offer codes').offer_codes || []; + }); } async getOfferCode(productId: string, offerCodeId: string): Promise { - let response = await this.axios.get(`/products/${productId}/offer_codes/${offerCodeId}`); - return response.data.offer_code; + return this.request('get offer code', async () => { + let response = await this.axios.get(`/products/${productId}/offer_codes/${offerCodeId}`); + return this.unwrap(response.data, 'get offer code').offer_code; + }); } async createOfferCode( @@ -67,14 +205,16 @@ export class GumroadClient { universal?: boolean; } ): Promise { - let response = await this.axios.post(`/products/${productId}/offer_codes`, { - name: params.name, - amount_off: params.amountOff, - offer_type: params.offerType || 'cents', - max_purchase_count: params.maxPurchaseCount || 0, - universal: params.universal ? 'true' : 'false' + return this.request('create offer code', async () => { + let response = await this.axios.post(`/products/${productId}/offer_codes`, { + name: params.name, + amount_off: params.amountOff, + offer_type: params.offerType || 'cents', + max_purchase_count: params.maxPurchaseCount ?? 0, + universal: params.universal ? 'true' : 'false' + }); + return this.unwrap(response.data, 'create offer code').offer_code; }); - return response.data.offer_code; } async updateOfferCode( @@ -84,35 +224,51 @@ export class GumroadClient { maxPurchaseCount?: number; } ): Promise { - let response = await this.axios.put(`/products/${productId}/offer_codes/${offerCodeId}`, { - max_purchase_count: params.maxPurchaseCount + return this.request('update offer code', async () => { + let response = await this.axios.put( + `/products/${productId}/offer_codes/${offerCodeId}`, + { + max_purchase_count: params.maxPurchaseCount + } + ); + return this.unwrap(response.data, 'update offer code').offer_code; }); - return response.data.offer_code; } async deleteOfferCode(productId: string, offerCodeId: string): Promise { - await this.axios.delete(`/products/${productId}/offer_codes/${offerCodeId}`); + await this.request('delete offer code', async () => { + let response = await this.axios.delete( + `/products/${productId}/offer_codes/${offerCodeId}` + ); + this.unwrap(response.data, 'delete offer code'); + }); } - // ── Variant Categories ──────────────────────────────────── + // -- Variant Categories ------------------------------------ async listVariantCategories(productId: string): Promise { - let response = await this.axios.get(`/products/${productId}/variant_categories`); - return response.data.variant_categories || []; + return this.request('list variant categories', async () => { + let response = await this.axios.get(`/products/${productId}/variant_categories`); + return this.unwrap(response.data, 'list variant categories').variant_categories || []; + }); } async getVariantCategory(productId: string, variantCategoryId: string): Promise { - let response = await this.axios.get( - `/products/${productId}/variant_categories/${variantCategoryId}` - ); - return response.data.variant_category; + return this.request('get variant category', async () => { + let response = await this.axios.get( + `/products/${productId}/variant_categories/${variantCategoryId}` + ); + return this.unwrap(response.data, 'get variant category').variant_category; + }); } async createVariantCategory(productId: string, title: string): Promise { - let response = await this.axios.post(`/products/${productId}/variant_categories`, { - title + return this.request('create variant category', async () => { + let response = await this.axios.post(`/products/${productId}/variant_categories`, { + title + }); + return this.unwrap(response.data, 'create variant category').variant_category; }); - return response.data.variant_category; } async updateVariantCategory( @@ -120,24 +276,33 @@ export class GumroadClient { variantCategoryId: string, title: string ): Promise { - let response = await this.axios.put( - `/products/${productId}/variant_categories/${variantCategoryId}`, - { title } - ); - return response.data.variant_category; + return this.request('update variant category', async () => { + let response = await this.axios.put( + `/products/${productId}/variant_categories/${variantCategoryId}`, + { title } + ); + return this.unwrap(response.data, 'update variant category').variant_category; + }); } async deleteVariantCategory(productId: string, variantCategoryId: string): Promise { - await this.axios.delete(`/products/${productId}/variant_categories/${variantCategoryId}`); + await this.request('delete variant category', async () => { + let response = await this.axios.delete( + `/products/${productId}/variant_categories/${variantCategoryId}` + ); + this.unwrap(response.data, 'delete variant category'); + }); } - // ── Variants ────────────────────────────────────────────── + // -- Variants ---------------------------------------------- async listVariants(productId: string, variantCategoryId: string): Promise { - let response = await this.axios.get( - `/products/${productId}/variant_categories/${variantCategoryId}/variants` - ); - return response.data.variants || []; + return this.request('list variants', async () => { + let response = await this.axios.get( + `/products/${productId}/variant_categories/${variantCategoryId}/variants` + ); + return this.unwrap(response.data, 'list variants').variants || []; + }); } async getVariant( @@ -145,10 +310,12 @@ export class GumroadClient { variantCategoryId: string, variantId: string ): Promise { - let response = await this.axios.get( - `/products/${productId}/variant_categories/${variantCategoryId}/variants/${variantId}` - ); - return response.data.variant; + return this.request('get variant', async () => { + let response = await this.axios.get( + `/products/${productId}/variant_categories/${variantCategoryId}/variants/${variantId}` + ); + return this.unwrap(response.data, 'get variant').variant; + }); } async createVariant( @@ -160,15 +327,17 @@ export class GumroadClient { maxPurchaseCount?: number; } ): Promise { - let response = await this.axios.post( - `/products/${productId}/variant_categories/${variantCategoryId}/variants`, - { - name: params.name, - price_difference_cents: params.priceDifferenceCents || 0, - max_purchase_count: params.maxPurchaseCount || 0 - } - ); - return response.data.variant; + return this.request('create variant', async () => { + let response = await this.axios.post( + `/products/${productId}/variant_categories/${variantCategoryId}/variants`, + { + name: params.name, + price_difference_cents: params.priceDifferenceCents ?? 0, + max_purchase_count: params.maxPurchaseCount ?? 0 + } + ); + return this.unwrap(response.data, 'create variant').variant; + }); } async updateVariant( @@ -181,18 +350,20 @@ export class GumroadClient { maxPurchaseCount?: number; } ): Promise { - let body: Record = {}; - if (params.name !== undefined) body.name = params.name; - if (params.priceDifferenceCents !== undefined) - body.price_difference_cents = params.priceDifferenceCents; - if (params.maxPurchaseCount !== undefined) - body.max_purchase_count = params.maxPurchaseCount; - - let response = await this.axios.put( - `/products/${productId}/variant_categories/${variantCategoryId}/variants/${variantId}`, - body - ); - return response.data.variant; + return this.request('update variant', async () => { + let body: Record = {}; + if (params.name !== undefined) body.name = params.name; + if (params.priceDifferenceCents !== undefined) + body.price_difference_cents = params.priceDifferenceCents; + if (params.maxPurchaseCount !== undefined) + body.max_purchase_count = params.maxPurchaseCount; + + let response = await this.axios.put( + `/products/${productId}/variant_categories/${variantCategoryId}/variants/${variantId}`, + body + ); + return this.unwrap(response.data, 'update variant').variant; + }); } async deleteVariant( @@ -200,16 +371,21 @@ export class GumroadClient { variantCategoryId: string, variantId: string ): Promise { - await this.axios.delete( - `/products/${productId}/variant_categories/${variantCategoryId}/variants/${variantId}` - ); + await this.request('delete variant', async () => { + let response = await this.axios.delete( + `/products/${productId}/variant_categories/${variantCategoryId}/variants/${variantId}` + ); + this.unwrap(response.data, 'delete variant'); + }); } - // ── Custom Fields ───────────────────────────────────────── + // -- Custom Fields ----------------------------------------- async listCustomFields(productId: string): Promise { - let response = await this.axios.get(`/products/${productId}/custom_fields`); - return response.data.custom_fields || []; + return this.request('list custom fields', async () => { + let response = await this.axios.get(`/products/${productId}/custom_fields`); + return this.unwrap(response.data, 'list custom fields').custom_fields || []; + }); } async createCustomField( @@ -219,11 +395,13 @@ export class GumroadClient { required?: boolean; } ): Promise { - let response = await this.axios.post(`/products/${productId}/custom_fields`, { - name: params.name, - required: params.required ? 'true' : 'false' + return this.request('create custom field', async () => { + let response = await this.axios.post(`/products/${productId}/custom_fields`, { + name: params.name, + required: params.required ? 'true' : 'false' + }); + return this.unwrap(response.data, 'create custom field').custom_field; }); - return response.data.custom_field; } async updateCustomField( @@ -233,146 +411,288 @@ export class GumroadClient { required?: boolean; } ): Promise { - let response = await this.axios.put( - `/products/${productId}/custom_fields/${encodeURIComponent(customFieldName)}`, - { - required: params.required ? 'true' : 'false' - } - ); - return response.data.custom_field; + return this.request('update custom field', async () => { + let response = await this.axios.put( + `/products/${productId}/custom_fields/${encodeURIComponent(customFieldName)}`, + { + required: params.required ? 'true' : 'false' + } + ); + return this.unwrap(response.data, 'update custom field').custom_field; + }); } async deleteCustomField(productId: string, customFieldName: string): Promise { - await this.axios.delete( - `/products/${productId}/custom_fields/${encodeURIComponent(customFieldName)}` - ); + await this.request('delete custom field', async () => { + let response = await this.axios.delete( + `/products/${productId}/custom_fields/${encodeURIComponent(customFieldName)}` + ); + this.unwrap(response.data, 'delete custom field'); + }); } - // ── Sales ───────────────────────────────────────────────── + // -- Sales ------------------------------------------------- async listSales(params?: { after?: string; before?: string; email?: string; + name?: string; + licenseKey?: string; productId?: string; orderId?: string; pageKey?: string; - }): Promise<{ sales: any[]; nextPageKey?: string }> { - let queryParams: Record = {}; - if (params?.after) queryParams.after = params.after; - if (params?.before) queryParams.before = params.before; - if (params?.email) queryParams.email = params.email; - if (params?.productId) queryParams.product_id = params.productId; - if (params?.orderId) queryParams.order_id = params.orderId; - if (params?.pageKey) queryParams.page_key = params.pageKey; - - let response = await this.axios.get('/sales', { params: queryParams }); - return { - sales: response.data.sales || [], - nextPageKey: response.data.next_page_key - }; + }): Promise<{ sales: any[]; nextPageKey?: string; nextPageUrl?: string }> { + return this.request('list sales', async () => { + let queryParams: Record = {}; + if (params?.after) queryParams.after = params.after; + if (params?.before) queryParams.before = params.before; + if (params?.email) queryParams.email = params.email; + if (params?.name) queryParams.name = params.name; + if (params?.licenseKey) queryParams.license_key = params.licenseKey; + if (params?.productId) queryParams.product_id = params.productId; + if (params?.orderId) queryParams.order_id = params.orderId; + if (params?.pageKey) queryParams.page_key = params.pageKey; + + let response = await this.axios.get('/sales', { params: queryParams }); + let data = this.unwrap(response.data, 'list sales'); + return { + sales: data.sales || [], + nextPageKey: data.next_page_key, + nextPageUrl: data.next_page_url + }; + }); } async getSale(saleId: string): Promise { - let response = await this.axios.get(`/sales/${saleId}`); - return response.data.sale; + return this.request('get sale', async () => { + let response = await this.axios.get(`/sales/${saleId}`); + return this.unwrap(response.data, 'get sale').sale; + }); } async markSaleAsShipped(saleId: string, trackingUrl?: string): Promise { - let body: Record = {}; - if (trackingUrl) body.tracking_url = trackingUrl; + return this.request('mark sale as shipped', async () => { + let body: Record = {}; + if (trackingUrl) body.tracking_url = trackingUrl; - let response = await this.axios.put(`/sales/${saleId}/mark_as_shipped`, body); - return response.data.sale; + let response = await this.axios.put(`/sales/${saleId}/mark_as_shipped`, body); + return this.unwrap(response.data, 'mark sale as shipped').sale; + }); } async refundSale(saleId: string, amountCents?: number): Promise { - let body: Record = {}; - if (amountCents !== undefined) body.amount_cents = amountCents; + return this.request('refund sale', async () => { + let body: Record = {}; + if (amountCents !== undefined) body.amount_cents = amountCents; - let response = await this.axios.put(`/sales/${saleId}/refund`, body); - return response.data.sale; + let response = await this.axios.put(`/sales/${saleId}/refund`, body); + return this.unwrap(response.data, 'refund sale').sale; + }); } - // ── Subscribers ─────────────────────────────────────────── + async resendReceipt(saleId: string): Promise { + await this.request('resend receipt', async () => { + let response = await this.axios.post(`/sales/${saleId}/resend_receipt`); + this.unwrap(response.data, 'resend receipt'); + }); + } + + // -- Subscribers ------------------------------------------- async listSubscribers( productId: string, params?: { email?: string; + paginated?: boolean; + pageKey?: string; } - ): Promise { - let queryParams: Record = { product_id: productId }; - if (params?.email) queryParams.email = params.email; - - let response = await this.axios.get(`/products/${productId}/subscribers`, { - params: queryParams + ): Promise<{ subscribers: any[]; nextPageKey?: string; nextPageUrl?: string }> { + return this.request('list subscribers', async () => { + let queryParams: Record = {}; + if (params?.email) queryParams.email = params.email; + if (params?.paginated !== undefined) queryParams.paginated = String(params.paginated); + if (params?.pageKey) queryParams.page_key = params.pageKey; + + let response = await this.axios.get(`/products/${productId}/subscribers`, { + params: queryParams + }); + let data = this.unwrap(response.data, 'list subscribers'); + return { + subscribers: data.subscribers || [], + nextPageKey: data.next_page_key, + nextPageUrl: data.next_page_url + }; }); - return response.data.subscribers || []; } async getSubscriber(subscriberId: string): Promise { - let response = await this.axios.get(`/subscribers/${subscriberId}`); - return response.data.subscriber; + return this.request('get subscriber', async () => { + let response = await this.axios.get(`/subscribers/${subscriberId}`); + let data = this.unwrap(response.data, 'get subscriber'); + return data.subscriber || data.subscribers; + }); } - // ── Licenses ────────────────────────────────────────────── + // -- Licenses ---------------------------------------------- async verifyLicense( - productPermalink: string, + productId: string, licenseKey: string, incrementUsesCount?: boolean ): Promise { - let response = await this.axios.post('/licenses/verify', { - product_permalink: productPermalink, - license_key: licenseKey, - increment_uses_count: incrementUsesCount !== false ? 'true' : 'false' + return this.request('verify license', async () => { + let response = await this.axios.post('/licenses/verify', { + product_id: productId, + license_key: licenseKey, + increment_uses_count: incrementUsesCount !== false ? 'true' : 'false' + }); + return this.unwrap(response.data, 'verify license'); + }); + } + + async enableLicense(productId: string, licenseKey: string): Promise { + return this.request('enable license', async () => { + let response = await this.axios.put('/licenses/enable', { + product_id: productId, + license_key: licenseKey + }); + return this.unwrap(response.data, 'enable license'); }); - return response.data; } - async enableLicense(productPermalink: string, licenseKey: string): Promise { - let response = await this.axios.put('/licenses/enable', { - product_permalink: productPermalink, - license_key: licenseKey + async disableLicense(productId: string, licenseKey: string): Promise { + return this.request('disable license', async () => { + let response = await this.axios.put('/licenses/disable', { + product_id: productId, + license_key: licenseKey + }); + return this.unwrap(response.data, 'disable license'); }); - return response.data; } - async disableLicense(productPermalink: string, licenseKey: string): Promise { - let response = await this.axios.put('/licenses/disable', { - product_permalink: productPermalink, - license_key: licenseKey + async decrementLicenseUsesCount(productId: string, licenseKey: string): Promise { + return this.request('decrement license uses count', async () => { + let response = await this.axios.put('/licenses/decrement_uses_count', { + product_id: productId, + license_key: licenseKey + }); + return this.unwrap(response.data, 'decrement license uses count'); }); - return response.data; } - async decrementLicenseUsesCount(productPermalink: string, licenseKey: string): Promise { - let response = await this.axios.put('/licenses/decrement_uses_count', { - product_permalink: productPermalink, - license_key: licenseKey + async rotateLicense(productId: string, licenseKey: string): Promise { + return this.request('rotate license', async () => { + let response = await this.axios.put('/licenses/rotate', { + product_id: productId, + license_key: licenseKey + }); + return this.unwrap(response.data, 'rotate license'); }); - return response.data; } - // ── Resource Subscriptions (Webhooks) ───────────────────── + // -- Payouts ----------------------------------------------- + + async listPayouts(params?: { + after?: string; + before?: string; + pageKey?: string; + includeUpcoming?: boolean; + }): Promise<{ payouts: any[]; nextPageKey?: string; nextPageUrl?: string }> { + return this.request('list payouts', async () => { + let queryParams: Record = {}; + if (params?.after) queryParams.after = params.after; + if (params?.before) queryParams.before = params.before; + if (params?.pageKey) queryParams.page_key = params.pageKey; + if (params?.includeUpcoming !== undefined) + queryParams.include_upcoming = String(params.includeUpcoming); + + let response = await this.axios.get('/payouts', { params: queryParams }); + let data = this.unwrap(response.data, 'list payouts'); + + return { + payouts: data.payouts || [], + nextPageKey: data.next_page_key, + nextPageUrl: data.next_page_url + }; + }); + } + + async getPayout( + payoutId: string, + params?: { + includeSales?: boolean; + includeTransactions?: boolean; + } + ): Promise { + return this.request('get payout', async () => { + let queryParams: Record = {}; + if (params?.includeSales !== undefined) + queryParams.include_sales = String(params.includeSales); + if (params?.includeTransactions !== undefined) + queryParams.include_transactions = String(params.includeTransactions); + + let response = await this.axios.get(`/payouts/${payoutId}`, { + params: queryParams + }); + return this.unwrap(response.data, 'get payout').payout; + }); + } + + async getUpcomingPayouts(params?: { + includeSales?: boolean; + includeTransactions?: boolean; + }): Promise { + return this.request('get upcoming payouts', async () => { + let queryParams: Record = {}; + if (params?.includeSales !== undefined) + queryParams.include_sales = String(params.includeSales); + if (params?.includeTransactions !== undefined) + queryParams.include_transactions = String(params.includeTransactions); + + let response = await this.axios.get('/payouts/upcoming', { + params: queryParams + }); + return this.unwrap(response.data, 'get upcoming payouts').payouts || []; + }); + } + + async getEarnings(year: number): Promise { + return this.request('get earnings', async () => { + let response = await this.axios.get('/earnings', { + params: { year: String(year) } + }); + return this.unwrap(response.data, 'get earnings'); + }); + } + + // -- Resource Subscriptions (Webhooks) --------------------- async listResourceSubscriptions(resourceName: string): Promise { - let response = await this.axios.get('/resource_subscriptions', { - params: { resource_name: resourceName } + return this.request('list resource subscriptions', async () => { + let response = await this.axios.get('/resource_subscriptions', { + params: { resource_name: resourceName } + }); + return ( + this.unwrap(response.data, 'list resource subscriptions').resource_subscriptions || [] + ); }); - return response.data.resource_subscriptions || []; } async createResourceSubscription(resourceName: string, postUrl: string): Promise { - let response = await this.axios.put('/resource_subscriptions', { - resource_name: resourceName, - post_url: postUrl + return this.request('create resource subscription', async () => { + let response = await this.axios.put('/resource_subscriptions', { + resource_name: resourceName, + post_url: postUrl + }); + return this.unwrap(response.data, 'create resource subscription').resource_subscription; }); - return response.data.resource_subscription; } async deleteResourceSubscription(subscriptionId: string): Promise { - await this.axios.delete(`/resource_subscriptions/${subscriptionId}`); + await this.request('delete resource subscription', async () => { + let response = await this.axios.delete(`/resource_subscriptions/${subscriptionId}`); + this.unwrap(response.data, 'delete resource subscription'); + }); } } diff --git a/integrations/gumroad/src/lib/errors.ts b/integrations/gumroad/src/lib/errors.ts new file mode 100644 index 0000000000..62f9454c0e --- /dev/null +++ b/integrations/gumroad/src/lib/errors.ts @@ -0,0 +1,70 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let pushMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let message = String(value).trim(); + if (message && !messages.includes(message)) messages.push(message); +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (Array.isArray(value)) { + for (let item of value) collectMessages(item, messages); + return; + } + + if (!isRecord(value)) { + pushMessage(messages, value); + return; + } + + pushMessage(messages, value.message); + pushMessage(messages, value.error); + pushMessage(messages, value.error_description); + pushMessage(messages, value.detail); + collectMessages(value.errors, messages); +}; + +let extractGumroadMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectMessages(response?.data, messages); + + if (messages.length > 0) return messages.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let gumroadServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let gumroadApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = gumroadServiceError( + `Gumroad API ${operation} failed: ${statusLabelFor(response)}${extractGumroadMessage(error)}` + ); + + serviceError.data.reason = 'gumroad_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) serviceError.setParent(error); + + return serviceError; +}; diff --git a/integrations/gumroad/src/tools.schema.test.ts b/integrations/gumroad/src/tools.schema.test.ts new file mode 100644 index 0000000000..c77e5985e8 --- /dev/null +++ b/integrations/gumroad/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Gumroad tool input schemas', provider.actions); diff --git a/integrations/gumroad/src/tools/get-earnings.ts b/integrations/gumroad/src/tools/get-earnings.ts new file mode 100644 index 0000000000..1b839406ea --- /dev/null +++ b/integrations/gumroad/src/tools/get-earnings.ts @@ -0,0 +1,47 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { GumroadClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getEarnings = SlateTool.create(spec, { + name: 'Get Earnings', + key: 'get_earnings', + description: `Retrieve the annual Gumroad earnings breakdown matching Tax Center totals. This endpoint requires view_tax_data scope and is only available to US-based sellers with Tax Center enabled.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + year: z.number().int().min(2000).describe('4-digit tax year to retrieve, such as 2025') + }) + ) + .output( + z.object({ + year: z.number().int().describe('Tax year'), + currency: z.string().describe('Currency, usually usd'), + grossCents: z.number().int().describe('Gross earnings in cents'), + feesCents: z.number().int().describe('Gumroad fees in cents'), + taxesCents: z.number().int().describe('Taxes in cents'), + affiliateCreditCents: z.number().int().describe('Affiliate credit in cents'), + netCents: z.number().int().describe('Net earnings in cents') + }) + ) + .handleInvocation(async ctx => { + let client = new GumroadClient({ token: ctx.auth.token }); + let result = await client.getEarnings(ctx.input.year); + + return { + output: { + year: result.year, + currency: result.currency, + grossCents: result.gross_cents, + feesCents: result.fees_cents, + taxesCents: result.taxes_cents, + affiliateCreditCents: result.affiliate_credit_cents, + netCents: result.net_cents + }, + message: `Retrieved ${result.year} Gumroad earnings.` + }; + }) + .build(); diff --git a/integrations/gumroad/src/tools/get-payout.ts b/integrations/gumroad/src/tools/get-payout.ts new file mode 100644 index 0000000000..f26f874687 --- /dev/null +++ b/integrations/gumroad/src/tools/get-payout.ts @@ -0,0 +1,45 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { GumroadClient } from '../lib/client'; +import { spec } from '../spec'; +import { mapPayout, payoutSchema } from './payout-utils'; + +export let getPayout = SlateTool.create(spec, { + name: 'Get Payout', + key: 'get_payout', + description: `Retrieve details for a specific Gumroad payout, optionally including sale IDs and exported transaction rows.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + payoutId: z.string().describe('The Gumroad payout ID to retrieve'), + includeSales: z + .boolean() + .optional() + .describe('Whether to include sales, refundedSales, and disputedSales'), + includeTransactions: z + .boolean() + .optional() + .describe('Whether to include transaction rows matching the exported payout CSV') + }) + ) + .output( + z.object({ + payout: payoutSchema.describe('Payout details') + }) + ) + .handleInvocation(async ctx => { + let client = new GumroadClient({ token: ctx.auth.token }); + let payout = await client.getPayout(ctx.input.payoutId, { + includeSales: ctx.input.includeSales, + includeTransactions: ctx.input.includeTransactions + }); + + return { + output: { payout: mapPayout(payout) }, + message: `Retrieved payout **${ctx.input.payoutId}**.` + }; + }) + .build(); diff --git a/integrations/gumroad/src/tools/get-product.ts b/integrations/gumroad/src/tools/get-product.ts index a3984fed6a..3a475c718f 100644 --- a/integrations/gumroad/src/tools/get-product.ts +++ b/integrations/gumroad/src/tools/get-product.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { GumroadClient } from '../lib/client'; import { spec } from '../spec'; +import { mapProduct, productSchema } from './product-utils'; export let getProduct = SlateTool.create(spec, { name: 'Get Product', @@ -16,51 +17,13 @@ export let getProduct = SlateTool.create(spec, { productId: z.string().describe('The product ID to retrieve') }) ) - .output( - z.object({ - productId: z.string().describe('Unique product ID'), - name: z.string().describe('Product name'), - permalink: z.string().optional().describe('Product permalink'), - description: z.string().optional().describe('Product description in HTML'), - published: z.boolean().optional().describe('Whether the product is published'), - priceCents: z.number().optional().describe('Product price in cents'), - currency: z.string().optional().describe('Currency code'), - url: z.string().optional().describe('Gumroad product URL'), - shortUrl: z.string().optional().describe('Short URL for the product'), - salesCount: z.number().optional().describe('Total number of sales'), - salesUsdCents: z.number().optional().describe('Total sales revenue in USD cents'), - tags: z.array(z.string()).optional().describe('Product tags'), - customFields: z - .array(z.any()) - .optional() - .describe('Custom fields configured on the product'), - variantCategories: z - .array(z.any()) - .optional() - .describe('Variant categories and their options') - }) - ) + .output(productSchema) .handleInvocation(async ctx => { let client = new GumroadClient({ token: ctx.auth.token }); let p = await client.getProduct(ctx.input.productId); return { - output: { - productId: p.id, - name: p.name || '', - permalink: p.permalink || undefined, - description: p.description || undefined, - published: p.published, - priceCents: p.price, - currency: p.currency, - url: p.url || undefined, - shortUrl: p.short_url || undefined, - salesCount: p.sales_count, - salesUsdCents: p.sales_usd_cents, - tags: p.tags || undefined, - customFields: p.custom_fields || undefined, - variantCategories: p.variant_categories || undefined - }, + output: mapProduct(p), message: `Retrieved product **${p.name}** (${p.id}).` }; }) diff --git a/integrations/gumroad/src/tools/get-upcoming-payouts.ts b/integrations/gumroad/src/tools/get-upcoming-payouts.ts new file mode 100644 index 0000000000..57cea6cd93 --- /dev/null +++ b/integrations/gumroad/src/tools/get-upcoming-payouts.ts @@ -0,0 +1,46 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { GumroadClient } from '../lib/client'; +import { spec } from '../spec'; +import { mapPayout, payoutSchema } from './payout-utils'; + +export let getUpcomingPayouts = SlateTool.create(spec, { + name: 'Get Upcoming Payouts', + key: 'get_upcoming_payouts', + description: `Retrieve upcoming Gumroad payouts for the authenticated user. Gumroad can return up to two upcoming payouts.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + includeSales: z + .boolean() + .optional() + .describe('Whether to include sales, refundedSales, and disputedSales'), + includeTransactions: z + .boolean() + .optional() + .describe('Whether to include transaction rows matching the exported payout CSV') + }) + ) + .output( + z.object({ + payouts: z.array(payoutSchema).describe('Upcoming payout details') + }) + ) + .handleInvocation(async ctx => { + let client = new GumroadClient({ token: ctx.auth.token }); + let payouts = await client.getUpcomingPayouts({ + includeSales: ctx.input.includeSales, + includeTransactions: ctx.input.includeTransactions + }); + + let mapped = payouts.map(mapPayout); + + return { + output: { payouts: mapped }, + message: `Found **${mapped.length}** upcoming payout(s).` + }; + }) + .build(); diff --git a/integrations/gumroad/src/tools/index.ts b/integrations/gumroad/src/tools/index.ts index 633e6061b4..846c9070bb 100644 --- a/integrations/gumroad/src/tools/index.ts +++ b/integrations/gumroad/src/tools/index.ts @@ -1,7 +1,12 @@ +export * from './get-earnings'; +export * from './get-payout'; export * from './get-product'; export * from './get-sale'; export * from './get-subscriber'; +export * from './get-upcoming-payouts'; export * from './get-user'; +export * from './list-categories'; +export * from './list-payouts'; export * from './list-products'; export * from './list-sales'; export * from './list-subscribers'; diff --git a/integrations/gumroad/src/tools/list-categories.ts b/integrations/gumroad/src/tools/list-categories.ts new file mode 100644 index 0000000000..d30d8d1224 --- /dev/null +++ b/integrations/gumroad/src/tools/list-categories.ts @@ -0,0 +1,45 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { GumroadClient } from '../lib/client'; +import { spec } from '../spec'; + +let categorySchema = z.object({ + categoryId: z.number().describe('Numeric category ID accepted as taxonomyId'), + name: z.string().describe('Short category slug'), + label: z.string().describe('Human-readable category label'), + path: z.string().describe('Full category path accepted as category'), + parentId: z.number().nullable().optional().describe('Parent category ID') +}); + +export let listCategories = SlateTool.create(spec, { + name: 'List Categories', + key: 'list_categories', + description: `Retrieve the Gumroad product category list. Use a category path or taxonomy ID when creating or updating products.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + categories: z.array(categorySchema).describe('Available product categories') + }) + ) + .handleInvocation(async ctx => { + let client = new GumroadClient({ token: ctx.auth.token }); + let categories = await client.listCategories(); + + let mapped = categories.map((category: any) => ({ + categoryId: category.id, + name: category.name || '', + label: category.label || '', + path: category.path || '', + parentId: category.parent_id ?? null + })); + + return { + output: { categories: mapped }, + message: `Found **${mapped.length}** Gumroad categor${mapped.length === 1 ? 'y' : 'ies'}.` + }; + }) + .build(); diff --git a/integrations/gumroad/src/tools/list-payouts.ts b/integrations/gumroad/src/tools/list-payouts.ts new file mode 100644 index 0000000000..f740290774 --- /dev/null +++ b/integrations/gumroad/src/tools/list-payouts.ts @@ -0,0 +1,64 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { GumroadClient } from '../lib/client'; +import { spec } from '../spec'; +import { mapPayout, payoutSchema } from './payout-utils'; + +export let listPayouts = SlateTool.create(spec, { + name: 'List Payouts', + key: 'list_payouts', + description: `Retrieve Gumroad payouts for the authenticated user. Supports date filtering, cursor pagination, and optional exclusion of upcoming payouts.`, + instructions: [ + 'Use after/before dates in YYYY-MM-DD format.', + 'Use pageKey from a previous response to get the next page of results.', + 'Set includeUpcoming to false when you only need historical payout records.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + after: z + .string() + .optional() + .describe('Only return payouts after this date (YYYY-MM-DD)'), + before: z + .string() + .optional() + .describe('Only return payouts before this date (YYYY-MM-DD)'), + pageKey: z.string().optional().describe('Pagination cursor from previous response'), + includeUpcoming: z + .boolean() + .optional() + .describe('Whether to include upcoming payouts. Gumroad defaults to true.') + }) + ) + .output( + z.object({ + payouts: z.array(payoutSchema).describe('List of payouts'), + nextPageKey: z.string().optional().describe('Cursor for the next page of results'), + nextPageUrl: z.string().optional().describe('URL for the next page of results') + }) + ) + .handleInvocation(async ctx => { + let client = new GumroadClient({ token: ctx.auth.token }); + let result = await client.listPayouts({ + after: ctx.input.after, + before: ctx.input.before, + pageKey: ctx.input.pageKey, + includeUpcoming: ctx.input.includeUpcoming + }); + + let mapped = result.payouts.map(mapPayout); + + return { + output: { + payouts: mapped, + nextPageKey: result.nextPageKey, + nextPageUrl: result.nextPageUrl + }, + message: `Found **${mapped.length}** payout(s).${result.nextPageKey ? ' More results are available with pagination.' : ''}` + }; + }) + .build(); diff --git a/integrations/gumroad/src/tools/list-products.ts b/integrations/gumroad/src/tools/list-products.ts index 72a42d4c32..61b86e6323 100644 --- a/integrations/gumroad/src/tools/list-products.ts +++ b/integrations/gumroad/src/tools/list-products.ts @@ -2,56 +2,41 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { GumroadClient } from '../lib/client'; import { spec } from '../spec'; - -let productSchema = z.object({ - productId: z.string().describe('Unique product ID'), - name: z.string().describe('Product name'), - permalink: z.string().optional().describe('Product permalink/slug'), - description: z.string().optional().describe('Product description'), - published: z.boolean().optional().describe('Whether the product is published'), - priceCents: z.number().optional().describe('Product price in cents'), - currency: z.string().optional().describe('Currency code'), - url: z.string().optional().describe('Gumroad product URL'), - shortUrl: z.string().optional().describe('Short URL for the product'), - salesCount: z.number().optional().describe('Total number of sales'), - salesUsdCents: z.number().optional().describe('Total sales revenue in USD cents') -}); +import { mapProduct, productSchema } from './product-utils'; export let listProducts = SlateTool.create(spec, { name: 'List Products', key: 'list_products', - description: `Retrieve all products from your Gumroad account. Returns product details including name, price, sales count, and publish status.`, + description: `Retrieve products from your Gumroad account. Returns product details including name, price, category, sales count, and publish status.`, + instructions: ['Use pageKey from a previous response to retrieve the next page.'], tags: { readOnly: true } }) - .input(z.object({})) + .input( + z.object({ + pageKey: z.string().optional().describe('Pagination cursor from a previous response') + }) + ) .output( z.object({ - products: z.array(productSchema).describe('List of products') + products: z.array(productSchema).describe('List of products'), + nextPageKey: z.string().optional().describe('Cursor for the next page of results'), + nextPageUrl: z.string().optional().describe('URL for the next page of results') }) ) .handleInvocation(async ctx => { let client = new GumroadClient({ token: ctx.auth.token }); - let products = await client.listProducts(); - - let mapped = products.map((p: any) => ({ - productId: p.id, - name: p.name || '', - permalink: p.permalink || undefined, - description: p.description || undefined, - published: p.published, - priceCents: p.price, - currency: p.currency, - url: p.url || undefined, - shortUrl: p.short_url || undefined, - salesCount: p.sales_count, - salesUsdCents: p.sales_usd_cents - })); + let result = await client.listProducts({ pageKey: ctx.input.pageKey }); + let mapped = result.products.map(mapProduct); return { - output: { products: mapped }, - message: `Found **${mapped.length}** product(s).` + output: { + products: mapped, + nextPageKey: result.nextPageKey, + nextPageUrl: result.nextPageUrl + }, + message: `Found **${mapped.length}** product(s).${result.nextPageKey ? ' More results are available with pagination.' : ''}` }; }) .build(); diff --git a/integrations/gumroad/src/tools/list-sales.ts b/integrations/gumroad/src/tools/list-sales.ts index 4983ba9120..b61d7d6cf3 100644 --- a/integrations/gumroad/src/tools/list-sales.ts +++ b/integrations/gumroad/src/tools/list-sales.ts @@ -21,7 +21,7 @@ let saleSchema = z.object({ export let listSales = SlateTool.create(spec, { name: 'List Sales', key: 'list_sales', - description: `Retrieve sales from your Gumroad account with optional filtering by date range, email, product, or order ID. Returns up to 10 sales per page with cursor-based pagination.`, + description: `Retrieve sales from your Gumroad account with optional filtering by date range, buyer email, buyer name, license key, product, or order ID. Returns up to 10 sales per page with cursor-based pagination.`, instructions: [ 'Use after/before dates in YYYY-MM-DD format.', 'Use pageKey from a previous response to get the next page of results.' @@ -38,6 +38,8 @@ export let listSales = SlateTool.create(spec, { .optional() .describe('Only return sales before this date (YYYY-MM-DD)'), email: z.string().optional().describe('Filter by buyer email address'), + name: z.string().optional().describe('Filter by buyer name'), + licenseKey: z.string().optional().describe('Filter by license key'), productId: z.string().optional().describe('Filter by product ID'), orderId: z.string().optional().describe('Filter by order ID'), pageKey: z.string().optional().describe('Pagination cursor from previous response') @@ -46,7 +48,8 @@ export let listSales = SlateTool.create(spec, { .output( z.object({ sales: z.array(saleSchema).describe('List of sales'), - nextPageKey: z.string().optional().describe('Cursor for next page of results') + nextPageKey: z.string().optional().describe('Cursor for next page of results'), + nextPageUrl: z.string().optional().describe('URL for next page of results') }) ) .handleInvocation(async ctx => { @@ -55,6 +58,8 @@ export let listSales = SlateTool.create(spec, { after: ctx.input.after, before: ctx.input.before, email: ctx.input.email, + name: ctx.input.name, + licenseKey: ctx.input.licenseKey, productId: ctx.input.productId, orderId: ctx.input.orderId, pageKey: ctx.input.pageKey @@ -76,7 +81,11 @@ export let listSales = SlateTool.create(spec, { })); return { - output: { sales: mapped, nextPageKey: result.nextPageKey }, + output: { + sales: mapped, + nextPageKey: result.nextPageKey, + nextPageUrl: result.nextPageUrl + }, message: `Found **${mapped.length}** sale(s).${result.nextPageKey ? ' More results available with pagination.' : ''}` }; }) diff --git a/integrations/gumroad/src/tools/list-subscribers.ts b/integrations/gumroad/src/tools/list-subscribers.ts index 4ff026db85..9400ce56a9 100644 --- a/integrations/gumroad/src/tools/list-subscribers.ts +++ b/integrations/gumroad/src/tools/list-subscribers.ts @@ -18,7 +18,8 @@ let subscriberSchema = z.object({ export let listSubscribers = SlateTool.create(spec, { name: 'List Subscribers', key: 'list_subscribers', - description: `Retrieve active subscribers for a specific Gumroad product. Optionally filter by email address.`, + description: `Retrieve active subscribers for a specific Gumroad product. Optionally filter by email address and use cursor-based pagination.`, + instructions: ['Use pageKey from a previous paginated response to get the next page.'], tags: { readOnly: true } @@ -26,21 +27,30 @@ export let listSubscribers = SlateTool.create(spec, { .input( z.object({ productId: z.string().describe('The product ID to list subscribers for'), - email: z.string().optional().describe('Filter subscribers by email address') + email: z.string().optional().describe('Filter subscribers by email address'), + paginated: z + .boolean() + .optional() + .describe('Whether to return paginated results. Defaults to Gumroad API behavior.'), + pageKey: z.string().optional().describe('Pagination cursor from previous response') }) ) .output( z.object({ - subscribers: z.array(subscriberSchema).describe('List of subscribers') + subscribers: z.array(subscriberSchema).describe('List of subscribers'), + nextPageKey: z.string().optional().describe('Cursor for next page of results'), + nextPageUrl: z.string().optional().describe('URL for next page of results') }) ) .handleInvocation(async ctx => { let client = new GumroadClient({ token: ctx.auth.token }); - let subscribers = await client.listSubscribers(ctx.input.productId, { - email: ctx.input.email + let result = await client.listSubscribers(ctx.input.productId, { + email: ctx.input.email, + paginated: ctx.input.paginated, + pageKey: ctx.input.pageKey }); - let mapped = subscribers.map((s: any) => ({ + let mapped = result.subscribers.map((s: any) => ({ subscriberId: s.id, productId: s.product_id || undefined, productName: s.product_name || undefined, @@ -53,8 +63,12 @@ export let listSubscribers = SlateTool.create(spec, { })); return { - output: { subscribers: mapped }, - message: `Found **${mapped.length}** subscriber(s) for product ${ctx.input.productId}.` + output: { + subscribers: mapped, + nextPageKey: result.nextPageKey, + nextPageUrl: result.nextPageUrl + }, + message: `Found **${mapped.length}** subscriber(s) for product ${ctx.input.productId}.${result.nextPageKey ? ' More results available with pagination.' : ''}` }; }) .build(); diff --git a/integrations/gumroad/src/tools/manage-custom-fields.ts b/integrations/gumroad/src/tools/manage-custom-fields.ts index b6f33b2271..acfafbd317 100644 --- a/integrations/gumroad/src/tools/manage-custom-fields.ts +++ b/integrations/gumroad/src/tools/manage-custom-fields.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { GumroadClient } from '../lib/client'; +import { gumroadServiceError } from '../lib/errors'; import { spec } from '../spec'; let customFieldSchema = z.object({ @@ -62,7 +63,7 @@ export let manageCustomFields = SlateTool.create(spec, { } if (action === 'create') { - if (!name) throw new Error('name is required for create action'); + if (!name) throw gumroadServiceError('name is required for create action.'); let field = await client.createCustomField(productId, { name, required: ctx.input.required @@ -80,7 +81,7 @@ export let manageCustomFields = SlateTool.create(spec, { } if (action === 'update') { - if (!name) throw new Error('name is required for update action'); + if (!name) throw gumroadServiceError('name is required for update action.'); let field = await client.updateCustomField(productId, name, { required: ctx.input.required }); @@ -97,7 +98,7 @@ export let manageCustomFields = SlateTool.create(spec, { } if (action === 'delete') { - if (!name) throw new Error('name is required for delete action'); + if (!name) throw gumroadServiceError('name is required for delete action.'); await client.deleteCustomField(productId, name); return { output: { deleted: true }, @@ -105,6 +106,6 @@ export let manageCustomFields = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw gumroadServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/gumroad/src/tools/manage-license.ts b/integrations/gumroad/src/tools/manage-license.ts index 97c89ef03a..e8fbe34377 100644 --- a/integrations/gumroad/src/tools/manage-license.ts +++ b/integrations/gumroad/src/tools/manage-license.ts @@ -1,28 +1,32 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { GumroadClient } from '../lib/client'; +import { gumroadServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageLicense = SlateTool.create(spec, { name: 'Manage License', key: 'manage_license', - description: `Verify, enable, disable, or decrement uses for a Gumroad license key. Used for gating access to software or premium content.`, + description: `Verify, enable, disable, decrement uses, or rotate a Gumroad license key. Used for gating access to software or premium content.`, instructions: [ 'Use "verify" to check if a license key is valid and optionally increment its use count.', 'Use "enable" to re-enable a previously disabled license.', 'Use "disable" to prevent a license key from being used.', - 'Use "decrement_uses" to reduce the usage count by one.' + 'Use "decrement_uses" to reduce the usage count by one.', + 'Use "rotate" to issue a new license key for the purchase.' ], tags: { - destructive: false + destructive: true } }) .input( z.object({ action: z - .enum(['verify', 'enable', 'disable', 'decrement_uses']) + .enum(['verify', 'enable', 'disable', 'decrement_uses', 'rotate']) .describe('Action to perform on the license'), - productPermalink: z.string().describe('Product permalink (short URL slug)'), + productId: z + .string() + .describe('Product ID from Gumroad. Required by the current Gumroad license API.'), licenseKey: z.string().describe('The license key to manage'), incrementUsesCount: z .boolean() @@ -36,6 +40,7 @@ export let manageLicense = SlateTool.create(spec, { uses: z.number().optional().describe('Current number of uses'), purchaseEmail: z.string().optional().describe('Email of the buyer'), purchaseId: z.string().optional().describe('Purchase/sale ID'), + licenseKey: z.string().optional().describe('License key returned by Gumroad'), createdAt: z.string().optional().describe('Purchase creation timestamp'), variants: z.any().optional().describe('Selected variant options'), customFields: z.any().optional().describe('Custom field values') @@ -43,24 +48,23 @@ export let manageLicense = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = new GumroadClient({ token: ctx.auth.token }); - let { action, productPermalink, licenseKey } = ctx.input; + let { action, licenseKey } = ctx.input; + let productId = ctx.input.productId; let result: any; if (action === 'verify') { - result = await client.verifyLicense( - productPermalink, - licenseKey, - ctx.input.incrementUsesCount - ); + result = await client.verifyLicense(productId, licenseKey, ctx.input.incrementUsesCount); } else if (action === 'enable') { - result = await client.enableLicense(productPermalink, licenseKey); + result = await client.enableLicense(productId, licenseKey); } else if (action === 'disable') { - result = await client.disableLicense(productPermalink, licenseKey); + result = await client.disableLicense(productId, licenseKey); } else if (action === 'decrement_uses') { - result = await client.decrementLicenseUsesCount(productPermalink, licenseKey); + result = await client.decrementLicenseUsesCount(productId, licenseKey); + } else if (action === 'rotate') { + result = await client.rotateLicense(productId, licenseKey); } else { - throw new Error(`Unknown action: ${action}`); + throw gumroadServiceError(`Unknown action: ${action}`); } let purchase = result.purchase || {}; @@ -70,7 +74,8 @@ export let manageLicense = SlateTool.create(spec, { success: result.success, uses: result.uses, purchaseEmail: purchase.email || undefined, - purchaseId: purchase.id || undefined, + purchaseId: purchase.id || purchase.sale_id || undefined, + licenseKey: purchase.license_key || result.license_key || undefined, createdAt: purchase.created_at || undefined, variants: purchase.variants || undefined, customFields: purchase.custom_fields || undefined diff --git a/integrations/gumroad/src/tools/manage-offer-codes.ts b/integrations/gumroad/src/tools/manage-offer-codes.ts index ca7d7f19de..962cc95999 100644 --- a/integrations/gumroad/src/tools/manage-offer-codes.ts +++ b/integrations/gumroad/src/tools/manage-offer-codes.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { GumroadClient } from '../lib/client'; +import { gumroadServiceError } from '../lib/errors'; import { spec } from '../spec'; let offerCodeSchema = z.object({ @@ -87,7 +88,7 @@ export let manageOfferCodes = SlateTool.create(spec, { } if (action === 'get') { - if (!offerCodeId) throw new Error('offerCodeId is required for get action'); + if (!offerCodeId) throw gumroadServiceError('offerCodeId is required for get action.'); let code = await client.getOfferCode(productId, offerCodeId); return { output: { @@ -105,9 +106,9 @@ export let manageOfferCodes = SlateTool.create(spec, { } if (action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); + if (!ctx.input.name) throw gumroadServiceError('name is required for create action.'); if (ctx.input.amountOff === undefined) - throw new Error('amountOff is required for create action'); + throw gumroadServiceError('amountOff is required for create action.'); let code = await client.createOfferCode(productId, { name: ctx.input.name, amountOff: ctx.input.amountOff, @@ -131,7 +132,8 @@ export let manageOfferCodes = SlateTool.create(spec, { } if (action === 'update') { - if (!offerCodeId) throw new Error('offerCodeId is required for update action'); + if (!offerCodeId) + throw gumroadServiceError('offerCodeId is required for update action.'); let code = await client.updateOfferCode(productId, offerCodeId, { maxPurchaseCount: ctx.input.maxPurchaseCount }); @@ -151,7 +153,8 @@ export let manageOfferCodes = SlateTool.create(spec, { } if (action === 'delete') { - if (!offerCodeId) throw new Error('offerCodeId is required for delete action'); + if (!offerCodeId) + throw gumroadServiceError('offerCodeId is required for delete action.'); await client.deleteOfferCode(productId, offerCodeId); return { output: { deleted: true }, @@ -159,6 +162,6 @@ export let manageOfferCodes = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw gumroadServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/gumroad/src/tools/manage-product.ts b/integrations/gumroad/src/tools/manage-product.ts index bd5375af80..f01366da43 100644 --- a/integrations/gumroad/src/tools/manage-product.ts +++ b/integrations/gumroad/src/tools/manage-product.ts @@ -1,15 +1,74 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { GumroadClient } from '../lib/client'; +import { GumroadClient, type ProductMutationParams } from '../lib/client'; +import { gumroadServiceError } from '../lib/errors'; import { spec } from '../spec'; +import { mapProduct, productSchema } from './product-utils'; + +let mutationFieldNames = [ + 'nativeType', + 'name', + 'description', + 'customPermalink', + 'priceCents', + 'currency', + 'subscriptionDuration', + 'customizablePrice', + 'suggestedPriceCents', + 'maxPurchaseCount', + 'quantityEnabled', + 'isAdult', + 'displayProductReviews', + 'shouldShowSalesCount', + 'category', + 'taxonomyId', + 'tags', + 'customReceipt', + 'customSummary', + 'customHtml', + 'coverIds', + 'richContent', + 'files', + 'hasSameRichContentForAllVariants' +] as const; + +type ManageProductInput = ProductMutationParams & { + action: 'create' | 'update' | 'enable' | 'disable' | 'delete'; + productId?: string; +}; + +let getProductId = (input: ManageProductInput) => { + if (!input.productId) { + throw gumroadServiceError(`productId is required for ${input.action} action.`); + } + + return input.productId; +}; + +let hasMutationField = (input: ManageProductInput) => + mutationFieldNames.some(field => input[field] !== undefined); + +let toMutationParams = (input: ManageProductInput): ProductMutationParams => { + if (input.category && input.taxonomyId !== undefined) { + throw gumroadServiceError('Send either category or taxonomyId, not both.'); + } + + return Object.fromEntries( + mutationFieldNames + .filter(field => input[field] !== undefined) + .map(field => [field, input[field]]) + ) as ProductMutationParams; +}; export let manageProduct = SlateTool.create(spec, { name: 'Manage Product', key: 'manage_product', - description: `Enable, disable, or delete a Gumroad product. Use this to control product visibility and lifecycle. Note: creating new products is not supported via the API.`, + description: `Create, update, enable, disable, or delete a Gumroad product. Use this to manage product drafts, metadata, pricing, categorization, and lifecycle.`, instructions: [ - 'Use "enable" to publish/make a product visible.', - 'Use "disable" to unpublish/hide a product.', + 'Use "create" with name and priceCents to create a draft product.', + 'Use "update" with productId and only the fields you want to change.', + 'Use either category or taxonomyId, never both.', + 'Use "enable" to publish a product and "disable" to hide it.', 'Use "delete" to permanently remove a product.' ], tags: { @@ -18,23 +77,135 @@ export let manageProduct = SlateTool.create(spec, { }) .input( z.object({ - productId: z.string().describe('The product ID to manage'), + productId: z + .string() + .optional() + .describe('The product ID. Required for update, enable, disable, and delete.'), action: z - .enum(['enable', 'disable', 'delete']) - .describe('Action to perform on the product') + .enum(['create', 'update', 'enable', 'disable', 'delete']) + .describe('Action to perform on the product'), + nativeType: z + .enum([ + 'digital', + 'course', + 'ebook', + 'membership', + 'bundle', + 'coffee', + 'call', + 'commission' + ]) + .optional() + .describe('Product type for create. Cannot be changed later.'), + name: z.string().optional().describe('Product name. Required for create.'), + description: z.string().optional().describe('Product description as HTML'), + customPermalink: z.string().optional().describe('Custom URL slug'), + priceCents: z + .number() + .int() + .nonnegative() + .optional() + .describe('Price in the smallest currency unit. Required for create.'), + currency: z.string().optional().describe('ISO currency code'), + subscriptionDuration: z + .enum(['monthly', 'quarterly', 'biannually', 'yearly', 'every_two_years']) + .optional() + .describe('Membership billing interval'), + customizablePrice: z.boolean().optional().describe('Enable pay-what-you-want pricing'), + suggestedPriceCents: z + .number() + .int() + .nonnegative() + .optional() + .describe('Suggested pay-what-you-want price in cents'), + maxPurchaseCount: z + .number() + .int() + .nonnegative() + .optional() + .describe('Maximum purchases allowed'), + quantityEnabled: z.boolean().optional().describe('Whether buyers can set quantity'), + isAdult: z.boolean().optional().describe('Whether the product is adult content'), + displayProductReviews: z + .boolean() + .optional() + .describe('Whether to display product reviews'), + shouldShowSalesCount: z + .boolean() + .optional() + .describe('Whether to show the product sales count'), + category: z + .string() + .optional() + .describe('Full category path from list_categories. Cannot be sent with taxonomyId.'), + taxonomyId: z + .number() + .int() + .optional() + .describe('Numeric category ID from list_categories. Cannot be sent with category.'), + tags: z + .array(z.string()) + .optional() + .describe('Product tags. Full replacement on update.'), + customReceipt: z.string().optional().describe('Custom receipt text'), + customSummary: z.string().optional().describe('Custom summary shown to buyers'), + customHtml: z.string().optional().describe('Custom landing page HTML'), + coverIds: z + .array(z.string()) + .optional() + .describe('Cover IDs to attach or reorder. Full replacement on update.'), + richContent: z + .array(z.any()) + .optional() + .describe('Rich content pages. Full replacement on update.'), + files: z + .array(z.any()) + .optional() + .describe('Files to attach. Full replacement on update.'), + hasSameRichContentForAllVariants: z + .boolean() + .optional() + .describe('Whether all variants share product-level rich content') }) ) .output( z.object({ - productId: z.string().describe('The managed product ID'), - name: z.string().optional().describe('Product name'), - published: z.boolean().optional().describe('Whether the product is now published'), + product: productSchema.optional().describe('Managed product details'), + productId: z.string().optional().describe('The managed product ID'), deleted: z.boolean().optional().describe('Whether the product was deleted') }) ) .handleInvocation(async ctx => { let client = new GumroadClient({ token: ctx.auth.token }); - let { productId, action } = ctx.input; + let input = ctx.input as ManageProductInput; + let { action } = input; + + if (action === 'create') { + if (!input.name) throw gumroadServiceError('name is required for create action.'); + if (input.priceCents === undefined) + throw gumroadServiceError('priceCents is required for create action.'); + + let product = await client.createProduct(toMutationParams(input)); + return { + output: { product: mapProduct(product), productId: product.id }, + message: `Created draft product **${product.name || product.id}**.` + }; + } + + if (action === 'update') { + let productId = getProductId(input); + if (!hasMutationField(input)) { + throw gumroadServiceError('At least one product field is required for update action.'); + } + + let product = await client.updateProduct(productId, toMutationParams(input)); + return { + output: { product: mapProduct(product), productId: product.id || productId }, + message: `Updated product **${product.name || productId}**.` + }; + } + + let productId = getProductId(input); if (action === 'delete') { await client.deleteProduct(productId); @@ -47,15 +218,16 @@ export let manageProduct = SlateTool.create(spec, { let product: any; if (action === 'enable') { product = await client.enableProduct(productId); - } else { + } else if (action === 'disable') { product = await client.disableProduct(productId); + } else { + throw gumroadServiceError(`Unknown action: ${action}`); } return { output: { - productId: product.id || productId, - name: product.name || undefined, - published: product.published + product: mapProduct(product), + productId: product.id || productId }, message: `Product **${product.name || productId}** has been ${action}d.` }; diff --git a/integrations/gumroad/src/tools/manage-sale.ts b/integrations/gumroad/src/tools/manage-sale.ts index dc17668654..c4b02edf63 100644 --- a/integrations/gumroad/src/tools/manage-sale.ts +++ b/integrations/gumroad/src/tools/manage-sale.ts @@ -1,15 +1,17 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { GumroadClient } from '../lib/client'; +import { gumroadServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageSale = SlateTool.create(spec, { name: 'Manage Sale', key: 'manage_sale', - description: `Mark a sale as shipped or refund a sale. Supports partial refunds by specifying an amount in cents.`, + description: `Mark a sale as shipped, refund a sale, or resend the purchase receipt. Supports partial refunds by specifying an amount in cents.`, instructions: [ 'Use "mark_as_shipped" to mark a physical product sale as shipped, optionally providing a tracking URL.', - 'Use "refund" to process a full or partial refund. Omit amountCents for a full refund.' + 'Use "refund" to process a full or partial refund. Omit amountCents for a full refund.', + 'Use "resend_receipt" to email the purchase receipt to the customer again.' ], tags: { destructive: true @@ -17,7 +19,9 @@ export let manageSale = SlateTool.create(spec, { }) .input( z.object({ - action: z.enum(['mark_as_shipped', 'refund']).describe('Action to perform on the sale'), + action: z + .enum(['mark_as_shipped', 'refund', 'resend_receipt']) + .describe('Action to perform on the sale'), saleId: z.string().describe('The sale ID to manage'), trackingUrl: z .string() @@ -34,7 +38,8 @@ export let manageSale = SlateTool.create(spec, { saleId: z.string().describe('The managed sale ID'), productName: z.string().optional().describe('Product name'), refunded: z.boolean().optional().describe('Whether the sale is refunded'), - shipped: z.boolean().optional().describe('Whether the sale is marked as shipped') + shipped: z.boolean().optional().describe('Whether the sale is marked as shipped'), + receiptResent: z.boolean().optional().describe('Whether the receipt was resent') }) ) .handleInvocation(async ctx => { @@ -67,6 +72,17 @@ export let manageSale = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + if (action === 'resend_receipt') { + await client.resendReceipt(saleId); + return { + output: { + saleId, + receiptResent: true + }, + message: `Resent receipt for sale **${saleId}**.` + }; + } + + throw gumroadServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/gumroad/src/tools/manage-variants.ts b/integrations/gumroad/src/tools/manage-variants.ts index 117a270ffa..e0386a49e8 100644 --- a/integrations/gumroad/src/tools/manage-variants.ts +++ b/integrations/gumroad/src/tools/manage-variants.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { GumroadClient } from '../lib/client'; +import { gumroadServiceError } from '../lib/errors'; import { spec } from '../spec'; let variantCategorySchema = z.object({ @@ -119,7 +120,8 @@ export let manageVariants = SlateTool.create(spec, { } if (action === 'create_category') { - if (!ctx.input.title) throw new Error('title is required for create_category'); + if (!ctx.input.title) + throw gumroadServiceError('title is required for create_category.'); let cat = await client.createVariantCategory(productId, ctx.input.title); return { output: { @@ -134,8 +136,9 @@ export let manageVariants = SlateTool.create(spec, { if (action === 'update_category') { if (!variantCategoryId) - throw new Error('variantCategoryId is required for update_category'); - if (!ctx.input.title) throw new Error('title is required for update_category'); + throw gumroadServiceError('variantCategoryId is required for update_category.'); + if (!ctx.input.title) + throw gumroadServiceError('title is required for update_category.'); let cat = await client.updateVariantCategory( productId, variantCategoryId, @@ -154,7 +157,7 @@ export let manageVariants = SlateTool.create(spec, { if (action === 'delete_category') { if (!variantCategoryId) - throw new Error('variantCategoryId is required for delete_category'); + throw gumroadServiceError('variantCategoryId is required for delete_category.'); await client.deleteVariantCategory(productId, variantCategoryId); return { output: { deleted: true }, @@ -166,7 +169,7 @@ export let manageVariants = SlateTool.create(spec, { if (action === 'list_variants') { if (!variantCategoryId) - throw new Error('variantCategoryId is required for list_variants'); + throw gumroadServiceError('variantCategoryId is required for list_variants.'); let variants = await client.listVariants(productId, variantCategoryId); let mapped = variants.map((v: any) => ({ variantId: v.id, @@ -182,8 +185,8 @@ export let manageVariants = SlateTool.create(spec, { if (action === 'create_variant') { if (!variantCategoryId) - throw new Error('variantCategoryId is required for create_variant'); - if (!ctx.input.name) throw new Error('name is required for create_variant'); + throw gumroadServiceError('variantCategoryId is required for create_variant.'); + if (!ctx.input.name) throw gumroadServiceError('name is required for create_variant.'); let v = await client.createVariant(productId, variantCategoryId, { name: ctx.input.name, priceDifferenceCents: ctx.input.priceDifferenceCents, @@ -204,8 +207,8 @@ export let manageVariants = SlateTool.create(spec, { if (action === 'update_variant') { if (!variantCategoryId) - throw new Error('variantCategoryId is required for update_variant'); - if (!variantId) throw new Error('variantId is required for update_variant'); + throw gumroadServiceError('variantCategoryId is required for update_variant.'); + if (!variantId) throw gumroadServiceError('variantId is required for update_variant.'); let v = await client.updateVariant(productId, variantCategoryId, variantId, { name: ctx.input.name, priceDifferenceCents: ctx.input.priceDifferenceCents, @@ -226,8 +229,8 @@ export let manageVariants = SlateTool.create(spec, { if (action === 'delete_variant') { if (!variantCategoryId) - throw new Error('variantCategoryId is required for delete_variant'); - if (!variantId) throw new Error('variantId is required for delete_variant'); + throw gumroadServiceError('variantCategoryId is required for delete_variant.'); + if (!variantId) throw gumroadServiceError('variantId is required for delete_variant.'); await client.deleteVariant(productId, variantCategoryId, variantId); return { output: { deleted: true }, @@ -235,6 +238,6 @@ export let manageVariants = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw gumroadServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/gumroad/src/tools/payout-utils.ts b/integrations/gumroad/src/tools/payout-utils.ts new file mode 100644 index 0000000000..ad3170da52 --- /dev/null +++ b/integrations/gumroad/src/tools/payout-utils.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export let payoutSchema = z.object({ + payoutId: z + .string() + .nullable() + .optional() + .describe('Payout ID. Upcoming payouts may be null.'), + amount: z.string().optional().describe('Payout amount as returned by Gumroad'), + currency: z.string().optional().describe('Payout currency'), + status: z.string().optional().describe('Payout status'), + createdAt: z.string().optional().describe('Payout creation timestamp'), + processedAt: z.string().nullable().optional().describe('Payout processed timestamp'), + paymentProcessor: z.string().optional().describe('Payment processor'), + bankAccountVisual: z.string().nullable().optional().describe('Masked bank account details'), + paypalEmail: z.string().nullable().optional().describe('PayPal payout email'), + sales: z.array(z.string()).optional().describe('Sale IDs included in the payout'), + refundedSales: z.array(z.string()).optional().describe('Refunded sale IDs in the payout'), + disputedSales: z.array(z.string()).optional().describe('Disputed sale IDs in the payout'), + transactions: z.array(z.any()).optional().describe('Payout transaction rows') +}); + +export let mapPayout = (payout: any) => ({ + payoutId: payout.id ?? null, + amount: payout.amount || undefined, + currency: payout.currency || undefined, + status: payout.status || undefined, + createdAt: payout.created_at || undefined, + processedAt: payout.processed_at ?? null, + paymentProcessor: payout.payment_processor || undefined, + bankAccountVisual: payout.bank_account_visual ?? null, + paypalEmail: payout.paypal_email ?? null, + sales: payout.sales || undefined, + refundedSales: payout.refunded_sales || undefined, + disputedSales: payout.disputed_sales || undefined, + transactions: payout.transactions || undefined +}); diff --git a/integrations/gumroad/src/tools/product-utils.ts b/integrations/gumroad/src/tools/product-utils.ts new file mode 100644 index 0000000000..4f40b6d59b --- /dev/null +++ b/integrations/gumroad/src/tools/product-utils.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +export let productSchema = z.object({ + productId: z.string().describe('Unique product ID'), + name: z.string().describe('Product name'), + permalink: z.string().optional().describe('Product permalink/slug'), + description: z.string().optional().describe('Product description'), + published: z.boolean().optional().describe('Whether the product is published'), + deleted: z.boolean().optional().describe('Whether the product has been deleted'), + priceCents: z.number().optional().describe('Product price in cents'), + currency: z.string().optional().describe('Currency code'), + category: z.string().optional().describe('Full Gumroad category path'), + categoryLabel: z.string().optional().describe('Human-readable category label'), + taxonomyId: z.number().optional().describe('Numeric Gumroad category ID'), + url: z.string().optional().describe('Gumroad product URL'), + shortUrl: z.string().optional().describe('Short URL for the product'), + thumbnailUrl: z.string().optional().describe('Product thumbnail URL'), + salesCount: z.number().optional().describe('Total number of sales'), + salesUsdCents: z.number().optional().describe('Total sales revenue in USD cents'), + tags: z.array(z.string()).optional().describe('Product tags'), + customFields: z + .array(z.any()) + .optional() + .describe('Custom fields configured on the product'), + variantCategories: z.array(z.any()).optional().describe('Variant categories and options'), + files: z.array(z.any()).optional().describe('Files attached to the product'), + richContent: z.array(z.any()).optional().describe('Product rich content pages') +}); + +export let mapProduct = (product: any) => ({ + productId: product.id, + name: product.name || '', + permalink: product.permalink || undefined, + description: product.description || undefined, + published: product.published, + deleted: product.deleted, + priceCents: product.price, + currency: product.currency, + category: product.category || undefined, + categoryLabel: product.category_label || undefined, + taxonomyId: product.taxonomy_id ?? undefined, + url: product.url || undefined, + shortUrl: product.short_url || undefined, + thumbnailUrl: product.thumbnail_url || undefined, + salesCount: product.sales_count, + salesUsdCents: product.sales_usd_cents, + tags: product.tags || undefined, + customFields: product.custom_fields || undefined, + variantCategories: product.variant_categories || product.variants || undefined, + files: product.files || undefined, + richContent: product.rich_content || undefined +}); diff --git a/integrations/gumroad/vitest.config.ts b/integrations/gumroad/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/gumroad/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/hellosign/package.json b/integrations/hellosign/package.json index 1bad7f4691..31088d5928 100644 --- a/integrations/hellosign/package.json +++ b/integrations/hellosign/package.json @@ -7,12 +7,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/hellosign/src/auth.ts b/integrations/hellosign/src/auth.ts index 2e1ceaaf28..d5a8f8523f 100644 --- a/integrations/hellosign/src/auth.ts +++ b/integrations/hellosign/src/auth.ts @@ -1,10 +1,21 @@ -import { createAxios, SlateAuth } from 'slates'; +import { + createApiServiceError, + createAxios, + normalizeOAuthTokenResponse, + SlateAuth +} from 'slates'; import { z } from 'zod'; +import { hellosignApiError } from './lib/errors'; let api = createAxios({ baseURL: 'https://api.hellosign.com/v3' }); +api.interceptors.response.use( + response => response, + error => Promise.reject(hellosignApiError(error, 'auth request')) +); + export let auth = SlateAuth.create() .output( z.object({ @@ -97,18 +108,17 @@ export let auth = SlateAuth.create() grant_type: 'authorization_code' }); - let data = response.data; - - let expiresAt: string | undefined; - if (data.expires_in) { - expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString(); - } + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Dropbox Sign', + operation: 'token exchange', + accessTokenMessage: 'Dropbox Sign OAuth token exchange did not return an access token.' + }); return { output: { - token: data.access_token, - refreshToken: data.refresh_token, - expiresAt, + token: token.token, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt, authMethod: 'oauth' as const } }; @@ -116,7 +126,7 @@ export let auth = SlateAuth.create() handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - throw new Error('No refresh token available'); + throw createApiServiceError('No Dropbox Sign refresh token is available.'); } let response = await api.post( @@ -132,18 +142,19 @@ export let auth = SlateAuth.create() } ); - let data = response.data; - - let expiresAt: string | undefined; - if (data.expires_in) { - expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString(); - } + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Dropbox Sign', + operation: 'token refresh', + previousRefreshToken: ctx.output.refreshToken, + refreshTokenFallbackMode: 'falsy', + accessTokenMessage: 'Dropbox Sign OAuth refresh did not return an access token.' + }); return { output: { - token: data.access_token, - refreshToken: data.refresh_token || ctx.output.refreshToken, - expiresAt, + token: token.token, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt, authMethod: 'oauth' as const } }; diff --git a/integrations/hellosign/src/index.ts b/integrations/hellosign/src/index.ts index 5e69284c8d..4f8df58e3b 100644 --- a/integrations/hellosign/src/index.ts +++ b/integrations/hellosign/src/index.ts @@ -1,6 +1,8 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + bulkSendTemplateRequest, + createReport, downloadFiles, getAccount, getEmbeddedUrls, @@ -8,6 +10,7 @@ import { getTemplate, listSignatureRequests, listTemplates, + manageBulkSendJobs, manageSignatureRequest, manageTeam, manageTemplate, @@ -21,16 +24,19 @@ export let provider = Slate.create({ tools: [ sendSignatureRequest, sendTemplateRequest, + bulkSendTemplateRequest, getSignatureRequest, listSignatureRequests, manageSignatureRequest, downloadFiles, + manageBulkSendJobs, listTemplates, getTemplate, manageTemplate, getAccount, getEmbeddedUrls, - manageTeam + manageTeam, + createReport ], triggers: [signatureRequestEvents, templateEvents] }); diff --git a/integrations/hellosign/src/lib/client.ts b/integrations/hellosign/src/lib/client.ts index 7eaad8611f..260d55210b 100644 --- a/integrations/hellosign/src/lib/client.ts +++ b/integrations/hellosign/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { hellosignApiError, hellosignServiceError } from './errors'; export interface ClientConfig { token: string; @@ -41,6 +42,7 @@ export interface SendSignatureRequestParams { signingRedirectUrl?: string; testMode?: boolean; clientId?: string; + embeddedSigning?: boolean; } export interface SendWithTemplateParams { @@ -55,6 +57,26 @@ export interface SendWithTemplateParams { signingRedirectUrl?: string; testMode?: boolean; clientId?: string; + embeddedSigning?: boolean; +} + +export interface BulkSignerListEntry { + signers: { role: string; name: string; emailAddress: string; pin?: string }[]; + customFields?: { name: string; value: string }[]; +} + +export interface BulkSendWithTemplateParams { + templateIds: string[]; + signerList: BulkSignerListEntry[]; + title?: string; + subject?: string; + message?: string; + ccs?: { role: string; emailAddress: string }[]; + metadata?: Record; + signingRedirectUrl?: string; + testMode?: boolean; + clientId?: string; + embeddedSigning?: boolean; } export interface UpdateSignatureRequestParams { @@ -76,11 +98,46 @@ export interface CreateTemplateParams { testMode?: boolean; } +let toBuffer = (data: unknown) => { + if (Buffer.isBuffer(data)) { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + if (typeof data === 'string') { + return Buffer.from(data, 'binary'); + } + return Buffer.from(data as any); +}; + +let getHeaderValue = (headers: Record, name: string) => { + let value = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return typeof value === 'string' ? value : undefined; +}; + +let cleanObject = >(value: T) => { + for (let key of Object.keys(value)) { + if (value[key] === undefined) { + delete value[key]; + } + } + return value; +}; + export class Client { private axios: ReturnType; constructor(config: ClientConfig) { - let headers: Record = {}; + let headers: Record = { + 'Content-Type': 'application/json' + }; if (config.authMethod === 'oauth') { headers.Authorization = `Bearer ${config.token}`; @@ -93,6 +150,11 @@ export class Client { baseURL: 'https://api.hellosign.com/v3', headers }); + + this.axios.interceptors.response.use( + response => response, + error => Promise.reject(hellosignApiError(error, 'request')) + ); } // ---- Account ---- @@ -114,91 +176,132 @@ export class Client { // ---- Signature Requests ---- async sendSignatureRequest(params: SendSignatureRequestParams): Promise { - let body: Record = {}; - - if (params.title) body.title = params.title; - if (params.subject) body.subject = params.subject; - if (params.message) body.message = params.message; - if (params.allowDecline) body.allow_decline = 1; - if (params.allowReassign) body.allow_reassign = 1; - if (params.useTextTags) body.use_text_tags = 1; - if (params.signingRedirectUrl) body.signing_redirect_url = params.signingRedirectUrl; - if (params.testMode) body.test_mode = 1; - if (params.clientId) body.client_id = params.clientId; - - params.signers.forEach((signer, i) => { - body[`signers[${i}][email_address]`] = signer.emailAddress; - body[`signers[${i}][name]`] = signer.name; - if (signer.order !== undefined) body[`signers[${i}][order]`] = signer.order; - if (signer.pin) body[`signers[${i}][pin]`] = signer.pin; - if (signer.smsPhoneNumber) - body[`signers[${i}][sms_phone_number]`] = signer.smsPhoneNumber; - if (signer.smsPhoneNumberType) - body[`signers[${i}][sms_phone_number_type]`] = signer.smsPhoneNumberType; - }); - - if (params.ccEmailAddresses) { - params.ccEmailAddresses.forEach((email, i) => { - body[`cc_email_addresses[${i}]`] = email; - }); - } - - if (params.fileUrls) { - params.fileUrls.forEach((url, i) => { - body[`file_url[${i}]`] = url; - }); + if (params.embeddedSigning && !params.clientId) { + throw hellosignServiceError('clientId is required for embedded signature requests.'); } - if (params.metadata) { - for (let [key, value] of Object.entries(params.metadata)) { - body[`metadata[${key}]`] = value; - } - } + let body = cleanObject({ + title: params.title, + subject: params.subject, + message: params.message, + signers: params.signers.map(signer => + cleanObject({ + email_address: signer.emailAddress, + name: signer.name, + order: signer.order, + pin: signer.pin, + sms_phone_number: signer.smsPhoneNumber, + sms_phone_number_type: signer.smsPhoneNumberType + }) + ), + cc_email_addresses: params.ccEmailAddresses, + file_urls: params.fileUrls, + metadata: params.metadata, + allow_decline: params.allowDecline, + allow_reassign: params.allowReassign, + use_text_tags: params.useTextTags, + signing_redirect_url: params.signingRedirectUrl, + test_mode: params.testMode, + client_id: params.clientId + }); - let response = await this.axios.post('/signature_request/send', body); + let endpoint = params.embeddedSigning + ? '/signature_request/create_embedded' + : '/signature_request/send'; + let response = await this.axios.post(endpoint, body); return response.data.signature_request; } async sendSignatureRequestWithTemplate(params: SendWithTemplateParams): Promise { - let body: Record = {}; - - if (params.title) body.title = params.title; - if (params.subject) body.subject = params.subject; - if (params.message) body.message = params.message; - if (params.signingRedirectUrl) body.signing_redirect_url = params.signingRedirectUrl; - if (params.testMode) body.test_mode = 1; - if (params.clientId) body.client_id = params.clientId; + if (params.embeddedSigning && !params.clientId) { + throw hellosignServiceError( + 'clientId is required for embedded template signature requests.' + ); + } - params.templateIds.forEach((id, i) => { - body[`template_ids[${i}]`] = id; + let body = cleanObject({ + title: params.title, + subject: params.subject, + message: params.message, + signing_redirect_url: params.signingRedirectUrl, + test_mode: params.testMode, + client_id: params.clientId, + template_ids: params.templateIds, + signers: params.signers.map(signer => + cleanObject({ + role: signer.role, + email_address: signer.emailAddress, + name: signer.name, + pin: signer.pin + }) + ), + ccs: params.ccs?.map(cc => ({ + role: cc.role, + email_address: cc.emailAddress + })), + custom_fields: params.customFields?.map(field => ({ + name: field.name, + value: field.value + })), + metadata: params.metadata }); - params.signers.forEach((signer, _i) => { - body[`signers[${signer.role}][email_address]`] = signer.emailAddress; - body[`signers[${signer.role}][name]`] = signer.name; - if (signer.pin) body[`signers[${signer.role}][pin]`] = signer.pin; - }); + let endpoint = params.embeddedSigning + ? '/signature_request/create_embedded_with_template' + : '/signature_request/send_with_template'; + let response = await this.axios.post(endpoint, body); + return response.data.signature_request; + } - if (params.ccs) { - params.ccs.forEach(cc => { - body[`ccs[${cc.role}][email_address]`] = cc.emailAddress; - }); + async bulkSendSignatureRequestWithTemplate( + params: BulkSendWithTemplateParams + ): Promise { + if (params.embeddedSigning && !params.clientId) { + throw hellosignServiceError('clientId is required for embedded bulk sends.'); } - if (params.customFields) { - params.customFields.forEach((field, i) => { - body[`custom_fields[${i}][name]`] = field.name; - body[`custom_fields[${i}][value]`] = field.value; - }); - } + let body = cleanObject({ + title: params.title, + subject: params.subject, + message: params.message, + signing_redirect_url: params.signingRedirectUrl, + test_mode: params.testMode, + client_id: params.clientId, + template_ids: params.templateIds, + signer_list: params.signerList.map(entry => + cleanObject({ + signers: entry.signers.map(signer => + cleanObject({ + role: signer.role, + name: signer.name, + email_address: signer.emailAddress, + pin: signer.pin + }) + ), + custom_fields: entry.customFields?.map(field => ({ + name: field.name, + value: field.value + })) + }) + ), + ccs: params.ccs?.map(cc => ({ + role: cc.role, + email_address: cc.emailAddress + })), + metadata: params.metadata + }); - if (params.metadata) { - for (let [key, value] of Object.entries(params.metadata)) { - body[`metadata[${key}]`] = value; - } - } + let endpoint = params.embeddedSigning + ? '/signature_request/bulk_create_embedded_with_template' + : '/signature_request/bulk_send_with_template'; + let response = await this.axios.post(endpoint, body); + return response.data.bulk_send_job; + } - let response = await this.axios.post('/signature_request/send_with_template', body); + async releaseOnHoldSignatureRequest(signatureRequestId: string): Promise { + let response = await this.axios.post( + `/signature_request/release_hold/${signatureRequestId}` + ); return response.data.signature_request; } @@ -252,7 +355,7 @@ export class Client { if (params.signatureId) body.signature_id = params.signatureId; if (params.emailAddress) body.email_address = params.emailAddress; if (params.signerName) body.signer_name = params.signerName; - if (params.expiresAt) body.expires_at = params.expiresAt; + if (params.expiresAt !== undefined) body.expires_at = params.expiresAt; let response = await this.axios.post( `/signature_request/update/${params.signatureRequestId}`, @@ -264,14 +367,22 @@ export class Client { async getSignatureRequestFiles( signatureRequestId: string, fileType?: 'pdf' | 'zip' - ): Promise<{ fileUrl: string }> { - let params: Record = { get_url: true }; + ): Promise<{ contentBase64: string; mimeType: string; byteLength: number }> { + let params: Record = {}; if (fileType) params.file_type = fileType; let response = await this.axios.get(`/signature_request/files/${signatureRequestId}`, { - params + params, + responseType: 'arraybuffer' }); - return { fileUrl: response.data.file_url }; + let content = toBuffer(response.data); + return { + contentBase64: content.toString('base64'), + mimeType: + getHeaderValue(response.headers as Record, 'content-type') ?? + (fileType === 'zip' ? 'application/zip' : 'application/pdf'), + byteLength: content.byteLength + }; } // ---- Templates ---- @@ -338,12 +449,22 @@ export class Client { async getTemplateFiles( templateId: string, fileType?: 'pdf' | 'zip' - ): Promise<{ fileUrl: string }> { - let params: Record = { get_url: true }; + ): Promise<{ contentBase64: string; mimeType: string; byteLength: number }> { + let params: Record = {}; if (fileType) params.file_type = fileType; - let response = await this.axios.get(`/template/files/${templateId}`, { params }); - return { fileUrl: response.data.file_url }; + let response = await this.axios.get(`/template/files/${templateId}`, { + params, + responseType: 'arraybuffer' + }); + let content = toBuffer(response.data); + return { + contentBase64: content.toString('base64'), + mimeType: + getHeaderValue(response.headers as Record, 'content-type') ?? + (fileType === 'zip' ? 'application/zip' : 'application/pdf'), + byteLength: content.byteLength + }; } // ---- Team ---- @@ -442,7 +563,7 @@ export class Client { end_date: endDate, report_type: reportType }); - return response.data; + return response.data.report ?? response.data; } // ---- Helpers ---- diff --git a/integrations/hellosign/src/lib/errors.ts b/integrations/hellosign/src/lib/errors.ts new file mode 100644 index 0000000000..9e146e005a --- /dev/null +++ b/integrations/hellosign/src/lib/errors.ts @@ -0,0 +1,102 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.error_msg); + addDetail(details, value.error_name); + addDetail(details, value.message); + addDetail(details, value.error); + addDetail(details, value.error_description); + addDetail(details, value.code); + addDetail(details, value.status); + collectDetails(value.error, details); + collectDetails(value.errors, details); + collectDetails(value.warnings, details); +}; + +let extractDropboxSignMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data ?? (isRecord(error) ? error.data : undefined); + let details: string[] = []; + + collectDetails(data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getDropboxSignErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + let data = isRecord(error.data) ? error.data : undefined; + return response?.status ?? error.status ?? data?.status; +}; + +export let hellosignServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let hellosignApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getDropboxSignErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = hellosignServiceError( + `Dropbox Sign API ${operation} failed: ${statusLabel}${extractDropboxSignMessage(error)}` + ); + serviceError.data.reason = 'dropbox_sign_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/hellosign/src/tools.schema.test.ts b/integrations/hellosign/src/tools.schema.test.ts new file mode 100644 index 0000000000..65a6624f51 --- /dev/null +++ b/integrations/hellosign/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Dropbox Sign tool input schemas', provider.actions); diff --git a/integrations/hellosign/src/tools/bulk-send-template-request.ts b/integrations/hellosign/src/tools/bulk-send-template-request.ts new file mode 100644 index 0000000000..99e3cc2b71 --- /dev/null +++ b/integrations/hellosign/src/tools/bulk-send-template-request.ts @@ -0,0 +1,119 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let bulkSignerSchema = z.object({ + role: z.string().describe('Template signer role name to assign this signer to'), + name: z.string().describe('Full name of the signer'), + emailAddress: z.string().describe('Email address of the signer'), + pin: z.string().optional().describe('Access code for the signer') +}); + +let bulkCustomFieldSchema = z.object({ + name: z.string().describe('Name of the template merge field'), + value: z.string().describe('Value to pre-fill') +}); + +let bulkCcSchema = z.object({ + role: z.string().describe('Template CC role name'), + emailAddress: z.string().describe('Email address of the CC recipient') +}); + +export let bulkSendTemplateRequest = SlateTool.create(spec, { + name: 'Bulk Send Template Request', + key: 'bulk_send_template_request', + description: `Create a Dropbox Sign bulk send job from one or more templates. Each signer list entry creates one signature request, optionally as embedded signing requests when clientId and embeddedSigning are provided.`, + instructions: [ + 'Template IDs and signer roles must match existing Dropbox Sign templates.', + 'Use testMode=true for testing.', + 'Bulk send requires Dropbox Sign Standard plan or higher.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + templateIds: z.array(z.string()).min(1).describe('IDs of the templates to use'), + signerList: z + .array( + z.object({ + signers: z + .array(bulkSignerSchema) + .min(1) + .describe('Template role assignments for one generated request'), + customFields: z + .array(bulkCustomFieldSchema) + .optional() + .describe('Template merge field values for this generated request') + }) + ) + .min(1) + .max(250) + .describe('Generated request definitions. Dropbox Sign supports up to 250.'), + title: z.string().optional().describe('Title for generated signature requests'), + subject: z.string().optional().describe('Subject line of the email'), + message: z.string().optional().describe('Message body of the email'), + ccs: z.array(bulkCcSchema).optional().describe('CC recipients mapped to template roles'), + metadata: z + .record(z.string(), z.string()) + .optional() + .describe('Key-value metadata pairs'), + signingRedirectUrl: z + .string() + .optional() + .describe('URL to redirect signers to after signing'), + testMode: z.boolean().optional().describe('Enable test mode'), + clientId: z.string().optional().describe('API App client ID'), + embeddedSigning: z + .boolean() + .optional() + .describe('Create embedded signing requests instead of email signing requests') + }) + ) + .output( + z.object({ + bulkSendJobId: z.string().describe('Unique identifier of the created bulk send job'), + total: z.number().optional().describe('Number of signature requests in the job'), + isCreator: z + .boolean() + .optional() + .describe('Whether the authenticated account created it'), + createdAt: z.string().optional().describe('Creation timestamp (ISO 8601)') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authMethod: ctx.auth.authMethod + }); + + let result = await client.bulkSendSignatureRequestWithTemplate({ + templateIds: ctx.input.templateIds, + signerList: ctx.input.signerList, + title: ctx.input.title, + subject: ctx.input.subject, + message: ctx.input.message, + ccs: ctx.input.ccs, + metadata: ctx.input.metadata, + signingRedirectUrl: ctx.input.signingRedirectUrl, + testMode: ctx.input.testMode, + clientId: ctx.input.clientId, + embeddedSigning: ctx.input.embeddedSigning + }); + + return { + output: { + bulkSendJobId: result.bulk_send_job_id, + total: result.total, + isCreator: result.is_creator, + createdAt: result.created_at + ? new Date(result.created_at * 1000).toISOString() + : undefined + }, + message: `Bulk send job **${result.bulk_send_job_id}** created for ${result.total ?? ctx.input.signerList.length} request(s).` + }; + }) + .build(); diff --git a/integrations/hellosign/src/tools/create-report.ts b/integrations/hellosign/src/tools/create-report.ts new file mode 100644 index 0000000000..151ec3c920 --- /dev/null +++ b/integrations/hellosign/src/tools/create-report.ts @@ -0,0 +1,64 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let reportTypeSchema = z.enum(['user_activity', 'document_status']); + +export let createReport = SlateTool.create(spec, { + name: 'Create Report', + key: 'create_report', + description: `Request Dropbox Sign activity and document-status reports for a date range. Dropbox Sign generates the CSV asynchronously and emails download links to the account.`, + instructions: [ + 'Use MM/DD/YYYY dates.', + 'The date range can be up to 12 months.', + 'Dropbox Sign emails the generated CSV report links when processing completes.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + startDate: z.string().describe('Inclusive start date in MM/DD/YYYY format'), + endDate: z.string().describe('Inclusive end date in MM/DD/YYYY format'), + reportTypes: z + .array(reportTypeSchema) + .min(1) + .describe('Report types to generate: user_activity and/or document_status') + }) + ) + .output( + z.object({ + successMessage: z.string().optional().describe('Dropbox Sign processing message'), + startDate: z.string().describe('Report start date'), + endDate: z.string().describe('Report end date'), + reportTypes: z.array(reportTypeSchema).describe('Requested report types') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authMethod: ctx.auth.authMethod + }); + + let result = await client.createReport( + ctx.input.startDate, + ctx.input.endDate, + ctx.input.reportTypes + ); + + let reportTypes = result.report_type || ctx.input.reportTypes; + + return { + output: { + successMessage: result.success, + startDate: result.start_date || ctx.input.startDate, + endDate: result.end_date || ctx.input.endDate, + reportTypes + }, + message: `Dropbox Sign report request accepted for ${reportTypes.join(', ')}.` + }; + }) + .build(); diff --git a/integrations/hellosign/src/tools/download-files.ts b/integrations/hellosign/src/tools/download-files.ts index 1f256ff06c..d184714bf8 100644 --- a/integrations/hellosign/src/tools/download-files.ts +++ b/integrations/hellosign/src/tools/download-files.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; @@ -6,7 +6,7 @@ import { spec } from '../spec'; export let downloadFiles = SlateTool.create(spec, { name: 'Download Files', key: 'download_files', - description: `Get a download URL for the documents associated with a signature request or template. Returns a temporary URL that can be used to download the files as PDF or ZIP.`, + description: `Download the documents associated with a signature request or template. Returns the PDF or ZIP file as a Slate attachment and keeps structured output limited to metadata.`, tags: { destructive: false, readOnly: true @@ -26,9 +26,12 @@ export let downloadFiles = SlateTool.create(spec, { ) .output( z.object({ - fileUrl: z.string().describe('Temporary URL to download the files'), resourceType: z.string().describe('Type of the resource'), - resourceId: z.string().describe('ID of the resource') + resourceId: z.string().describe('ID of the resource'), + fileType: z.string().describe('Downloaded file format'), + mimeType: z.string().describe('MIME type of the returned attachment'), + byteLength: z.number().describe('Decoded byte length of the returned attachment'), + attachmentCount: z.number().describe('Number of attachments returned') }) ) .handleInvocation(async ctx => { @@ -37,26 +40,25 @@ export let downloadFiles = SlateTool.create(spec, { authMethod: ctx.auth.authMethod }); - let fileUrl: string; + let result: { contentBase64: string; mimeType: string; byteLength: number }; if (ctx.input.resourceType === 'signature_request') { - let result = await client.getSignatureRequestFiles( - ctx.input.resourceId, - ctx.input.fileType - ); - fileUrl = result.fileUrl; + result = await client.getSignatureRequestFiles(ctx.input.resourceId, ctx.input.fileType); } else { - let result = await client.getTemplateFiles(ctx.input.resourceId, ctx.input.fileType); - fileUrl = result.fileUrl; + result = await client.getTemplateFiles(ctx.input.resourceId, ctx.input.fileType); } return { output: { - fileUrl, resourceType: ctx.input.resourceType, - resourceId: ctx.input.resourceId + resourceId: ctx.input.resourceId, + fileType: ctx.input.fileType || 'pdf', + mimeType: result.mimeType, + byteLength: result.byteLength, + attachmentCount: 1 }, - message: `Download URL generated for ${ctx.input.resourceType} **${ctx.input.resourceId}** (${ctx.input.fileType || 'pdf'}).` + attachments: [createBase64Attachment(result.contentBase64, result.mimeType)], + message: `Downloaded ${ctx.input.resourceType} **${ctx.input.resourceId}** as a ${ctx.input.fileType || 'pdf'} attachment.` }; }) .build(); diff --git a/integrations/hellosign/src/tools/get-embedded-urls.ts b/integrations/hellosign/src/tools/get-embedded-urls.ts index 7fdbaa98c5..cd59a3d02a 100644 --- a/integrations/hellosign/src/tools/get-embedded-urls.ts +++ b/integrations/hellosign/src/tools/get-embedded-urls.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { hellosignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let getEmbeddedUrls = SlateTool.create(spec, { @@ -51,7 +52,7 @@ export let getEmbeddedUrls = SlateTool.create(spec, { if (ctx.input.type === 'sign') { if (!ctx.input.signatureId) { - throw new Error('signatureId is required for embedded signing URLs'); + throw hellosignServiceError('signatureId is required for embedded signing URLs.'); } let result = await client.getEmbeddedSignUrl(ctx.input.signatureId); @@ -66,7 +67,7 @@ export let getEmbeddedUrls = SlateTool.create(spec, { }; } else { if (!ctx.input.templateId) { - throw new Error('templateId is required for embedded editing URLs'); + throw hellosignServiceError('templateId is required for embedded editing URLs.'); } let result = await client.getEmbeddedEditUrl(ctx.input.templateId, { diff --git a/integrations/hellosign/src/tools/index.ts b/integrations/hellosign/src/tools/index.ts index 5a14318fc7..c01d61f179 100644 --- a/integrations/hellosign/src/tools/index.ts +++ b/integrations/hellosign/src/tools/index.ts @@ -1,3 +1,5 @@ +export * from './bulk-send-template-request'; +export * from './create-report'; export * from './download-files'; export * from './get-account'; export * from './get-embedded-urls'; @@ -5,6 +7,7 @@ export * from './get-signature-request'; export * from './get-template'; export * from './list-signature-requests'; export * from './list-templates'; +export * from './manage-bulk-send-jobs'; export * from './manage-signature-request'; export * from './manage-team'; export * from './manage-template'; diff --git a/integrations/hellosign/src/tools/manage-bulk-send-jobs.ts b/integrations/hellosign/src/tools/manage-bulk-send-jobs.ts new file mode 100644 index 0000000000..dbbf829054 --- /dev/null +++ b/integrations/hellosign/src/tools/manage-bulk-send-jobs.ts @@ -0,0 +1,122 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { hellosignServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let bulkSendJobSchema = z.object({ + bulkSendJobId: z.string().describe('Bulk send job ID'), + total: z.number().optional().describe('Number of signature requests in the job'), + isCreator: z.boolean().optional().describe('Whether the authenticated account created it'), + createdAt: z.string().optional().describe('Creation timestamp (ISO 8601)') +}); + +let signatureRequestSummarySchema = z.object({ + signatureRequestId: z.string().describe('Signature request ID'), + title: z.string().optional().describe('Title'), + subject: z.string().optional().describe('Subject'), + isComplete: z.boolean().optional().describe('Whether all signers completed'), + isDeclined: z.boolean().optional().describe('Whether a signer declined'), + hasError: z.boolean().optional().describe('Whether Dropbox Sign reports an error'), + requesterEmailAddress: z.string().optional().describe('Requester email address') +}); + +let mapBulkSendJob = (job: any) => ({ + bulkSendJobId: job.bulk_send_job_id, + total: job.total, + isCreator: job.is_creator, + createdAt: job.created_at ? new Date(job.created_at * 1000).toISOString() : undefined +}); + +let mapSignatureRequest = (request: any) => ({ + signatureRequestId: request.signature_request_id, + title: request.title, + subject: request.subject, + isComplete: request.is_complete, + isDeclined: request.is_declined, + hasError: request.has_error, + requesterEmailAddress: request.requester_email_address +}); + +export let manageBulkSendJobs = SlateTool.create(spec, { + name: 'Manage Bulk Send Jobs', + key: 'manage_bulk_send_jobs', + description: `List Dropbox Sign bulk send jobs or retrieve a specific job with its generated signature requests.`, + instructions: [ + 'Use action "list" to page through accessible bulk send jobs.', + 'Use action "get" with bulkSendJobId to retrieve a job and its generated signature requests.' + ], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + action: z.enum(['list', 'get']).describe('Action to perform'), + bulkSendJobId: z.string().optional().describe('Required for action "get"'), + page: z.number().optional().describe('Page number (default 1)'), + pageSize: z.number().optional().describe('Results per page, 1-100 (default 20)') + }) + ) + .output( + z.object({ + action: z.string().describe('Action performed'), + bulkSendJobs: z + .array(bulkSendJobSchema) + .optional() + .describe('Bulk send jobs returned by list'), + bulkSendJob: bulkSendJobSchema.optional().describe('Bulk send job returned by get'), + signatureRequests: z + .array(signatureRequestSummarySchema) + .optional() + .describe('Signature requests generated by the job'), + page: z.number().optional().describe('Current page'), + numPages: z.number().optional().describe('Total pages'), + numResults: z.number().optional().describe('Total results') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + authMethod: ctx.auth.authMethod + }); + + if (ctx.input.action === 'get') { + if (!ctx.input.bulkSendJobId) { + throw hellosignServiceError('bulkSendJobId is required for the "get" action.'); + } + + let result = await client.getBulkSendJob( + ctx.input.bulkSendJobId, + ctx.input.page, + ctx.input.pageSize + ); + let listInfo = result.list_info; + + return { + output: { + action: ctx.input.action, + bulkSendJob: mapBulkSendJob(result.bulk_send_job), + signatureRequests: (result.signature_requests || []).map(mapSignatureRequest), + page: listInfo?.page, + numPages: listInfo?.num_pages, + numResults: listInfo?.num_results + }, + message: `Bulk send job **${ctx.input.bulkSendJobId}** retrieved.` + }; + } + + let result = await client.listBulkSendJobs(ctx.input.page, ctx.input.pageSize); + return { + output: { + action: ctx.input.action, + bulkSendJobs: result.bulkSendJobs.map(mapBulkSendJob), + page: result.listInfo.page, + numPages: result.listInfo.numPages, + numResults: result.listInfo.numResults + }, + message: `Found **${result.listInfo.numResults}** bulk send job(s). Showing page ${result.listInfo.page} of ${result.listInfo.numPages}.` + }; + }) + .build(); diff --git a/integrations/hellosign/src/tools/manage-signature-request.ts b/integrations/hellosign/src/tools/manage-signature-request.ts index 4d631263b0..5a8f0215d8 100644 --- a/integrations/hellosign/src/tools/manage-signature-request.ts +++ b/integrations/hellosign/src/tools/manage-signature-request.ts @@ -1,14 +1,16 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { hellosignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageSignatureRequest = SlateTool.create(spec, { name: 'Manage Signature Request', key: 'manage_signature_request', - description: `Perform actions on an existing signature request: cancel it, remove your access, send a reminder to a signer, or update signer details. Combine related management actions into a single call.`, + description: `Perform actions on an existing signature request: cancel it, release an on-hold request, remove your access, send a reminder to a signer, or update signer details. Combine related management actions into a single call.`, instructions: [ 'Use action "cancel" to permanently cancel an incomplete request.', + 'Use action "release_hold" to send a prepared/on-hold signature request.', 'Use action "remove" to remove your access to a completed request (irreversible).', 'Use action "remind" to send a reminder to a specific signer by email.', 'Use action "update" to change a signer\'s email, name, or the request expiration.' @@ -21,7 +23,9 @@ export let manageSignatureRequest = SlateTool.create(spec, { .input( z.object({ signatureRequestId: z.string().describe('ID of the signature request'), - action: z.enum(['cancel', 'remove', 'remind', 'update']).describe('Action to perform'), + action: z + .enum(['cancel', 'release_hold', 'remove', 'remind', 'update']) + .describe('Action to perform'), reminderEmailAddress: z .string() .optional() @@ -65,13 +69,19 @@ export let manageSignatureRequest = SlateTool.create(spec, { await client.cancelSignatureRequest(signatureRequestId); break; + case 'release_hold': + await client.releaseOnHoldSignatureRequest(signatureRequestId); + break; + case 'remove': await client.removeSignatureRequest(signatureRequestId); break; case 'remind': if (!ctx.input.reminderEmailAddress) { - throw new Error('reminderEmailAddress is required for the "remind" action'); + throw hellosignServiceError( + 'reminderEmailAddress is required for the "remind" action.' + ); } await client.sendReminder( signatureRequestId, @@ -81,6 +91,16 @@ export let manageSignatureRequest = SlateTool.create(spec, { break; case 'update': + if ( + !ctx.input.signatureId && + !ctx.input.newEmailAddress && + !ctx.input.newSignerName && + ctx.input.expiresAt === undefined + ) { + throw hellosignServiceError( + 'Provide signatureId, newEmailAddress, newSignerName, or expiresAt for the "update" action.' + ); + } await client.updateSignatureRequest({ signatureRequestId, signatureId: ctx.input.signatureId, @@ -93,6 +113,7 @@ export let manageSignatureRequest = SlateTool.create(spec, { let actionLabels: Record = { cancel: 'canceled', + release_hold: 'released from hold', remove: 'removed', remind: 'reminder sent', update: 'updated' diff --git a/integrations/hellosign/src/tools/manage-team.ts b/integrations/hellosign/src/tools/manage-team.ts index 4bc3b468b1..fc0440c2cd 100644 --- a/integrations/hellosign/src/tools/manage-team.ts +++ b/integrations/hellosign/src/tools/manage-team.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { hellosignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageTeam = SlateTool.create(spec, { @@ -54,7 +55,7 @@ export let manageTeam = SlateTool.create(spec, { case 'update_name': { if (!ctx.input.teamName) { - throw new Error('teamName is required for "update_name" action'); + throw hellosignServiceError('teamName is required for the "update_name" action.'); } let team = await client.updateTeam(ctx.input.teamName); teamName = team.name; @@ -62,6 +63,11 @@ export let manageTeam = SlateTool.create(spec, { } case 'add_member': { + if (!ctx.input.memberEmailAddress && !ctx.input.memberAccountId) { + throw hellosignServiceError( + 'memberEmailAddress or memberAccountId is required for the "add_member" action.' + ); + } let team = await client.addTeamMember({ emailAddress: ctx.input.memberEmailAddress, accountId: ctx.input.memberAccountId, @@ -72,6 +78,11 @@ export let manageTeam = SlateTool.create(spec, { } case 'remove_member': { + if (!ctx.input.memberEmailAddress && !ctx.input.memberAccountId) { + throw hellosignServiceError( + 'memberEmailAddress or memberAccountId is required for the "remove_member" action.' + ); + } let team = await client.removeTeamMember({ emailAddress: ctx.input.memberEmailAddress, accountId: ctx.input.memberAccountId diff --git a/integrations/hellosign/src/tools/manage-template.ts b/integrations/hellosign/src/tools/manage-template.ts index 4d1f1e5926..07f97bc2bb 100644 --- a/integrations/hellosign/src/tools/manage-template.ts +++ b/integrations/hellosign/src/tools/manage-template.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { hellosignServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageTemplate = SlateTool.create(spec, { @@ -53,6 +54,11 @@ export let manageTemplate = SlateTool.create(spec, { switch (action) { case 'update': + if (!ctx.input.title && !ctx.input.subject && !ctx.input.message) { + throw hellosignServiceError( + 'Provide title, subject, or message for the "update" action.' + ); + } await client.updateTemplate(templateId, { title: ctx.input.title, subject: ctx.input.subject, @@ -65,6 +71,11 @@ export let manageTemplate = SlateTool.create(spec, { break; case 'add_user': + if (!ctx.input.userEmailAddress && !ctx.input.userAccountId) { + throw hellosignServiceError( + 'userEmailAddress or userAccountId is required for the "add_user" action.' + ); + } await client.addTemplateUser(templateId, { accountId: ctx.input.userAccountId, emailAddress: ctx.input.userEmailAddress @@ -72,6 +83,11 @@ export let manageTemplate = SlateTool.create(spec, { break; case 'remove_user': + if (!ctx.input.userEmailAddress && !ctx.input.userAccountId) { + throw hellosignServiceError( + 'userEmailAddress or userAccountId is required for the "remove_user" action.' + ); + } await client.removeTemplateUser(templateId, { accountId: ctx.input.userAccountId, emailAddress: ctx.input.userEmailAddress diff --git a/integrations/hellosign/src/tools/send-signature-request.ts b/integrations/hellosign/src/tools/send-signature-request.ts index 86039a32ef..58ef050ce3 100644 --- a/integrations/hellosign/src/tools/send-signature-request.ts +++ b/integrations/hellosign/src/tools/send-signature-request.ts @@ -24,9 +24,10 @@ let signerSchema = z.object({ export let sendSignatureRequest = SlateTool.create(spec, { name: 'Send Signature Request', key: 'send_signature_request', - description: `Send documents for electronic signature to one or more signers via email. Supports uploading documents via URL, setting signer order, adding CC recipients, access codes for signer authentication, and attaching metadata.`, + description: `Send documents for electronic signature to one or more signers via email or as an embedded Dropbox Sign request. Supports document URLs, signer order, CC recipients, access codes for signer authentication, and metadata.`, instructions: [ 'Provide at least one file URL and at least one signer.', + 'Use embeddedSigning=true with clientId to create an embedded signing request.', 'Use testMode=true for testing without consuming signature requests.' ], tags: { @@ -72,7 +73,11 @@ export let sendSignatureRequest = SlateTool.create(spec, { clientId: z .string() .optional() - .describe('API App client ID for branding and embedded flows') + .describe('API App client ID for branding and embedded signing flows'), + embeddedSigning: z + .boolean() + .optional() + .describe('Create an embedded signing request instead of an email signing request') }) ) .output( @@ -123,7 +128,8 @@ export let sendSignatureRequest = SlateTool.create(spec, { useTextTags: ctx.input.useTextTags, signingRedirectUrl: ctx.input.signingRedirectUrl, testMode: ctx.input.testMode, - clientId: ctx.input.clientId + clientId: ctx.input.clientId, + embeddedSigning: ctx.input.embeddedSigning }); let signatures = (result.signatures || []).map((s: any) => ({ diff --git a/integrations/hellosign/src/tools/send-template-request.ts b/integrations/hellosign/src/tools/send-template-request.ts index 04f141d481..bc3a5f9242 100644 --- a/integrations/hellosign/src/tools/send-template-request.ts +++ b/integrations/hellosign/src/tools/send-template-request.ts @@ -6,10 +6,11 @@ import { spec } from '../spec'; export let sendTemplateRequest = SlateTool.create(spec, { name: 'Send Template Signature Request', key: 'send_template_request', - description: `Send a signature request based on one or more existing templates. Templates define the document layout, signer roles, and form fields. You assign actual signers to the template roles and optionally pre-fill custom fields.`, + description: `Send an email or embedded signature request based on one or more existing templates. Templates define the document layout, signer roles, and form fields. You assign actual signers to the template roles and optionally pre-fill custom fields.`, instructions: [ 'Signer roles must match the roles defined in the template.', - 'Custom field names must match the merge fields defined in the template.' + 'Custom field names must match the merge fields defined in the template.', + 'Use embeddedSigning=true with clientId to create embedded template signing requests.' ], tags: { destructive: false, @@ -60,7 +61,11 @@ export let sendTemplateRequest = SlateTool.create(spec, { .optional() .describe('URL to redirect signers to after signing'), testMode: z.boolean().optional().describe('Enable test mode'), - clientId: z.string().optional().describe('API App client ID') + clientId: z.string().optional().describe('API App client ID'), + embeddedSigning: z + .boolean() + .optional() + .describe('Create embedded signing requests instead of email signing requests') }) ) .output( @@ -104,7 +109,8 @@ export let sendTemplateRequest = SlateTool.create(spec, { metadata: ctx.input.metadata, signingRedirectUrl: ctx.input.signingRedirectUrl, testMode: ctx.input.testMode, - clientId: ctx.input.clientId + clientId: ctx.input.clientId, + embeddedSigning: ctx.input.embeddedSigning }); let signatures = (result.signatures || []).map((s: any) => ({ diff --git a/integrations/helpscout/package.json b/integrations/helpscout/package.json index 8fb21f60f2..f73ebd84ff 100644 --- a/integrations/helpscout/package.json +++ b/integrations/helpscout/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/helpscout/src/auth.ts b/integrations/helpscout/src/auth.ts index b6926d61ac..74d2f1b640 100644 --- a/integrations/helpscout/src/auth.ts +++ b/integrations/helpscout/src/auth.ts @@ -1,5 +1,11 @@ -import { createAxios, SlateAuth } from 'slates'; +import { + createApiServiceError, + createAxios, + normalizeOAuthTokenResponse, + SlateAuth +} from 'slates'; import { z } from 'zod'; +import { withHelpScoutErrorHandling } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -53,7 +59,7 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let http = createAxios(); + let http = withHelpScoutErrorHandling(createAxios(), 'OAuth token exchange'); let response = await http.post( 'https://api.helpscout.net/v2/oauth2/token', @@ -68,18 +74,16 @@ export let auth = SlateAuth.create() } ); - let data = response.data; - - let expiresAt: string | undefined; - if (data.expires_in) { - expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString(); - } + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Help Scout', + operation: 'token exchange' + }); return { output: { - token: data.access_token, - refreshToken: data.refresh_token, - expiresAt, + token: token.token, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt, docsApiKey: ctx.input.docsApiKey || undefined }, input: ctx.input @@ -88,10 +92,12 @@ export let auth = SlateAuth.create() handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - return { output: ctx.output }; + throw createApiServiceError( + 'Cannot refresh Help Scout OAuth token because no refresh token was saved.' + ); } - let http = createAxios(); + let http = withHelpScoutErrorHandling(createAxios(), 'OAuth token refresh'); let response = await http.post( 'https://api.helpscout.net/v2/oauth2/token', @@ -106,30 +112,32 @@ export let auth = SlateAuth.create() } ); - let data = response.data; - - let expiresAt: string | undefined; - if (data.expires_in) { - expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString(); - } + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Help Scout', + operation: 'token refresh', + previousRefreshToken: ctx.output.refreshToken + }); return { output: { - token: data.access_token, - refreshToken: data.refresh_token ?? ctx.output.refreshToken, - expiresAt, + token: token.token, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt, docsApiKey: ctx.output.docsApiKey } }; }, getProfile: async (ctx: any) => { - let http = createAxios({ - baseURL: 'https://api.helpscout.net/v2', - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + let http = withHelpScoutErrorHandling( + createAxios({ + baseURL: 'https://api.helpscout.net/v2', + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }), + 'profile request' + ); let response = await http.get('/users/me'); let user = response.data; @@ -159,7 +167,7 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { - let http = createAxios(); + let http = withHelpScoutErrorHandling(createAxios(), 'client credentials token request'); let response = await http.post( 'https://api.helpscout.net/v2/oauth2/token', @@ -173,30 +181,31 @@ export let auth = SlateAuth.create() } ); - let data = response.data; - - let expiresAt: string | undefined; - if (data.expires_in) { - expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString(); - } + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Help Scout', + operation: 'client credentials token request' + }); return { output: { - token: data.access_token, + token: token.token, refreshToken: undefined, - expiresAt, + expiresAt: token.expiresAt, docsApiKey: ctx.input.docsApiKey || undefined } }; }, getProfile: async (ctx: any) => { - let http = createAxios({ - baseURL: 'https://api.helpscout.net/v2', - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + let http = withHelpScoutErrorHandling( + createAxios({ + baseURL: 'https://api.helpscout.net/v2', + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }), + 'profile request' + ); let response = await http.get('/users/me'); let user = response.data; diff --git a/integrations/helpscout/src/index.ts b/integrations/helpscout/src/index.ts index 363f63a550..db527ea871 100644 --- a/integrations/helpscout/src/index.ts +++ b/integrations/helpscout/src/index.ts @@ -5,6 +5,7 @@ import { createConversation, createCustomer, deleteConversation, + deleteCustomer, getConversation, getCustomer, getReport, @@ -44,6 +45,7 @@ export let provider = Slate.create({ getCustomer, createCustomer, updateCustomer, + deleteCustomer, manageOrganization, listMailboxes, manageTags, diff --git a/integrations/helpscout/src/lib/client.ts b/integrations/helpscout/src/lib/client.ts index 7441836220..a13d3fd4ea 100644 --- a/integrations/helpscout/src/lib/client.ts +++ b/integrations/helpscout/src/lib/client.ts @@ -1,16 +1,20 @@ import { createAxios } from 'slates'; +import { withHelpScoutErrorHandling } from './errors'; export class HelpScoutClient { private http: ReturnType; constructor(token: string) { - this.http = createAxios({ - baseURL: 'https://api.helpscout.net/v2', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); + this.http = withHelpScoutErrorHandling( + createAxios({ + baseURL: 'https://api.helpscout.net/v2', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }), + 'Inbox API request' + ); } // ─── Conversations ────────────────────────────────────────── @@ -78,7 +82,7 @@ export class HelpScoutClient { }>; tags?: string[]; status?: string; - assignTo?: number; + assignTo?: number | null; autoReply?: boolean; }) { let body: Record = { @@ -91,7 +95,7 @@ export class HelpScoutClient { autoReply: data.autoReply }; if (data.tags) body.tags = data.tags; - if (data.assignTo) body.assignTo = data.assignTo; + if (data.assignTo !== undefined) body.assignTo = data.assignTo; let response = await this.http.post('/conversations', body); let location = response.headers?.['resource-id'] ?? response.headers?.location; @@ -103,10 +107,11 @@ export class HelpScoutClient { operations: Array<{ op: string; path: string; - value: any; + value?: any; }> ) { - await this.http.patch(`/conversations/${conversationId}`, operations, { + let body = operations.length === 1 ? operations[0] : operations; + await this.http.patch(`/conversations/${conversationId}`, body, { headers: { 'Content-Type': 'application/json' } }); } @@ -116,19 +121,17 @@ export class HelpScoutClient { } async updateConversationStatus(conversationId: number, status: string) { - await this.http.put(`/conversations/${conversationId}/status`, { - status, - op: 'replace', - path: '/status' - }); + await this.updateConversation(conversationId, [ + { op: 'replace', path: '/status', value: status } + ]); } - async assignConversation(conversationId: number, assignTo: number) { - await this.http.put(`/conversations/${conversationId}/assignee`, { - assignTo, - op: 'replace', - path: '/assignTo' - }); + async assignConversation(conversationId: number, assignTo: number | null) { + await this.updateConversation(conversationId, [ + assignTo === null + ? { op: 'remove', path: '/assignTo' } + : { op: 'replace', path: '/assignTo', value: assignTo } + ]); } async updateConversationTags(conversationId: number, tags: string[]) { @@ -194,6 +197,19 @@ export class HelpScoutClient { }); } + async createChatThread( + conversationId: number, + data: { + text: string; + customer: { email?: string; id?: number }; + } + ) { + await this.http.post(`/conversations/${conversationId}/chats`, { + text: data.text, + customer: data.customer + }); + } + // ─── Customers ────────────────────────────────────────────── async listCustomers( diff --git a/integrations/helpscout/src/lib/docs-client.ts b/integrations/helpscout/src/lib/docs-client.ts index 5aa4153520..425476c7a9 100644 --- a/integrations/helpscout/src/lib/docs-client.ts +++ b/integrations/helpscout/src/lib/docs-client.ts @@ -1,17 +1,21 @@ import { createAxios } from 'slates'; +import { withHelpScoutErrorHandling } from './errors'; export class DocsClient { private http: ReturnType; constructor(apiKey: string) { let encoded = btoa(`${apiKey}:X`); - this.http = createAxios({ - baseURL: 'https://docsapi.helpscout.net/v1', - headers: { - Authorization: `Basic ${encoded}`, - 'Content-Type': 'application/json' - } - }); + this.http = withHelpScoutErrorHandling( + createAxios({ + baseURL: 'https://docsapi.helpscout.net/v1', + headers: { + Authorization: `Basic ${encoded}`, + 'Content-Type': 'application/json' + } + }), + 'Docs API request' + ); } // ─── Sites ────────────────────────────────────────────────── diff --git a/integrations/helpscout/src/lib/errors.ts b/integrations/helpscout/src/lib/errors.ts new file mode 100644 index 0000000000..fc56ac3a3e --- /dev/null +++ b/integrations/helpscout/src/lib/errors.ts @@ -0,0 +1,101 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let message = value.trim(); + if (message && !messages.includes(message)) { + messages.push(message); + } +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectMessages(item, messages); + } + return; + } + + if (!isRecord(value)) { + pushMessage(messages, value); + return; + } + + for (let key of ['message', 'error', 'error_description', 'title', 'detail']) { + pushMessage(messages, value[key]); + } + + for (let nested of Object.values(value)) { + if (Array.isArray(nested) || isRecord(nested)) { + collectMessages(nested, messages); + } + } +}; + +let extractHelpScoutMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectMessages(response?.data, messages); + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let helpscoutServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let helpscoutApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = helpscoutServiceError( + `Help Scout API ${operation} failed: ${statusLabel}${extractHelpScoutMessage(error)}` + ); + + serviceError.data.reason = 'helpscout_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let withHelpScoutErrorHandling = ( + http: T, + operation = 'request' +) => { + http.interceptors.response.use( + (response: unknown) => response, + (error: unknown) => Promise.reject(helpscoutApiError(error, operation)) + ); + + return http; +}; diff --git a/integrations/helpscout/src/tools/add-thread.ts b/integrations/helpscout/src/tools/add-thread.ts index 5f6b34e955..fde0bddb23 100644 --- a/integrations/helpscout/src/tools/add-thread.ts +++ b/integrations/helpscout/src/tools/add-thread.ts @@ -1,31 +1,33 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { HelpScoutClient } from '../lib/client'; +import { helpscoutServiceError } from '../lib/errors'; import { spec } from '../spec'; export let addThread = SlateTool.create(spec, { name: 'Add Thread', key: 'add_thread', - description: `Add a reply, note, or phone thread to an existing conversation. Agent replies send actual emails to the customer. Notes are internal-only. Phone threads log phone calls.`, + description: `Add a reply, note, phone, or chat thread to an existing conversation. Agent replies send actual emails to the customer. Notes are internal-only. Phone and chat threads log non-email customer interactions.`, instructions: [ 'Use "reply" to send an email reply to the customer. Requires customer email or ID.', 'Use "note" for internal-only comments not visible to the customer.', - 'Use "phone" to log a phone call. Requires customer email or ID.' + 'Use "phone" to log a phone call. Requires customer email or ID.', + 'Use "chat" to log a chat transcript. Requires customer email or ID.' ] }) .input( z.object({ conversationId: z.number().describe('Conversation ID to add the thread to'), - type: z.enum(['reply', 'note', 'phone']).describe('Type of thread to add'), + type: z.enum(['reply', 'note', 'phone', 'chat']).describe('Type of thread to add'), text: z.string().describe('Thread content (HTML supported)'), customerEmail: z .string() .optional() - .describe('Customer email (required for reply and phone threads)'), + .describe('Customer email (required for reply, phone, and chat threads)'), customerId: z .number() .optional() - .describe('Customer ID (alternative to customerEmail for reply and phone)'), + .describe('Customer ID (alternative to customerEmail for reply, phone, and chat)'), draft: z .boolean() .optional() @@ -52,6 +54,12 @@ export let addThread = SlateTool.create(spec, { customer.email = ctx.input.customerEmail; } + if (ctx.input.type !== 'note' && !customer.id && !customer.email) { + throw helpscoutServiceError( + 'Customer ID or customer email is required for reply, phone, and chat threads.' + ); + } + if (ctx.input.type === 'reply') { await client.createReply(ctx.input.conversationId, { text: ctx.input.text, @@ -68,6 +76,11 @@ export let addThread = SlateTool.create(spec, { text: ctx.input.text, customer }); + } else if (ctx.input.type === 'chat') { + await client.createChatThread(ctx.input.conversationId, { + text: ctx.input.text, + customer + }); } return { diff --git a/integrations/helpscout/src/tools/create-conversation.ts b/integrations/helpscout/src/tools/create-conversation.ts index 229745a46f..9733b99d2c 100644 --- a/integrations/helpscout/src/tools/create-conversation.ts +++ b/integrations/helpscout/src/tools/create-conversation.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { HelpScoutClient } from '../lib/client'; +import { helpscoutServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createConversation = SlateTool.create(spec, { @@ -32,7 +33,13 @@ export let createConversation = SlateTool.create(spec, { .enum(['active', 'pending', 'closed']) .optional() .describe('Initial conversation status'), - assignTo: z.number().optional().describe('User ID to assign the conversation to'), + assignTo: z + .number() + .nullable() + .optional() + .describe( + 'User ID to assign the conversation to. Use null to keep it explicitly unassigned.' + ), autoReply: z.boolean().optional().describe('Whether to send auto-reply to the customer') }) ) @@ -54,6 +61,12 @@ export let createConversation = SlateTool.create(spec, { customer.email = ctx.input.customerEmail; } + if (!customer.id && !customer.email) { + throw helpscoutServiceError( + 'Customer ID or customer email is required to create a conversation.' + ); + } + let result = await client.createConversation({ subject: ctx.input.subject, type: ctx.input.type, diff --git a/integrations/helpscout/src/tools/manage-customer.ts b/integrations/helpscout/src/tools/manage-customer.ts index 8203084478..521c0b0856 100644 --- a/integrations/helpscout/src/tools/manage-customer.ts +++ b/integrations/helpscout/src/tools/manage-customer.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { HelpScoutClient } from '../lib/client'; +import { helpscoutServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listCustomers = SlateTool.create(spec, { @@ -194,6 +195,13 @@ export let createCustomer = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = new HelpScoutClient(ctx.auth.token); + + if (!ctx.input.firstName && !ctx.input.emails?.length) { + throw helpscoutServiceError( + 'Provide at least a first name or email address to create a customer.' + ); + } + let result = await client.createCustomer({ firstName: ctx.input.firstName, lastName: ctx.input.lastName, @@ -299,6 +307,12 @@ export let updateCustomer = SlateTool.create(spec, { updated.push(`removed ${ctx.input.removePhoneIds.length} phone(s)`); } + if (updated.length === 0) { + throw helpscoutServiceError( + 'Provide at least one customer field or contact method to update.' + ); + } + return { output: { customerId: ctx.input.customerId, @@ -308,3 +322,34 @@ export let updateCustomer = SlateTool.create(spec, { }; }) .build(); + +export let deleteCustomer = SlateTool.create(spec, { + name: 'Delete Customer', + key: 'delete_customer', + description: `Permanently delete a customer and their associated survey responses and conversations. This supports Help Scout's GDPR erasure workflow and cannot be undone.`, + tags: { destructive: true } +}) + .input( + z.object({ + customerId: z.number().describe('Customer ID to permanently delete') + }) + ) + .output( + z.object({ + customerId: z.number().describe('Deleted customer ID'), + deleted: z.boolean().describe('Whether the deletion was successful') + }) + ) + .handleInvocation(async ctx => { + let client = new HelpScoutClient(ctx.auth.token); + await client.deleteCustomer(ctx.input.customerId); + + return { + output: { + customerId: ctx.input.customerId, + deleted: true + }, + message: `Deleted customer **#${ctx.input.customerId}**.` + }; + }) + .build(); diff --git a/integrations/helpscout/src/tools/manage-docs.ts b/integrations/helpscout/src/tools/manage-docs.ts index 8b9709e88f..809f867377 100644 --- a/integrations/helpscout/src/tools/manage-docs.ts +++ b/integrations/helpscout/src/tools/manage-docs.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { DocsClient } from '../lib/docs-client'; +import { helpscoutServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageDocs = SlateTool.create(spec, { @@ -119,7 +120,7 @@ export let manageDocs = SlateTool.create(spec, { ) .handleInvocation(async ctx => { if (!ctx.auth.docsApiKey) { - throw new Error( + throw helpscoutServiceError( 'Docs API key is not configured. Please set it during authentication setup.' ); } @@ -141,7 +142,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'list_collections': { - if (!ctx.input.siteId) throw new Error('Site ID is required'); + if (!ctx.input.siteId) throw helpscoutServiceError('Site ID is required'); let data = await client.listCollections(ctx.input.siteId, { page: ctx.input.page }); let collections = (data?.collections?.items ?? data?.items ?? []).map((c: any) => ({ collectionId: c.id, @@ -156,7 +157,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'get_collection': { - if (!ctx.input.collectionId) throw new Error('Collection ID is required'); + if (!ctx.input.collectionId) throw helpscoutServiceError('Collection ID is required'); let data = await client.getCollection(ctx.input.collectionId); let c = data?.collection ?? data; return { @@ -170,7 +171,7 @@ export let manageDocs = SlateTool.create(spec, { case 'create_collection': { if (!ctx.input.siteId || !ctx.input.name) - throw new Error('Site ID and name are required'); + throw helpscoutServiceError('Site ID and name are required'); let data = await client.createCollection({ siteId: ctx.input.siteId, name: ctx.input.name, @@ -192,7 +193,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'update_collection': { - if (!ctx.input.collectionId) throw new Error('Collection ID is required'); + if (!ctx.input.collectionId) throw helpscoutServiceError('Collection ID is required'); await client.updateCollection(ctx.input.collectionId, { name: ctx.input.name, visibility: ctx.input.visibility, @@ -205,7 +206,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'delete_collection': { - if (!ctx.input.collectionId) throw new Error('Collection ID is required'); + if (!ctx.input.collectionId) throw helpscoutServiceError('Collection ID is required'); await client.deleteCollection(ctx.input.collectionId); return { output: { success: true }, @@ -214,7 +215,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'list_categories': { - if (!ctx.input.collectionId) throw new Error('Collection ID is required'); + if (!ctx.input.collectionId) throw helpscoutServiceError('Collection ID is required'); let data = await client.listCategories(ctx.input.collectionId, { page: ctx.input.page }); @@ -232,7 +233,7 @@ export let manageDocs = SlateTool.create(spec, { case 'create_category': { if (!ctx.input.collectionId || !ctx.input.name) - throw new Error('Collection ID and name are required'); + throw helpscoutServiceError('Collection ID and name are required'); await client.createCategory(ctx.input.collectionId, { name: ctx.input.name, description: ctx.input.description, @@ -245,7 +246,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'update_category': { - if (!ctx.input.categoryId) throw new Error('Category ID is required'); + if (!ctx.input.categoryId) throw helpscoutServiceError('Category ID is required'); await client.updateCategory(ctx.input.categoryId, { name: ctx.input.name, description: ctx.input.description, @@ -258,7 +259,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'delete_category': { - if (!ctx.input.categoryId) throw new Error('Category ID is required'); + if (!ctx.input.categoryId) throw helpscoutServiceError('Category ID is required'); await client.deleteCategory(ctx.input.categoryId); return { output: { success: true }, @@ -267,7 +268,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'list_articles': { - if (!ctx.input.collectionId) throw new Error('Collection ID is required'); + if (!ctx.input.collectionId) throw helpscoutServiceError('Collection ID is required'); let data = await client.listArticles(ctx.input.collectionId, { page: ctx.input.page, status: ctx.input.status, @@ -287,7 +288,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'get_article': { - if (!ctx.input.articleId) throw new Error('Article ID is required'); + if (!ctx.input.articleId) throw helpscoutServiceError('Article ID is required'); let data = await client.getArticle(ctx.input.articleId); let a = data?.article ?? data; return { @@ -309,7 +310,7 @@ export let manageDocs = SlateTool.create(spec, { case 'create_article': { if (!ctx.input.collectionId || !ctx.input.name || !ctx.input.text) { - throw new Error('Collection ID, name, and text are required'); + throw helpscoutServiceError('Collection ID, name, and text are required'); } let data = await client.createArticle(ctx.input.collectionId, { name: ctx.input.name, @@ -333,7 +334,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'update_article': { - if (!ctx.input.articleId) throw new Error('Article ID is required'); + if (!ctx.input.articleId) throw helpscoutServiceError('Article ID is required'); await client.updateArticle(ctx.input.articleId, { name: ctx.input.name, text: ctx.input.text, @@ -348,7 +349,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'delete_article': { - if (!ctx.input.articleId) throw new Error('Article ID is required'); + if (!ctx.input.articleId) throw helpscoutServiceError('Article ID is required'); await client.deleteArticle(ctx.input.articleId); return { output: { success: true }, @@ -357,7 +358,7 @@ export let manageDocs = SlateTool.create(spec, { } case 'search_articles': { - if (!ctx.input.query) throw new Error('Search query is required'); + if (!ctx.input.query) throw helpscoutServiceError('Search query is required'); let data = await client.searchArticles(ctx.input.query, { collectionId: ctx.input.collectionId, page: ctx.input.page, diff --git a/integrations/helpscout/src/tools/manage-organization.ts b/integrations/helpscout/src/tools/manage-organization.ts index 941c1370ba..a768b0b953 100644 --- a/integrations/helpscout/src/tools/manage-organization.ts +++ b/integrations/helpscout/src/tools/manage-organization.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { HelpScoutClient } from '../lib/client'; +import { helpscoutServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageOrganization = SlateTool.create(spec, { @@ -69,7 +70,8 @@ export let manageOrganization = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.organizationId) throw new Error('Organization ID is required'); + if (!ctx.input.organizationId) + throw helpscoutServiceError('Organization ID is required'); let data = await client.getOrganization(ctx.input.organizationId); return { output: { @@ -86,7 +88,7 @@ export let manageOrganization = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('Organization name is required'); + if (!ctx.input.name) throw helpscoutServiceError('Organization name is required'); let _result = await client.createOrganization({ name: ctx.input.name }); return { output: { success: true }, @@ -95,7 +97,9 @@ export let manageOrganization = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.organizationId) throw new Error('Organization ID is required'); + if (!ctx.input.organizationId) + throw helpscoutServiceError('Organization ID is required'); + if (!ctx.input.name) throw helpscoutServiceError('Organization name is required'); await client.updateOrganization(ctx.input.organizationId, { name: ctx.input.name }); return { output: { success: true }, @@ -104,7 +108,8 @@ export let manageOrganization = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.organizationId) throw new Error('Organization ID is required'); + if (!ctx.input.organizationId) + throw helpscoutServiceError('Organization ID is required'); await client.deleteOrganization(ctx.input.organizationId); return { output: { success: true }, diff --git a/integrations/helpscout/src/tools/manage-tags.ts b/integrations/helpscout/src/tools/manage-tags.ts index 928fe58f06..9ff4b5f89a 100644 --- a/integrations/helpscout/src/tools/manage-tags.ts +++ b/integrations/helpscout/src/tools/manage-tags.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { HelpScoutClient } from '../lib/client'; +import { helpscoutServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageTags = SlateTool.create(spec, { @@ -57,7 +58,8 @@ export let manageTags = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('Tag name is required for create action'); + if (!ctx.input.name) + throw helpscoutServiceError('Tag name is required for create action'); await client.createTag(ctx.input.name); return { output: { success: true }, @@ -66,8 +68,10 @@ export let manageTags = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.tagId) throw new Error('Tag ID is required for update action'); - if (!ctx.input.name) throw new Error('Tag name is required for update action'); + if (!ctx.input.tagId) + throw helpscoutServiceError('Tag ID is required for update action'); + if (!ctx.input.name) + throw helpscoutServiceError('Tag name is required for update action'); await client.updateTag(ctx.input.tagId, ctx.input.name); return { output: { success: true }, @@ -76,7 +80,8 @@ export let manageTags = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.tagId) throw new Error('Tag ID is required for delete action'); + if (!ctx.input.tagId) + throw helpscoutServiceError('Tag ID is required for delete action'); await client.deleteTag(ctx.input.tagId); return { output: { success: true }, diff --git a/integrations/helpscout/src/tools/manage-workflow.ts b/integrations/helpscout/src/tools/manage-workflow.ts index 1319009309..9fe4ae3fa6 100644 --- a/integrations/helpscout/src/tools/manage-workflow.ts +++ b/integrations/helpscout/src/tools/manage-workflow.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { HelpScoutClient } from '../lib/client'; +import { helpscoutServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageWorkflow = SlateTool.create(spec, { @@ -64,7 +65,7 @@ export let manageWorkflow = SlateTool.create(spec, { } if (ctx.input.action === 'activate') { - if (!ctx.input.workflowId) throw new Error('Workflow ID is required'); + if (!ctx.input.workflowId) throw helpscoutServiceError('Workflow ID is required'); await client.activateWorkflow(ctx.input.workflowId); return { output: { success: true }, @@ -73,7 +74,7 @@ export let manageWorkflow = SlateTool.create(spec, { } if (ctx.input.action === 'deactivate') { - if (!ctx.input.workflowId) throw new Error('Workflow ID is required'); + if (!ctx.input.workflowId) throw helpscoutServiceError('Workflow ID is required'); await client.deactivateWorkflow(ctx.input.workflowId); return { output: { success: true }, @@ -82,8 +83,9 @@ export let manageWorkflow = SlateTool.create(spec, { } if (ctx.input.action === 'run') { - if (!ctx.input.workflowId) throw new Error('Workflow ID is required'); - if (!ctx.input.conversationIds?.length) throw new Error('Conversation IDs are required'); + if (!ctx.input.workflowId) throw helpscoutServiceError('Workflow ID is required'); + if (!ctx.input.conversationIds?.length) + throw helpscoutServiceError('Conversation IDs are required'); await client.runWorkflowOnConversations(ctx.input.workflowId, ctx.input.conversationIds); return { output: { success: true }, diff --git a/integrations/helpscout/src/tools/update-conversation.ts b/integrations/helpscout/src/tools/update-conversation.ts index c56457808a..cc0b9bc442 100644 --- a/integrations/helpscout/src/tools/update-conversation.ts +++ b/integrations/helpscout/src/tools/update-conversation.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { HelpScoutClient } from '../lib/client'; +import { helpscoutServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateConversation = SlateTool.create(spec, { @@ -15,7 +16,11 @@ export let updateConversation = SlateTool.create(spec, { .enum(['active', 'pending', 'closed', 'spam']) .optional() .describe('New conversation status'), - assignTo: z.number().optional().describe('User ID to assign the conversation to'), + assignTo: z + .number() + .nullable() + .optional() + .describe('User ID to assign the conversation to. Use null to unassign.'), subject: z.string().optional().describe('New conversation subject'), tags: z.array(z.string()).optional().describe('Replace all tags with this list'), customFields: z @@ -69,6 +74,10 @@ export let updateConversation = SlateTool.create(spec, { updated.push('customFields'); } + if (updated.length === 0) { + throw helpscoutServiceError('Provide at least one conversation field to update.'); + } + return { output: { conversationId: ctx.input.conversationId, diff --git a/integrations/hotjar/package.json b/integrations/hotjar/package.json index af95780533..629d1c319a 100644 --- a/integrations/hotjar/package.json +++ b/integrations/hotjar/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/hotjar/src/auth.ts b/integrations/hotjar/src/auth.ts index 9aceb20517..49b36b83bd 100644 --- a/integrations/hotjar/src/auth.ts +++ b/integrations/hotjar/src/auth.ts @@ -1,5 +1,6 @@ -import { createAxios, SlateAuth } from 'slates'; +import { SlateAuth } from 'slates'; import { z } from 'zod'; +import { requestHotjarAccessToken } from './lib/oauth'; export let auth = SlateAuth.create() .output( @@ -23,29 +24,14 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { - let http = createAxios({ - baseURL: 'https://api.hotjar.io' - }); - - let params = new URLSearchParams(); - params.append('grant_type', 'client_credentials'); - params.append('client_id', ctx.input.clientId); - params.append('client_secret', ctx.input.clientSecret); - - let response = await http.post('/v1/oauth/token', params.toString(), { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - }); - - let expiresAt = new Date(Date.now() + response.data.expires_in * 1000).toISOString(); + let token = await requestHotjarAccessToken(ctx.input.clientId, ctx.input.clientSecret); return { output: { - token: response.data.access_token, + token: token.accessToken, clientId: ctx.input.clientId, clientSecret: ctx.input.clientSecret, - expiresAt + expiresAt: token.expiresAt } }; } diff --git a/integrations/hotjar/src/index.ts b/integrations/hotjar/src/index.ts index 097a3c2e29..3d0dceec8a 100644 --- a/integrations/hotjar/src/index.ts +++ b/integrations/hotjar/src/index.ts @@ -1,10 +1,10 @@ import { Slate } from 'slates'; import { spec } from './spec'; -import { getSurveyResponses, listSurveys, userLookup } from './tools'; +import { getSurvey, getSurveyResponses, listSurveys, userLookup } from './tools'; import { recordingTrigger, surveyResponseTrigger } from './triggers'; export let provider = Slate.create({ spec, - tools: [listSurveys, getSurveyResponses, userLookup], + tools: [listSurveys, getSurvey, getSurveyResponses, userLookup], triggers: [surveyResponseTrigger, recordingTrigger] }); diff --git a/integrations/hotjar/src/lib/client.ts b/integrations/hotjar/src/lib/client.ts index 05f8c9e6c9..0e18b6ed90 100644 --- a/integrations/hotjar/src/lib/client.ts +++ b/integrations/hotjar/src/lib/client.ts @@ -1,4 +1,6 @@ import { createAxios } from 'slates'; +import { hotjarApiError } from './errors'; +import { requestHotjarAccessToken } from './oauth'; export interface HotjarSurvey { id: string; @@ -8,6 +10,7 @@ export interface HotjarSurvey { responses_url: string; is_enabled: boolean; created_time: string; + updated_time?: string; sentiment_analysis_enabled: boolean; questions?: HotjarQuestion[]; } @@ -58,16 +61,52 @@ export interface UserLookupRequest { export class Client { private token: string; - - constructor(config: { token: string }) { + private clientId?: string; + private clientSecret?: string; + private expiresAt?: string; + + constructor(config: { + token: string; + clientId?: string; + clientSecret?: string; + expiresAt?: string; + }) { this.token = config.token; + this.clientId = config.clientId; + this.clientSecret = config.clientSecret; + this.expiresAt = config.expiresAt; + } + + private isTokenExpired() { + if (!this.expiresAt) { + return false; + } + + let expiresAt = Date.parse(this.expiresAt); + if (!Number.isFinite(expiresAt)) { + return false; + } + + return expiresAt <= Date.now() + 60_000; } - private createHttp() { + private async getToken() { + if (this.clientId && this.clientSecret && this.isTokenExpired()) { + let refreshed = await requestHotjarAccessToken(this.clientId, this.clientSecret); + this.token = refreshed.accessToken; + this.expiresAt = refreshed.expiresAt; + } + + return this.token; + } + + private async createHttp() { + let token = await this.getToken(); + return createAxios({ baseURL: 'https://api.hotjar.io', headers: { - Authorization: `Bearer ${this.token}`, + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json; charset=utf-8' } }); @@ -81,27 +120,35 @@ export class Client { cursor?: string; } ): Promise> { - let http = this.createHttp(); - let params: Record = {}; + try { + let http = await this.createHttp(); + let params: Record = {}; - if (options?.withQuestions) { - params.with_questions = 'true'; - } - if (options?.limit) { - params.limit = String(options.limit); - } - if (options?.cursor) { - params.cursor = options.cursor; - } + if (options?.withQuestions) { + params.with_questions = 'true'; + } + if (options?.limit) { + params.limit = String(options.limit); + } + if (options?.cursor) { + params.cursor = options.cursor; + } - let response = await http.get(`/v1/sites/${siteId}/surveys`, { params }); - return response.data; + let response = await http.get(`/v1/sites/${siteId}/surveys`, { params }); + return response.data; + } catch (error) { + throw hotjarApiError(error, 'list surveys'); + } } async getSurvey(siteId: string, surveyId: string): Promise { - let http = this.createHttp(); - let response = await http.get(`/v1/sites/${siteId}/surveys/${surveyId}`); - return response.data; + try { + let http = await this.createHttp(); + let response = await http.get(`/v1/sites/${siteId}/surveys/${surveyId}`); + return response.data; + } catch (error) { + throw hotjarApiError(error, 'get survey'); + } } async listSurveyResponses( @@ -112,25 +159,36 @@ export class Client { cursor?: string; } ): Promise> { - let http = this.createHttp(); - let params: Record = {}; + try { + let http = await this.createHttp(); + let params: Record = {}; - if (options?.limit) { - params.limit = String(options.limit); - } - if (options?.cursor) { - params.cursor = options.cursor; - } + if (options?.limit) { + params.limit = String(options.limit); + } + if (options?.cursor) { + params.cursor = options.cursor; + } - let response = await http.get(`/v1/sites/${siteId}/surveys/${surveyId}/responses`, { - params - }); - return response.data; + let response = await http.get(`/v1/sites/${siteId}/surveys/${surveyId}/responses`, { + params + }); + return response.data; + } catch (error) { + throw hotjarApiError(error, 'list survey responses'); + } } async userLookup(organizationId: string, request: UserLookupRequest): Promise { - let http = this.createHttp(); - let response = await http.post(`/v1/organizations/${organizationId}/user-lookup`, request); - return response.data; + try { + let http = await this.createHttp(); + let response = await http.post( + `/v1/organizations/${organizationId}/user-lookup`, + request + ); + return response.data; + } catch (error) { + throw hotjarApiError(error, 'user lookup'); + } } } diff --git a/integrations/hotjar/src/lib/errors.ts b/integrations/hotjar/src/lib/errors.ts new file mode 100644 index 0000000000..6b40b55301 --- /dev/null +++ b/integrations/hotjar/src/lib/errors.ts @@ -0,0 +1,78 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.msg); + addDetail(details, value.message); + addDetail(details, value.error_description); + addDetail(details, value.error); + addDetail(details, value.code); +}; + +let extractHotjarMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let hotjarServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let hotjarApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = hotjarServiceError( + `Hotjar API ${operation} failed: ${statusLabelFor(response)}${extractHotjarMessage(error)}` + ); + serviceError.data.reason = 'hotjar_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/hotjar/src/lib/oauth.ts b/integrations/hotjar/src/lib/oauth.ts new file mode 100644 index 0000000000..f5ff97d2eb --- /dev/null +++ b/integrations/hotjar/src/lib/oauth.ts @@ -0,0 +1,53 @@ +import { createApiServiceError, createAxios, normalizeOAuthTokenResponse } from 'slates'; +import { hotjarApiError } from './errors'; + +export type HotjarToken = { + accessToken: string; + expiresAt: string; +}; + +export let requestHotjarAccessToken = async ( + clientId: string, + clientSecret: string +): Promise => { + try { + let http = createAxios({ + baseURL: 'https://api.hotjar.io' + }); + + let params = new URLSearchParams(); + params.append('grant_type', 'client_credentials'); + params.append('client_id', clientId); + params.append('client_secret', clientSecret); + + let response = await http.post('/v1/oauth/token', params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Hotjar', + operation: 'token exchange', + required: true, + expiresInType: 'number', + accessTokenMessage: + 'Hotjar OAuth token response did not include access_token and expires_in.', + expiresInMessage: + 'Hotjar OAuth token response did not include access_token and expires_in.' + }); + + if (!token.expiresAt) { + throw createApiServiceError( + 'Hotjar OAuth token response did not include access_token and expires_in.' + ); + } + + return { + accessToken: token.token, + expiresAt: token.expiresAt + }; + } catch (error) { + throw hotjarApiError(error, 'OAuth token exchange'); + } +}; diff --git a/integrations/hotjar/src/tools/get-survey-responses.ts b/integrations/hotjar/src/tools/get-survey-responses.ts index 42ce3f9f18..0937827e7b 100644 --- a/integrations/hotjar/src/tools/get-survey-responses.ts +++ b/integrations/hotjar/src/tools/get-survey-responses.ts @@ -91,7 +91,12 @@ export let getSurveyResponses = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client({ + token: ctx.auth.token, + clientId: ctx.auth.clientId, + clientSecret: ctx.auth.clientSecret, + expiresAt: ctx.auth.expiresAt + }); let result = await client.listSurveyResponses(ctx.input.siteId, ctx.input.surveyId, { limit: ctx.input.limit, diff --git a/integrations/hotjar/src/tools/get-survey.ts b/integrations/hotjar/src/tools/get-survey.ts new file mode 100644 index 0000000000..8ee12f556d --- /dev/null +++ b/integrations/hotjar/src/tools/get-survey.ts @@ -0,0 +1,89 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getSurvey = SlateTool.create(spec, { + name: 'Get Survey', + key: 'get_survey', + description: `Retrieve metadata and question details for a specific Hotjar survey. Use this when you already know the survey ID and need the current survey configuration before exporting responses.`, + constraints: [ + 'Available on Ask Scale plans only.', + 'Rate limited to 3,000 requests per minute.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + siteId: z.string().describe('Hotjar site ID. Found on the Sites & Organizations page.'), + surveyId: z.string().describe('ID of the survey to retrieve.') + }) + ) + .output( + z.object({ + survey: z + .object({ + surveyId: z.string().describe('Unique survey identifier.'), + name: z.string().describe('Survey name.'), + type: z.string().describe('Survey type (e.g., "popover").'), + url: z.string().describe('Survey URL.'), + responsesUrl: z.string().describe('URL to access survey responses.'), + isEnabled: z.boolean().describe('Whether the survey is currently active.'), + createdTime: z.string().describe('When the survey was created.'), + updatedTime: z.string().optional().describe('When the survey was last updated.'), + sentimentAnalysisEnabled: z + .boolean() + .describe('Whether sentiment analysis is enabled.'), + questions: z + .array( + z.object({ + questionId: z.string().describe('Unique question identifier.'), + type: z + .string() + .describe('Question type (e.g., "nps", "short-text", "radio", etc.).'), + text: z.string().describe('Question text.'), + isRequired: z.boolean().describe('Whether the question requires an answer.') + }) + ) + .optional() + .describe('Survey questions, when returned by Hotjar.') + }) + .describe('Survey metadata and question details.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + clientId: ctx.auth.clientId, + clientSecret: ctx.auth.clientSecret, + expiresAt: ctx.auth.expiresAt + }); + + let survey = await client.getSurvey(ctx.input.siteId, ctx.input.surveyId); + + return { + output: { + survey: { + surveyId: survey.id, + name: survey.name, + type: survey.type, + url: survey.url, + responsesUrl: survey.responses_url, + isEnabled: survey.is_enabled, + createdTime: survey.created_time, + updatedTime: survey.updated_time, + sentimentAnalysisEnabled: survey.sentiment_analysis_enabled, + questions: survey.questions?.map(q => ({ + questionId: q.id, + type: q.type, + text: q.text, + isRequired: q.is_required + })) + } + }, + message: `Retrieved survey **${survey.name}** (${survey.id}).` + }; + }) + .build(); diff --git a/integrations/hotjar/src/tools/index.ts b/integrations/hotjar/src/tools/index.ts index 4f9d7d29b3..d162c1e708 100644 --- a/integrations/hotjar/src/tools/index.ts +++ b/integrations/hotjar/src/tools/index.ts @@ -1,3 +1,4 @@ +export * from './get-survey'; export * from './get-survey-responses'; export * from './list-surveys'; export * from './user-lookup'; diff --git a/integrations/hotjar/src/tools/list-surveys.ts b/integrations/hotjar/src/tools/list-surveys.ts index 8799a8a71e..98eb0f946e 100644 --- a/integrations/hotjar/src/tools/list-surveys.ts +++ b/integrations/hotjar/src/tools/list-surveys.ts @@ -42,6 +42,7 @@ export let listSurveys = SlateTool.create(spec, { responsesUrl: z.string().describe('URL to access survey responses.'), isEnabled: z.boolean().describe('Whether the survey is currently active.'), createdTime: z.string().describe('When the survey was created.'), + updatedTime: z.string().optional().describe('When the survey was last updated.'), sentimentAnalysisEnabled: z .boolean() .describe('Whether sentiment analysis is enabled.'), @@ -68,7 +69,12 @@ export let listSurveys = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { - let client = new Client({ token: ctx.auth.token }); + let client = new Client({ + token: ctx.auth.token, + clientId: ctx.auth.clientId, + clientSecret: ctx.auth.clientSecret, + expiresAt: ctx.auth.expiresAt + }); let result = await client.listSurveys(ctx.input.siteId, { withQuestions: ctx.input.withQuestions, @@ -84,6 +90,7 @@ export let listSurveys = SlateTool.create(spec, { responsesUrl: survey.responses_url, isEnabled: survey.is_enabled, createdTime: survey.created_time, + updatedTime: survey.updated_time, sentimentAnalysisEnabled: survey.sentiment_analysis_enabled, questions: survey.questions?.map(q => ({ questionId: q.id, diff --git a/integrations/hotjar/src/tools/user-lookup.ts b/integrations/hotjar/src/tools/user-lookup.ts index a8d9fd89e9..87f0d20455 100644 --- a/integrations/hotjar/src/tools/user-lookup.ts +++ b/integrations/hotjar/src/tools/user-lookup.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { hotjarServiceError } from '../lib/errors'; import { spec } from '../spec'; export let userLookup = SlateTool.create(spec, { @@ -54,16 +55,21 @@ export let userLookup = SlateTool.create(spec, { let organizationId = ctx.input.organizationId || ctx.config.organizationId; if (!organizationId) { - throw new Error( + throw hotjarServiceError( 'Organization ID is required. Provide it as input or set it in the global configuration.' ); } if (!ctx.input.email && !ctx.input.siteUserIdMap) { - throw new Error('At least one of email or siteUserIdMap must be provided.'); + throw hotjarServiceError('At least one of email or siteUserIdMap must be provided.'); } - let client = new Client({ token: ctx.auth.token }); + let client = new Client({ + token: ctx.auth.token, + clientId: ctx.auth.clientId, + clientSecret: ctx.auth.clientSecret, + expiresAt: ctx.auth.expiresAt + }); let requestBody: { data_subject_email?: string; diff --git a/integrations/huggingface/README.md b/integrations/huggingface/README.md index 465ee627bf..3eb8d6dfbc 100644 --- a/integrations/huggingface/README.md +++ b/integrations/huggingface/README.md @@ -8,41 +8,133 @@ Manage machine learning model, dataset, and Spaces repositories on Hugging Face Get information about the authenticated user, including username, email, organizations, and account details. -### Chat Completion +### Search Models -Run a chat completion using a model on the Hugging Face Inference API. Follows the OpenAI-compatible chat completions format. Supports conversation history with system, user, and assistant messages. +Search for machine learning models on Hugging Face Hub by keyword, author, library, pipeline task, and tags. -### Get Collection +### Search Datasets -Retrieve a Hugging Face collection by its slug. Returns the collection's title, description, and all items (models, datasets, spaces) it contains. +Search for datasets on Hugging Face Hub by keyword, author, tags, and sort order. -### List Discussions +### Search Spaces + +Search for Spaces on Hugging Face Hub by keyword, author, tags, and sort order. + +### Create Repository + +Create a new model, dataset, or Space repository on Hugging Face Hub. Supports setting visibility, SDK type (for Spaces), and license. + +### Delete Repository + +Permanently delete a model, dataset, or Space repository. + +### Duplicate Repository + +Duplicate a Space repository to a new Space. + +### Get Repository Info -List discussions and pull requests on a Hugging Face repository. Returns summaries including title, status, and whether each item is a PR. +Get metadata for a model, dataset, or Space repository. + +### Update Repository Visibility + +Change a repository's public/private visibility. ### List Repository Files -List files and directories in a Hugging Face repository at a given path and revision. Returns file metadata including type, size, and OID. +List files and directories in a Hugging Face repository at a path and revision. -### Create Repository +### Get File Content -Create a new model, dataset, or Space repository on Hugging Face Hub. Supports setting visibility, SDK type (for Spaces), and license. +Download repository file text as a Slate attachment with metadata. + +### Upload File + +Upload or update a text file in a repository through the commit API. + +### Delete File + +Delete a repository file through the commit API. + +### List Discussions + +List discussions and pull requests on a Hugging Face repository. + +### Get Discussion + +Get details, comments, and events for a discussion or pull request. + +### Create Discussion + +Create a discussion or pull request on a repository. + +### Comment on Discussion + +Post a markdown comment on a discussion or pull request. + +### Update Discussion Status + +Open, close, or merge a discussion or pull request. + +### List Collections + +List Hugging Face collections by owner, query, item, sort, and cursor. + +### Get Collection + +Retrieve a collection by slug and list its items. + +### Create Collection + +Create a collection for models, datasets, and Spaces. + +### Update Collection + +Update collection metadata, visibility, and theme. + +### Delete Collection + +Delete a collection without deleting its contained items. + +### Add Collection Item + +Add a model, dataset, or Space to a collection. + +### Remove Collection Item + +Remove an item from a collection. ### Get Space Runtime Get runtime information for a Space, including hardware, stage, SDK, and storage details. -### Search Datasets +### Control Space -Search for datasets on Hugging Face Hub. Filter by keyword, author, and tags. Results include dataset metadata such as downloads and likes. +Pause, restart, or change hardware for a Space. -### Search Models +### Manage Space Secrets -Search for machine learning models on Hugging Face Hub. Filter by keyword, author, library framework, pipeline task, and tags. Results include model metadata such as downloads, likes, and pipeline task. +List, add, or delete encrypted Space secrets. -### Search Spaces +### Manage Space Variables + +List, add, or delete public Space variables. + +### Chat Completion + +Run an OpenAI-compatible chat completion through Hugging Face Inference Providers. + +### Text Generation + +Generate text with a Hugging Face text-generation model. + +### Feature Extraction + +Generate embeddings for semantic search, RAG, clustering, and similarity workflows. + +### Run Inference -Search for Spaces (ML application demos) on Hugging Face Hub. Filter by keyword, author, and tags. Results include Space metadata such as SDK type and likes. +Run generic inference on a Hugging Face model with task-specific inputs and parameters. ## License diff --git a/integrations/huggingface/docs/SPEC.md b/integrations/huggingface/docs/SPEC.md index abab4f0ed5..80d657e9de 100644 --- a/integrations/huggingface/docs/SPEC.md +++ b/integrations/huggingface/docs/SPEC.md @@ -41,10 +41,18 @@ Supported OAuth scopes (with `openid` and `profile` always included): - `openid` – OpenID Connect identity - `profile` – User profile information - `email` – User email address +- `read-billing` – Know whether the user has a payment method set up - `read-repos` – Read access to repositories +- `gated-repos` – Read access to public gated repos the user has been granted access to +- `contribute-repos` – Create repositories and access those created by the app - `write-repos` – Write access to repositories - `manage-repos` – Manage repositories (create, delete, update settings) +- `read-collections` – Read access to collections +- `write-collections` – Create, update, and delete collections - `inference-api` – Make inference requests on behalf of the user +- `jobs` – Run Hugging Face Jobs +- `webhooks` – Manage webhooks +- `write-discussions` – Open and interact with discussions and pull requests ## Features diff --git a/integrations/huggingface/package.json b/integrations/huggingface/package.json index f1e2b22d29..3295c721ba 100644 --- a/integrations/huggingface/package.json +++ b/integrations/huggingface/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/huggingface/src/auth.ts b/integrations/huggingface/src/auth.ts index 3d7d65e278..9420e4f58d 100644 --- a/integrations/huggingface/src/auth.ts +++ b/integrations/huggingface/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { huggingFaceApiError } from './lib/errors'; let hfAxios = createAxios({ baseURL: 'https://huggingface.co' @@ -47,6 +48,26 @@ export let auth = SlateAuth.create() description: 'Manage repositories (create, delete, update settings)', scope: 'manage-repos' }, + { + title: 'Read Collections', + description: 'Read access to collections', + scope: 'read-collections' + }, + { + title: 'Write Collections', + description: 'Create, update, and delete collections', + scope: 'write-collections' + }, + { + title: 'Write Discussions', + description: 'Create and update discussions and pull requests', + scope: 'write-discussions' + }, + { + title: 'Webhooks', + description: 'Manage repository webhooks', + scope: 'webhooks' + }, { title: 'Inference API', description: 'Make inference requests on behalf of the user', @@ -69,27 +90,31 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let response = await hfAxios.post( - '/oauth/token', - new URLSearchParams({ - grant_type: 'authorization_code', - code: ctx.code, - redirect_uri: ctx.redirectUri, - client_id: ctx.clientId, - client_secret: ctx.clientSecret - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + try { + let response = await hfAxios.post( + '/oauth/token', + new URLSearchParams({ + grant_type: 'authorization_code', + code: ctx.code, + redirect_uri: ctx.redirectUri, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } } - } - ); + ); - return { - output: { - token: response.data.access_token - } - }; + return { + output: { + token: response.data.access_token + } + }; + } catch (error) { + throw huggingFaceApiError(error, 'exchange OAuth code'); + } }, handleTokenRefresh: async (ctx: any) => { @@ -101,11 +126,16 @@ export let auth = SlateAuth.create() }, getProfile: async (ctx: { output: { token: string }; input: any; scopes: string[] }) => { - let response = await hfAxios.get('/api/whoami-v2', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + let response: any; + try { + response = await hfAxios.get('/api/whoami-v2', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); + } catch (error) { + throw huggingFaceApiError(error, 'load OAuth profile'); + } let data = response.data; @@ -138,11 +168,16 @@ export let auth = SlateAuth.create() }, getProfile: async (ctx: { output: { token: string }; input: { token: string } }) => { - let response = await hfAxios.get('/api/whoami-v2', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + let response: any; + try { + response = await hfAxios.get('/api/whoami-v2', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); + } catch (error) { + throw huggingFaceApiError(error, 'load token profile'); + } let data = response.data; diff --git a/integrations/huggingface/src/index.ts b/integrations/huggingface/src/index.ts index d1368a5f75..739347182f 100644 --- a/integrations/huggingface/src/index.ts +++ b/integrations/huggingface/src/index.ts @@ -11,12 +11,15 @@ import { deleteCollectionTool, deleteFileTool, deleteRepositoryTool, + duplicateRepositoryTool, + featureExtractionTool, getCollectionTool, getDiscussionTool, getFileContentTool, getRepositoryInfoTool, getSpaceRuntimeTool, getUserInfoTool, + listCollectionsTool, listDiscussionsTool, listRepoFilesTool, manageSpaceSecretsTool, @@ -27,6 +30,7 @@ import { searchModelsTool, searchSpacesTool, textGenerationTool, + updateCollectionTool, updateDiscussionStatusTool, updateRepositoryVisibilityTool, uploadFileTool @@ -41,6 +45,7 @@ export let provider = Slate.create({ searchSpacesTool, createRepositoryTool, deleteRepositoryTool, + duplicateRepositoryTool, getRepositoryInfoTool, updateRepositoryVisibilityTool, listRepoFilesTool, @@ -53,7 +58,9 @@ export let provider = Slate.create({ commentOnDiscussionTool, updateDiscussionStatusTool, getCollectionTool, + listCollectionsTool, createCollectionTool, + updateCollectionTool, deleteCollectionTool, addCollectionItemTool, removeCollectionItemTool, @@ -63,6 +70,7 @@ export let provider = Slate.create({ manageSpaceVariablesTool, chatCompletionTool, textGenerationTool, + featureExtractionTool, runInferenceTool, getUserInfoTool ], diff --git a/integrations/huggingface/src/lib/client.ts b/integrations/huggingface/src/lib/client.ts index b881488674..54a0bbc2b3 100644 --- a/integrations/huggingface/src/lib/client.ts +++ b/integrations/huggingface/src/lib/client.ts @@ -1,11 +1,12 @@ import { createAxios } from 'slates'; +import { huggingFaceApiError, huggingFaceServiceError } from './errors'; let hubAxios = createAxios({ baseURL: 'https://huggingface.co' }); -let inferenceAxios = createAxios({ - baseURL: 'https://api-inference.huggingface.co' +let routerAxios = createAxios({ + baseURL: 'https://router.huggingface.co' }); export type RepoType = 'model' | 'dataset' | 'space'; @@ -51,6 +52,96 @@ export interface CollectionInfo { [key: string]: any; } +type AxiosResult = Promise<{ data: T }>; + +let encodePath = (path: string) => + path + .split('/') + .filter(segment => segment.length > 0) + .map(segment => encodeURIComponent(segment)) + .join('/'); + +let splitRepoId = (repoId: string) => { + let parts = repoId.split('/').filter(Boolean); + let namespace = parts[0]; + let repo = parts[1]; + if (parts.length !== 2 || !namespace || !repo) { + throw huggingFaceServiceError( + 'repoId must be a full Hugging Face repository ID in "namespace/name" format.' + ); + } + + return { + namespace, + repo + }; +}; + +let repoRoute = (prefix: string, repoId: string, ...segments: string[]) => { + let { namespace, repo } = splitRepoId(repoId); + let suffix = segments + .filter(segment => segment.length > 0) + .map(segment => encodePath(segment)) + .join('/'); + + return `/api/${prefix}/${encodeURIComponent(namespace)}/${encodeURIComponent(repo)}${ + suffix ? `/${suffix}` : '' + }`; +}; + +let discussionRoute = (repoType: RepoType, repoId: string, ...segments: string[]) => + repoRoute( + repoType === 'model' ? 'models' : `${repoType}s`, + repoId, + 'discussions', + ...segments + ); + +let repoResolveRoute = ( + repoType: RepoType, + repoId: string, + revision: string, + filePath: string +) => { + let { namespace, repo } = splitRepoId(repoId); + let prefix = repoType === 'model' ? '' : `/${repoType}s`; + + return `${prefix}/${encodeURIComponent(namespace)}/${encodeURIComponent(repo)}/resolve/${encodeURIComponent( + revision + )}/${encodePath(filePath)}`; +}; + +let collectionRoute = (slug: string, ...segments: string[]) => { + let [namespace, collectionSlug] = slug.split('/'); + if (!namespace || !collectionSlug) { + throw huggingFaceServiceError( + 'Collection slug must include the namespace, for example "username/collection-name-abc123".' + ); + } + + let suffix = segments + .filter(segment => segment.length > 0) + .map(segment => encodePath(segment)) + .join('/'); + + return `/api/collections/${encodeURIComponent(namespace)}/${encodeURIComponent(collectionSlug)}${ + suffix ? `/${suffix}` : '' + }`; +}; + +let collectionItemRoute = (slug: string, itemId: string) => { + let [namespace, collectionSlug] = slug.split('/'); + if (!namespace || !collectionSlug) { + throw huggingFaceServiceError( + 'Collection slug must include the namespace, for example "username/collection-name-abc123".' + ); + } + + return `/api/collections/${encodeURIComponent(namespace)}/${encodeURIComponent( + collectionSlug + )}/items/${encodeURIComponent(itemId)}`; +}; + export class HubClient { private token: string; @@ -64,13 +155,38 @@ export class HubClient { }; } + private jsonHeaders() { + return { + ...this.headers(), + 'Content-Type': 'application/json' + }; + } + + private async request(operation: string, run: () => AxiosResult): Promise { + try { + let response = await run(); + return response.data; + } catch (error) { + throw huggingFaceApiError(error, operation); + } + } + + private async requestVoid(operation: string, run: () => Promise): Promise { + try { + await run(); + } catch (error) { + throw huggingFaceApiError(error, operation); + } + } + // ---- User / Org ---- async whoami(): Promise { - let response = await hubAxios.get('/api/whoami-v2', { - headers: this.headers() - }); - return response.data; + return await this.request('get current user', async () => + hubAxios.get('/api/whoami-v2', { + headers: this.headers() + }) + ); } // ---- Repository Management ---- @@ -98,10 +214,11 @@ export class HubClient { if (params.sdk) body.sdk = params.sdk; if (params.license) body.license = params.license; - let response = await hubAxios.post('/api/repos/create', body, { - headers: this.headers() - }); - return response.data; + return await this.request('create repository', async () => + hubAxios.post('/api/repos/create', body, { + headers: this.jsonHeaders() + }) + ); } async deleteRepo(params: { @@ -115,10 +232,35 @@ export class HubClient { }; if (params.organization) body.organization = params.organization; - await hubAxios.delete('/api/repos/delete', { - headers: this.headers(), - data: body - }); + await this.requestVoid('delete repository', async () => + hubAxios.delete('/api/repos/delete', { + headers: this.jsonHeaders(), + data: body + }) + ); + } + + async duplicateSpace(params: { + sourceRepoId: string; + destinationRepoId: string; + private?: boolean; + visibility?: 'private' | 'public' | 'protected'; + hardware?: string; + sleepTimeSeconds?: number; + }): Promise { + let body: any = { + repository: params.destinationRepoId + }; + if (params.private !== undefined) body.private = params.private; + if (params.visibility) body.visibility = params.visibility; + if (params.hardware) body.hardware = params.hardware; + if (params.sleepTimeSeconds !== undefined) body.sleepTimeSeconds = params.sleepTimeSeconds; + + return await this.request('duplicate Space repository', async () => + hubAxios.post(repoRoute('spaces', params.sourceRepoId, 'duplicate'), body, { + headers: this.jsonHeaders() + }) + ); } async updateRepoVisibility(params: { @@ -127,12 +269,13 @@ export class HubClient { private: boolean; }): Promise { let prefix = this.repoTypePrefix(params.repoType); - let response = await hubAxios.put( - `/api/${prefix}/${params.repoId}/settings`, - { private: params.private }, - { headers: this.headers() } + return await this.request('update repository visibility', async () => + hubAxios.put( + repoRoute(prefix, params.repoId, 'settings'), + { private: params.private }, + { headers: this.jsonHeaders() } + ) ); - return response.data; } async getRepoInfo(params: { @@ -141,13 +284,14 @@ export class HubClient { revision?: string; }): Promise { let prefix = this.repoTypePrefix(params.repoType); - let url = `/api/${prefix}/${params.repoId}`; - if (params.revision) url += `/revision/${params.revision}`; + let url = repoRoute(prefix, params.repoId); + if (params.revision) url += `/revision/${encodeURIComponent(params.revision)}`; - let response = await hubAxios.get(url, { - headers: this.headers() - }); - return response.data; + return await this.request('get repository info', async () => + hubAxios.get(url, { + headers: this.headers() + }) + ); } // ---- Search ---- @@ -171,14 +315,15 @@ export class HubClient { if (params.library) queryParams.library = params.library; if (params.sort) queryParams.sort = params.sort; if (params.direction) queryParams.direction = params.direction; - if (params.limit) queryParams.limit = params.limit; - if (params.full) queryParams.full = params.full; - - let response = await hubAxios.get('/api/models', { - headers: this.headers(), - params: queryParams - }); - return response.data; + if (params.limit !== undefined) queryParams.limit = params.limit; + if (params.full !== undefined) queryParams.full = params.full; + + return await this.request('search models', async () => + hubAxios.get('/api/models', { + headers: this.headers(), + params: queryParams + }) + ); } async searchDatasets(params: { @@ -198,14 +343,15 @@ export class HubClient { if (params.tags) queryParams.tags = params.tags.join(','); if (params.sort) queryParams.sort = params.sort; if (params.direction) queryParams.direction = params.direction; - if (params.limit) queryParams.limit = params.limit; - if (params.full) queryParams.full = params.full; - - let response = await hubAxios.get('/api/datasets', { - headers: this.headers(), - params: queryParams - }); - return response.data; + if (params.limit !== undefined) queryParams.limit = params.limit; + if (params.full !== undefined) queryParams.full = params.full; + + return await this.request('search datasets', async () => + hubAxios.get('/api/datasets', { + headers: this.headers(), + params: queryParams + }) + ); } async searchSpaces(params: { @@ -225,14 +371,15 @@ export class HubClient { if (params.tags) queryParams.tags = params.tags.join(','); if (params.sort) queryParams.sort = params.sort; if (params.direction) queryParams.direction = params.direction; - if (params.limit) queryParams.limit = params.limit; - if (params.full) queryParams.full = params.full; - - let response = await hubAxios.get('/api/spaces', { - headers: this.headers(), - params: queryParams - }); - return response.data; + if (params.limit !== undefined) queryParams.limit = params.limit; + if (params.full !== undefined) queryParams.full = params.full; + + return await this.request('search spaces', async () => + hubAxios.get('/api/spaces', { + headers: this.headers(), + params: queryParams + }) + ); } // ---- File Operations ---- @@ -244,14 +391,14 @@ export class HubClient { path?: string; }): Promise { let prefix = this.repoTypePrefix(params.repoType); + let revision = params.revision || 'main'; let path = params.path || ''; - let url = `/api/${prefix}/${params.repoId}/tree/${params.revision || 'main'}`; - if (path) url += `/${path}`; - let response = await hubAxios.get(url, { - headers: this.headers() - }); - return response.data; + return await this.request('list repository files', async () => + hubAxios.get(repoRoute(prefix, params.repoId, 'tree', revision, path), { + headers: this.headers() + }) + ); } async getFileContent(params: { @@ -259,21 +406,39 @@ export class HubClient { repoId: string; filePath: string; revision?: string; - }): Promise { - let _prefix = this.repoTypePrefix(params.repoType); - let revision = params.revision || 'main'; - let url = `/${params.repoId}/raw/${revision}/${params.filePath}`; - if (params.repoType === 'dataset') { - url = `/datasets/${params.repoId}/raw/${revision}/${params.filePath}`; - } else if (params.repoType === 'space') { - url = `/spaces/${params.repoId}/raw/${revision}/${params.filePath}`; - } + }): Promise<{ content: string; contentType?: string; size: number }> { + let response = await this.request<{ content: string; contentType?: string; size: number }>( + 'download repository file', + async () => { + let res = await hubAxios.get( + repoResolveRoute( + params.repoType, + params.repoId, + params.revision || 'main', + params.filePath + ), + { + headers: this.headers(), + responseType: 'text' + } + ); + + let content = typeof res.data === 'string' ? res.data : String(res.data ?? ''); + let contentTypeHeader = res.headers?.['content-type']; + let contentType = + typeof contentTypeHeader === 'string' ? contentTypeHeader : undefined; + + return { + data: { + content, + contentType, + size: Buffer.byteLength(content) + } + }; + } + ); - let response = await hubAxios.get(url, { - headers: this.headers(), - responseType: 'text' - }); - return response.data; + return response; } async uploadFile(params: { @@ -287,39 +452,25 @@ export class HubClient { let prefix = this.repoTypePrefix(params.repoType); let revision = params.revision || 'main'; - // Use the commit API for uploading files - let _operations = [ - { - key: 'file', - value: { - content: params.content, - path: params.filePath, - encoding: 'utf-8' + return await this.request('upload repository file', async () => + hubAxios.post( + repoRoute(prefix, params.repoId, 'commit', revision), + { + summary: params.commitMessage || `Upload ${params.filePath}`, + operations: [ + { + op: 'addOrUpdate', + path: params.filePath, + content: params.content, + encoding: 'utf-8' + } + ] + }, + { + headers: this.jsonHeaders() } - } - ]; - - let response = await hubAxios.post( - `/api/${prefix}/${params.repoId}/commit/${revision}`, - { - summary: params.commitMessage || `Upload ${params.filePath}`, - operations: [ - { - op: 'addOrUpdate', - path: params.filePath, - content: params.content, - encoding: 'utf-8' - } - ] - }, - { - headers: { - ...this.headers(), - 'Content-Type': 'application/json' - } - } + ) ); - return response.data; } async deleteFile(params: { @@ -332,35 +483,33 @@ export class HubClient { let prefix = this.repoTypePrefix(params.repoType); let revision = params.revision || 'main'; - let response = await hubAxios.post( - `/api/${prefix}/${params.repoId}/commit/${revision}`, - { - summary: params.commitMessage || `Delete ${params.filePath}`, - operations: [ - { - op: 'delete', - path: params.filePath - } - ] - }, - { - headers: { - ...this.headers(), - 'Content-Type': 'application/json' + return await this.request('delete repository file', async () => + hubAxios.post( + repoRoute(prefix, params.repoId, 'commit', revision), + { + summary: params.commitMessage || `Delete ${params.filePath}`, + operations: [ + { + op: 'delete', + path: params.filePath + } + ] + }, + { + headers: this.jsonHeaders() } - } + ) ); - return response.data; } // ---- Discussions ---- async listDiscussions(params: { repoType: RepoType; repoId: string }): Promise { - let prefix = this.repoTypePrefix(params.repoType); - let response = await hubAxios.get(`/api/${prefix}/${params.repoId}/discussions`, { - headers: this.headers() - }); - return response.data; + return await this.request('list discussions', async () => + hubAxios.get(discussionRoute(params.repoType, params.repoId), { + headers: this.headers() + }) + ); } async getDiscussion(params: { @@ -368,12 +517,14 @@ export class HubClient { repoId: string; discussionNum: number; }): Promise { - let prefix = this.repoTypePrefix(params.repoType); - let response = await hubAxios.get( - `/api/${prefix}/${params.repoId}/discussions/${params.discussionNum}`, - { headers: this.headers() } + return await this.request('get discussion', async () => + hubAxios.get( + discussionRoute(params.repoType, params.repoId, String(params.discussionNum)), + { + headers: this.headers() + } + ) ); - return response.data; } async createDiscussion(params: { @@ -383,7 +534,6 @@ export class HubClient { description?: string; isPullRequest?: boolean; }): Promise { - let prefix = this.repoTypePrefix(params.repoType); let body: any = { title: params.title, description: params.description || '' @@ -392,10 +542,11 @@ export class HubClient { body.pullRequest = true; } - let response = await hubAxios.post(`/api/${prefix}/${params.repoId}/discussions`, body, { - headers: this.headers() - }); - return response.data; + return await this.request('create discussion', async () => + hubAxios.post(discussionRoute(params.repoType, params.repoId), body, { + headers: this.jsonHeaders() + }) + ); } async commentOnDiscussion(params: { @@ -404,13 +555,18 @@ export class HubClient { discussionNum: number; comment: string; }): Promise { - let prefix = this.repoTypePrefix(params.repoType); - let response = await hubAxios.post( - `/api/${prefix}/${params.repoId}/discussions/${params.discussionNum}/comment`, - { comment: params.comment }, - { headers: this.headers() } + return await this.request('comment on discussion', async () => + hubAxios.post( + discussionRoute( + params.repoType, + params.repoId, + String(params.discussionNum), + 'comment' + ), + { comment: params.comment }, + { headers: this.jsonHeaders() } + ) ); - return response.data; } async updateDiscussionStatus(params: { @@ -420,13 +576,18 @@ export class HubClient { status: 'open' | 'closed'; comment?: string; }): Promise { - let prefix = this.repoTypePrefix(params.repoType); - let response = await hubAxios.post( - `/api/${prefix}/${params.repoId}/discussions/${params.discussionNum}/status`, - { status: params.status, comment: params.comment || '' }, - { headers: this.headers() } + return await this.request('update discussion status', async () => + hubAxios.post( + discussionRoute( + params.repoType, + params.repoId, + String(params.discussionNum), + 'status' + ), + { status: params.status, comment: params.comment || '' }, + { headers: this.jsonHeaders() } + ) ); - return response.data; } async mergeDiscussion(params: { @@ -435,22 +596,47 @@ export class HubClient { discussionNum: number; comment?: string; }): Promise { - let prefix = this.repoTypePrefix(params.repoType); - let response = await hubAxios.post( - `/api/${prefix}/${params.repoId}/discussions/${params.discussionNum}/merge`, - { comment: params.comment || '' }, - { headers: this.headers() } + return await this.request('merge discussion', async () => + hubAxios.post( + discussionRoute(params.repoType, params.repoId, String(params.discussionNum), 'merge'), + { comment: params.comment || '' }, + { headers: this.jsonHeaders() } + ) ); - return response.data; } // ---- Collections ---- + async listCollections(params: { + query?: string; + owner?: string; + item?: string; + sort?: 'upvotes' | 'lastModified' | 'trending'; + cursor?: string; + limit?: number; + }): Promise { + let queryParams: any = {}; + if (params.query) queryParams.q = params.query; + if (params.owner) queryParams.owner = params.owner; + if (params.item) queryParams.item = params.item; + if (params.sort) queryParams.sort = params.sort; + if (params.cursor) queryParams.cursor = params.cursor; + if (params.limit !== undefined) queryParams.limit = params.limit; + + return await this.request('list collections', async () => + hubAxios.get('/api/collections', { + headers: this.headers(), + params: queryParams + }) + ); + } + async getCollection(params: { slug: string }): Promise { - let response = await hubAxios.get(`/api/collections/${params.slug}`, { - headers: this.headers() - }); - return response.data; + return await this.request('get collection', async () => + hubAxios.get(collectionRoute(params.slug), { + headers: this.headers() + }) + ); } async createCollection(params: { @@ -459,21 +645,42 @@ export class HubClient { description?: string; private?: boolean; }): Promise { - let response = await hubAxios.post( - '/api/collections', - { - title: params.title, - namespace: params.namespace, - description: params.description || '', - private: params.private ?? false - }, - { headers: this.headers() } + return await this.request('create collection', async () => + hubAxios.post( + '/api/collections', + { + title: params.title, + namespace: params.namespace, + description: params.description || '', + private: params.private ?? false + }, + { headers: this.jsonHeaders() } + ) + ); + } + + async updateCollection(params: { + slug: string; + title?: string; + description?: string; + private?: boolean; + theme?: 'orange' | 'blue' | 'green' | 'purple' | 'pink' | 'indigo'; + }): Promise { + let body: any = {}; + if (params.title !== undefined) body.title = params.title; + if (params.description !== undefined) body.description = params.description; + if (params.private !== undefined) body.private = params.private; + if (params.theme !== undefined) body.theme = params.theme; + + return await this.request('update collection', async () => + hubAxios.patch(collectionRoute(params.slug), body, { headers: this.jsonHeaders() }) ); - return response.data; } async deleteCollection(params: { slug: string }): Promise { - await hubAxios.delete(`/api/collections/${params.slug}`, { headers: this.headers() }); + await this.requestVoid('delete collection', async () => + hubAxios.delete(collectionRoute(params.slug), { headers: this.headers() }) + ); } async addCollectionItem(params: { @@ -482,98 +689,138 @@ export class HubClient { itemType: RepoType; note?: string; }): Promise { - let response = await hubAxios.post( - `/api/collections/${params.slug}/item`, - { - item: { id: params.itemId, type: params.itemType }, - note: params.note - }, - { headers: this.headers() } + return await this.request('add collection item', async () => + hubAxios.post( + collectionRoute(params.slug, 'items'), + { + item: { id: params.itemId, type: params.itemType }, + note: params.note + }, + { headers: this.jsonHeaders() } + ) ); - return response.data; } async removeCollectionItem(params: { slug: string; itemId: string }): Promise { - await hubAxios.delete(`/api/collections/${params.slug}/items/${params.itemId}`, { - headers: this.headers() - }); + await this.requestVoid('remove collection item', async () => + hubAxios.delete(collectionItemRoute(params.slug, params.itemId), { + headers: this.headers() + }) + ); } // ---- Spaces Management ---- async getSpaceRuntime(params: { repoId: string }): Promise { - let response = await hubAxios.get(`/api/spaces/${params.repoId}/runtime`, { - headers: this.headers() - }); - return response.data; + return await this.request('get Space runtime', async () => + hubAxios.get(repoRoute('spaces', params.repoId, 'runtime'), { + headers: this.headers() + }) + ); } async setSpaceHardware(params: { repoId: string; hardware: string }): Promise { - let response = await hubAxios.post( - `/api/spaces/${params.repoId}/hardware`, - { flavor: params.hardware }, - { headers: this.headers() } + return await this.request('set Space hardware', async () => + hubAxios.post( + repoRoute('spaces', params.repoId, 'hardware'), + { flavor: params.hardware }, + { headers: this.jsonHeaders() } + ) ); - return response.data; } async pauseSpace(params: { repoId: string }): Promise { - let response = await hubAxios.post( - `/api/spaces/${params.repoId}/pause`, - {}, - { headers: this.headers() } + return await this.request('pause Space', async () => + hubAxios.post( + repoRoute('spaces', params.repoId, 'pause'), + {}, + { headers: this.jsonHeaders() } + ) ); - return response.data; } async restartSpace(params: { repoId: string }): Promise { - let response = await hubAxios.post( - `/api/spaces/${params.repoId}/restart`, - {}, - { headers: this.headers() } + return await this.request('restart Space', async () => + hubAxios.post( + repoRoute('spaces', params.repoId, 'restart'), + {}, + { headers: this.jsonHeaders() } + ) ); - return response.data; } - async addSpaceSecret(params: { repoId: string; key: string; value: string }): Promise { - await hubAxios.post( - `/api/spaces/${params.repoId}/secrets`, - { key: params.key, value: params.value }, - { headers: this.headers() } + async listSpaceSecrets(params: { repoId: string }): Promise { + return await this.request('list Space secrets', async () => + hubAxios.get(repoRoute('spaces', params.repoId, 'secrets'), { + headers: this.headers() + }) + ); + } + + async addSpaceSecret(params: { + repoId: string; + key: string; + value: string; + description?: string; + }): Promise { + let body: any = { key: params.key, value: params.value }; + if (params.description) body.description = params.description; + + await this.requestVoid('upsert Space secret', async () => + hubAxios.post(repoRoute('spaces', params.repoId, 'secrets'), body, { + headers: this.jsonHeaders() + }) ); } async deleteSpaceSecret(params: { repoId: string; key: string }): Promise { - await hubAxios.delete(`/api/spaces/${params.repoId}/secrets`, { - headers: this.headers(), - data: { key: params.key } - }); + await this.requestVoid('delete Space secret', async () => + hubAxios.delete(repoRoute('spaces', params.repoId, 'secrets'), { + headers: this.jsonHeaders(), + data: { key: params.key } + }) + ); + } + + async listSpaceVariables(params: { repoId: string }): Promise { + return await this.request('list Space variables', async () => + hubAxios.get(repoRoute('spaces', params.repoId, 'variables'), { + headers: this.headers() + }) + ); } async addSpaceVariable(params: { repoId: string; key: string; value: string; + description?: string; }): Promise { - await hubAxios.post( - `/api/spaces/${params.repoId}/variables`, - { key: params.key, value: params.value }, - { headers: this.headers() } + let body: any = { key: params.key, value: params.value }; + if (params.description) body.description = params.description; + + await this.requestVoid('upsert Space variable', async () => + hubAxios.post(repoRoute('spaces', params.repoId, 'variables'), body, { + headers: this.jsonHeaders() + }) ); } async deleteSpaceVariable(params: { repoId: string; key: string }): Promise { - await hubAxios.delete(`/api/spaces/${params.repoId}/variables`, { - headers: this.headers(), - data: { key: params.key } - }); + await this.requestVoid('delete Space variable', async () => + hubAxios.delete(repoRoute('spaces', params.repoId, 'variables'), { + headers: this.jsonHeaders(), + data: { key: params.key } + }) + ); } // ---- Webhook Management ---- async listWebhooks(): Promise { - let response = await hubAxios.get('/api/settings/webhooks', { headers: this.headers() }); - return response.data; + return await this.request('list webhooks', async () => + hubAxios.get('/api/settings/webhooks', { headers: this.headers() }) + ); } async createWebhook(params: { @@ -589,10 +836,11 @@ export class HubClient { }; if (params.secret) body.secret = params.secret; - let response = await hubAxios.post('/api/settings/webhooks', body, { - headers: this.headers() - }); - return response.data; + return await this.request('create webhook', async () => + hubAxios.post('/api/settings/webhooks', body, { + headers: this.jsonHeaders() + }) + ); } async updateWebhook(params: { @@ -608,34 +856,39 @@ export class HubClient { if (params.domains) body.domains = params.domains; if (params.secret) body.secret = params.secret; - let response = await hubAxios.post(`/api/settings/webhooks/${params.webhookId}`, body, { - headers: this.headers() - }); - return response.data; + return await this.request('update webhook', async () => + hubAxios.post(`/api/settings/webhooks/${encodeURIComponent(params.webhookId)}`, body, { + headers: this.jsonHeaders() + }) + ); } async deleteWebhook(params: { webhookId: string }): Promise { - await hubAxios.delete(`/api/settings/webhooks/${params.webhookId}`, { - headers: this.headers() - }); + await this.requestVoid('delete webhook', async () => + hubAxios.delete(`/api/settings/webhooks/${encodeURIComponent(params.webhookId)}`, { + headers: this.headers() + }) + ); } async enableWebhook(params: { webhookId: string }): Promise { - let response = await hubAxios.post( - `/api/settings/webhooks/${params.webhookId}/enable`, - {}, - { headers: this.headers() } + return await this.request('enable webhook', async () => + hubAxios.post( + `/api/settings/webhooks/${encodeURIComponent(params.webhookId)}/enable`, + {}, + { headers: this.jsonHeaders() } + ) ); - return response.data; } async disableWebhook(params: { webhookId: string }): Promise { - let response = await hubAxios.post( - `/api/settings/webhooks/${params.webhookId}/disable`, - {}, - { headers: this.headers() } + return await this.request('disable webhook', async () => + hubAxios.post( + `/api/settings/webhooks/${encodeURIComponent(params.webhookId)}/disable`, + {}, + { headers: this.jsonHeaders() } + ) ); - return response.data; } // ---- Inference ---- @@ -652,10 +905,11 @@ export class HubClient { if (params.parameters) body.parameters = params.parameters; if (params.options) body.options = params.options; - let response = await inferenceAxios.post(`/models/${params.modelId}`, body, { - headers: this.headers() - }); - return response.data; + return await this.request('run inference', async () => + routerAxios.post(`/hf-inference/models/${encodePath(params.modelId)}`, body, { + headers: this.jsonHeaders() + }) + ); } async chatCompletion(params: { @@ -677,10 +931,11 @@ export class HubClient { if (params.stop) body.stop = params.stop; body.stream = false; - let response = await inferenceAxios.post('/v1/chat/completions', body, { - headers: this.headers() - }); - return response.data; + return await this.request('create chat completion', async () => + routerAxios.post('/v1/chat/completions', body, { + headers: this.jsonHeaders() + }) + ); } async textGeneration(params: { @@ -693,34 +948,59 @@ export class HubClient { doSample?: boolean; returnFullText?: boolean; }): Promise { - let parameters: any = {}; - if (params.maxNewTokens !== undefined) parameters.max_new_tokens = params.maxNewTokens; - if (params.temperature !== undefined) parameters.temperature = params.temperature; - if (params.topP !== undefined) parameters.top_p = params.topP; + let body: any = { + model: params.model, + prompt: params.inputs + }; + if (params.maxNewTokens !== undefined) body.max_tokens = params.maxNewTokens; + if (params.temperature !== undefined) body.temperature = params.temperature; + if (params.topP !== undefined) body.top_p = params.topP; if (params.repetitionPenalty !== undefined) - parameters.repetition_penalty = params.repetitionPenalty; - if (params.doSample !== undefined) parameters.do_sample = params.doSample; - if (params.returnFullText !== undefined) - parameters.return_full_text = params.returnFullText; - - let response = await inferenceAxios.post( - `/models/${params.model}`, - { - inputs: params.inputs, - parameters: Object.keys(parameters).length > 0 ? parameters : undefined - }, - { headers: this.headers() } + body.repetition_penalty = params.repetitionPenalty; + if (params.doSample !== undefined) body.do_sample = params.doSample; + if (params.returnFullText !== undefined) body.return_full_text = params.returnFullText; + + let result = await this.request('generate text', async () => + routerAxios.post( + `/hf-inference/models/${encodePath(params.model)}/v1/completions`, + body, + { + headers: this.jsonHeaders() + } + ) ); - return response.data; + + if (result?.choices?.[0]?.text !== undefined) { + return { generated_text: result.choices[0].text }; + } + + return result; } - async featureExtraction(params: { model: string; inputs: string | string[] }): Promise { - let response = await inferenceAxios.post( - `/models/${params.model}`, - { inputs: params.inputs }, - { headers: this.headers() } + async featureExtraction(params: { + model: string; + inputs: string | string[]; + normalize?: boolean; + truncate?: boolean; + promptName?: string; + truncationDirection?: 'left' | 'right'; + }): Promise { + let body: any = { + inputs: params.inputs + }; + if (params.normalize !== undefined) body.normalize = params.normalize; + if (params.truncate !== undefined) body.truncate = params.truncate; + if (params.promptName !== undefined) body.prompt_name = params.promptName; + if (params.truncationDirection !== undefined) + body.truncation_direction = params.truncationDirection; + + return await this.request('run feature extraction', async () => + routerAxios.post( + `/hf-inference/models/${encodePath(params.model)}/pipeline/feature-extraction`, + body, + { headers: this.jsonHeaders() } + ) ); - return response.data; } async summarization(params: { @@ -733,14 +1013,15 @@ export class HubClient { if (params.maxLength !== undefined) parameters.max_length = params.maxLength; if (params.minLength !== undefined) parameters.min_length = params.minLength; - let response = await inferenceAxios.post( - `/models/${params.model}`, - { - inputs: params.inputs, - parameters: Object.keys(parameters).length > 0 ? parameters : undefined - }, - { headers: this.headers() } + return await this.request('run summarization', async () => + routerAxios.post( + `/hf-inference/models/${encodePath(params.model)}`, + { + inputs: params.inputs, + parameters: Object.keys(parameters).length > 0 ? parameters : undefined + }, + { headers: this.jsonHeaders() } + ) ); - return response.data; } } diff --git a/integrations/huggingface/src/lib/errors.ts b/integrations/huggingface/src/lib/errors.ts new file mode 100644 index 0000000000..912eb11c84 --- /dev/null +++ b/integrations/huggingface/src/lib/errors.ts @@ -0,0 +1,105 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.detail); + pushDetail(details, value.title); + pushDetail(details, value.error); + pushDetail(details, value.error_description); + pushDetail(details, value.code); + collectDetails(value.data, details); + collectDetails(value.errors, details); +}; + +let extractHuggingFaceMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + return typeof response.data.code === 'string' ? response.data.code : undefined; +}; + +export let huggingFaceServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let huggingFaceApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = huggingFaceServiceError( + `Hugging Face API ${operation} failed: ${statusLabelFor(response)}${extractHuggingFaceMessage(error)}` + ); + serviceError.data.reason = 'hugging_face_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let requireHuggingFaceInput = (value: unknown, label: string, action?: string) => { + if (typeof value === 'string' && value.trim()) { + return value; + } + + throw huggingFaceServiceError(`${label} is required${action ? ` for "${action}"` : ''}.`); +}; diff --git a/integrations/huggingface/src/tools.schema.test.ts b/integrations/huggingface/src/tools.schema.test.ts new file mode 100644 index 0000000000..0547012f27 --- /dev/null +++ b/integrations/huggingface/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Hugging Face tool input schemas', provider.actions); diff --git a/integrations/huggingface/src/tools/index.ts b/integrations/huggingface/src/tools/index.ts index 21a3e80cd9..aec669783e 100644 --- a/integrations/huggingface/src/tools/index.ts +++ b/integrations/huggingface/src/tools/index.ts @@ -1,11 +1,18 @@ export { getUserInfoTool } from './get-user-info'; -export { chatCompletionTool, runInferenceTool, textGenerationTool } from './inference'; +export { + chatCompletionTool, + featureExtractionTool, + runInferenceTool, + textGenerationTool +} from './inference'; export { addCollectionItemTool, createCollectionTool, deleteCollectionTool, getCollectionTool, - removeCollectionItemTool + listCollectionsTool, + removeCollectionItemTool, + updateCollectionTool } from './manage-collections'; export { commentOnDiscussionTool, @@ -23,6 +30,7 @@ export { export { createRepositoryTool, deleteRepositoryTool, + duplicateRepositoryTool, getRepositoryInfoTool, updateRepositoryVisibilityTool } from './manage-repository'; diff --git a/integrations/huggingface/src/tools/inference.ts b/integrations/huggingface/src/tools/inference.ts index 01297829a3..e0500ae4cf 100644 --- a/integrations/huggingface/src/tools/inference.ts +++ b/integrations/huggingface/src/tools/inference.ts @@ -9,7 +9,7 @@ export let chatCompletionTool = SlateTool.create(spec, { description: `Run a chat completion using a model on the Hugging Face Inference API. Follows the OpenAI-compatible chat completions format. Supports conversation history with system, user, and assistant messages.`, instructions: [ 'The model must be hosted on Hugging Face and accessible via the Inference API.', - 'Popular models include "meta-llama/Llama-3.1-8B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3", etc.' + 'Popular models include "openai/gpt-oss-120b:fastest", "Qwen/Qwen3-Coder-480B-A35B-Instruct:fastest", etc.' ], tags: { readOnly: true @@ -132,6 +132,80 @@ export let textGenerationTool = SlateTool.create(spec, { }) .build(); +let getEmbeddingShape = (value: unknown): number[] => { + let shape: number[] = []; + let cursor = value; + + while (Array.isArray(cursor)) { + shape.push(cursor.length); + cursor = cursor[0]; + } + + return shape; +}; + +export let featureExtractionTool = SlateTool.create(spec, { + name: 'Feature Extraction', + key: 'feature_extraction', + description: `Generate embeddings with a Hugging Face feature-extraction model. Use this for semantic search, RAG retrieval, clustering, and similarity workflows.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + model: z + .string() + .describe( + 'Feature-extraction model ID (e.g. "sentence-transformers/all-MiniLM-L6-v2")' + ), + inputs: z + .union([z.string(), z.array(z.string())]) + .describe('Text or list of texts to embed'), + normalize: z.boolean().optional().describe('Whether to normalize returned embeddings'), + truncate: z + .boolean() + .optional() + .describe('Whether to truncate inputs that exceed model limits'), + promptName: z + .string() + .optional() + .describe('Prompt name from sentence-transformers model configuration'), + truncationDirection: z + .enum(['left', 'right']) + .optional() + .describe('Direction for input truncation') + }) + ) + .output( + z.object({ + embeddings: z.any().describe('Embedding vector or nested embedding vectors'), + shape: z.array(z.number()).describe('Shape of the returned embedding array') + }) + ) + .handleInvocation(async ctx => { + let client = new HubClient({ token: ctx.auth.token }); + + let embeddings = await client.featureExtraction({ + model: ctx.input.model, + inputs: ctx.input.inputs, + normalize: ctx.input.normalize, + truncate: ctx.input.truncate, + promptName: ctx.input.promptName, + truncationDirection: ctx.input.truncationDirection + }); + let shape = getEmbeddingShape(embeddings); + + return { + output: { + embeddings, + shape + }, + message: `Generated embeddings using **${ctx.input.model}** with shape **${shape.join(' x ') || 'unknown'}**.` + }; + }) + .build(); + export let runInferenceTool = SlateTool.create(spec, { name: 'Run Inference', key: 'run_inference', diff --git a/integrations/huggingface/src/tools/manage-collections.ts b/integrations/huggingface/src/tools/manage-collections.ts index 62dd6ac0d0..94b9d5bfe4 100644 --- a/integrations/huggingface/src/tools/manage-collections.ts +++ b/integrations/huggingface/src/tools/manage-collections.ts @@ -1,8 +1,86 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { HubClient } from '../lib/client'; +import { huggingFaceServiceError } from '../lib/errors'; import { spec } from '../spec'; +let collectionSummarySchema = z.object({ + slug: z.string().describe('Collection slug'), + title: z.string().optional().describe('Collection title'), + description: z.string().optional().describe('Collection description'), + owner: z.string().optional().describe('Owner username or organization'), + private: z.boolean().optional().describe('Whether the collection is private'), + itemCount: z.number().optional().describe('Number of items in the collection'), + lastUpdated: z.string().optional().describe('Last updated timestamp') +}); + +let mapCollectionSummary = (collection: any) => ({ + slug: collection.slug, + title: collection.title, + description: collection.description, + owner: collection.owner?.name || collection.owner, + private: collection.private, + itemCount: Array.isArray(collection.items) ? collection.items.length : collection.itemCount, + lastUpdated: collection.lastUpdated || collection.updatedAt +}); + +export let listCollectionsTool = SlateTool.create(spec, { + name: 'List Collections', + key: 'list_collections', + description: `List Hugging Face collections. Filter by owner, query text, or item, and page through collection discovery results.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + query: z.string().optional().describe('Search query for collection title or content'), + owner: z.string().optional().describe('Owner username or organization namespace'), + item: z + .string() + .optional() + .describe('Filter by contained item, for example "models/owner/model-name"'), + sort: z.enum(['upvotes', 'lastModified', 'trending']).optional().describe('Sort field'), + cursor: z.string().optional().describe('Pagination cursor from a previous response'), + limit: z + .number() + .optional() + .default(10) + .describe('Maximum number of collections to return') + }) + ) + .output( + z.object({ + collections: z.array(collectionSummarySchema).describe('Matching collections'), + nextCursor: z.string().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new HubClient({ token: ctx.auth.token }); + let result = await client.listCollections({ + query: ctx.input.query, + owner: ctx.input.owner, + item: ctx.input.item, + sort: ctx.input.sort, + cursor: ctx.input.cursor, + limit: ctx.input.limit + }); + + let rawCollections = Array.isArray(result) + ? result + : result.collections || result.items || result.data || []; + let collections = rawCollections.map(mapCollectionSummary); + + return { + output: { + collections, + nextCursor: result.nextCursor || result.cursor + }, + message: `Found **${collections.length}** collection(s).` + }; + }) + .build(); + export let getCollectionTool = SlateTool.create(spec, { name: 'Get Collection', key: 'get_collection', @@ -109,6 +187,70 @@ export let createCollectionTool = SlateTool.create(spec, { }) .build(); +export let updateCollectionTool = SlateTool.create(spec, { + name: 'Update Collection', + key: 'update_collection', + description: `Update a Hugging Face collection's title, description, visibility, or theme.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + slug: z.string().describe('Collection slug to update'), + title: z.string().optional().describe('New collection title'), + description: z.string().optional().describe('New collection description'), + private: z.boolean().optional().describe('Whether the collection should be private'), + theme: z + .enum(['orange', 'blue', 'green', 'purple', 'pink', 'indigo']) + .optional() + .describe('Collection theme color') + }) + ) + .output( + z.object({ + slug: z.string().describe('Updated collection slug'), + title: z.string().optional().describe('Collection title'), + description: z.string().optional().describe('Collection description'), + private: z.boolean().optional().describe('Whether the collection is private'), + url: z.string().optional().describe('URL to the collection') + }) + ) + .handleInvocation(async ctx => { + let hasUpdates = + ctx.input.title !== undefined || + ctx.input.description !== undefined || + ctx.input.private !== undefined || + ctx.input.theme !== undefined; + + if (!hasUpdates) { + throw huggingFaceServiceError( + 'Provide at least one of title, description, private, or theme to update a collection.' + ); + } + + let client = new HubClient({ token: ctx.auth.token }); + let result = await client.updateCollection({ + slug: ctx.input.slug, + title: ctx.input.title, + description: ctx.input.description, + private: ctx.input.private, + theme: ctx.input.theme + }); + + return { + output: { + slug: result.slug || ctx.input.slug, + title: result.title, + description: result.description, + private: result.private, + url: result.url + }, + message: `Updated collection **${result.slug || ctx.input.slug}**.` + }; + }) + .build(); + export let deleteCollectionTool = SlateTool.create(spec, { name: 'Delete Collection', key: 'delete_collection', diff --git a/integrations/huggingface/src/tools/manage-discussions.ts b/integrations/huggingface/src/tools/manage-discussions.ts index 1777b22979..d694149ab0 100644 --- a/integrations/huggingface/src/tools/manage-discussions.ts +++ b/integrations/huggingface/src/tools/manage-discussions.ts @@ -256,7 +256,7 @@ export let updateDiscussionStatusTool = SlateTool.create(spec, { repoType: ctx.input.repoType, repoId: ctx.input.repoId, discussionNum: ctx.input.discussionNum, - status: ctx.input.action as 'open' | 'closed', + status: ctx.input.action === 'close' ? 'closed' : 'open', comment: ctx.input.comment }); diff --git a/integrations/huggingface/src/tools/manage-files.ts b/integrations/huggingface/src/tools/manage-files.ts index 2e3f091565..bc41c0b6e4 100644 --- a/integrations/huggingface/src/tools/manage-files.ts +++ b/integrations/huggingface/src/tools/manage-files.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; import { HubClient } from '../lib/client'; import { spec } from '../spec'; @@ -84,14 +84,16 @@ export let getFileContentTool = SlateTool.create(spec, { ) .output( z.object({ - content: z.string().describe('Text content of the file'), - filePath: z.string().describe('Path of the file read') + filePath: z.string().describe('Path of the file read'), + mimeType: z.string().optional().describe('MIME type reported by Hugging Face'), + size: z.number().describe('Downloaded content size in bytes'), + attachmentCount: z.number().describe('Number of returned Slate attachments') }) ) .handleInvocation(async ctx => { let client = new HubClient({ token: ctx.auth.token }); - let content = await client.getFileContent({ + let result = await client.getFileContent({ repoType: ctx.input.repoType, repoId: ctx.input.repoId, filePath: ctx.input.filePath, @@ -100,9 +102,12 @@ export let getFileContentTool = SlateTool.create(spec, { return { output: { - content, - filePath: ctx.input.filePath + filePath: ctx.input.filePath, + mimeType: result.contentType, + size: result.size, + attachmentCount: 1 }, + attachments: [createTextAttachment(result.content, result.contentType || 'text/plain')], message: `Retrieved content of **${ctx.input.filePath}** from **${ctx.input.repoId}**.` }; }) diff --git a/integrations/huggingface/src/tools/manage-repository.ts b/integrations/huggingface/src/tools/manage-repository.ts index 4f6fe52c08..179993d0f8 100644 --- a/integrations/huggingface/src/tools/manage-repository.ts +++ b/integrations/huggingface/src/tools/manage-repository.ts @@ -98,6 +98,71 @@ export let deleteRepositoryTool = SlateTool.create(spec, { }) .build(); +export let duplicateRepositoryTool = SlateTool.create(spec, { + name: 'Duplicate Repository', + key: 'duplicate_repository', + description: `Duplicate a Hugging Face Space repository to a new Space. The current Hub API exposes repository duplication for Spaces.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + repoType: z + .enum(['space']) + .default('space') + .describe( + 'Repository type to duplicate. Hugging Face currently supports Space duplication.' + ), + sourceRepoId: z.string().describe('Source Space ID (e.g. "owner/source-space")'), + destinationRepoId: z + .string() + .describe('Destination Space ID or name for the duplicated repository'), + private: z + .boolean() + .optional() + .describe('Whether the duplicated Space should be private'), + visibility: z + .enum(['private', 'public', 'protected']) + .optional() + .describe('Visibility for the duplicated Space'), + hardware: z + .string() + .optional() + .describe('Optional hardware flavor for the duplicated Space'), + sleepTimeSeconds: z + .number() + .optional() + .describe('Optional Space sleep timeout in seconds, or -1 to disable sleep') + }) + ) + .output( + z.object({ + url: z.string().optional().describe('URL of the duplicated repository'), + repoId: z.string().describe('Destination repository ID') + }) + ) + .handleInvocation(async ctx => { + let client = new HubClient({ token: ctx.auth.token }); + let result = await client.duplicateSpace({ + sourceRepoId: ctx.input.sourceRepoId, + destinationRepoId: ctx.input.destinationRepoId, + private: ctx.input.private, + visibility: ctx.input.visibility, + hardware: ctx.input.hardware, + sleepTimeSeconds: ctx.input.sleepTimeSeconds + }); + + return { + output: { + url: result.url, + repoId: ctx.input.destinationRepoId + }, + message: `Duplicated Space **${ctx.input.sourceRepoId}** to **${ctx.input.destinationRepoId}**.` + }; + }) + .build(); + export let getRepositoryInfoTool = SlateTool.create(spec, { name: 'Get Repository Info', key: 'get_repository_info', diff --git a/integrations/huggingface/src/tools/manage-spaces.ts b/integrations/huggingface/src/tools/manage-spaces.ts index 60817169c4..d49b30d546 100644 --- a/integrations/huggingface/src/tools/manage-spaces.ts +++ b/integrations/huggingface/src/tools/manage-spaces.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { HubClient } from '../lib/client'; +import { requireHuggingFaceInput } from '../lib/errors'; import { spec } from '../spec'; export let getSpaceRuntimeTool = SlateTool.create(spec, { @@ -85,12 +86,14 @@ export let controlSpaceTool = SlateTool.create(spec, { } else if (ctx.input.action === 'restart') { result = await client.restartSpace({ repoId: ctx.input.repoId }); } else if (ctx.input.action === 'set_hardware') { - if (!ctx.input.hardware) { - throw new Error('Hardware flavor is required for set_hardware action'); - } + let hardware = requireHuggingFaceInput( + ctx.input.hardware, + 'Hardware flavor', + 'set_hardware' + ); result = await client.setSpaceHardware({ repoId: ctx.input.repoId, - hardware: ctx.input.hardware + hardware }); } @@ -108,7 +111,7 @@ export let controlSpaceTool = SlateTool.create(spec, { export let manageSpaceSecretsTool = SlateTool.create(spec, { name: 'Manage Space Secrets', key: 'manage_space_secrets', - description: `Add or delete secrets on a Space. Secrets are encrypted environment variables available at runtime.`, + description: `List, add, or delete secrets on a Space. Secrets are encrypted environment variables available at runtime.`, tags: { destructive: false } @@ -116,34 +119,64 @@ export let manageSpaceSecretsTool = SlateTool.create(spec, { .input( z.object({ repoId: z.string().describe('Full Space ID (e.g. "username/space-name")'), - action: z.enum(['add', 'delete']).describe('Whether to add or delete a secret'), - key: z.string().describe('Secret key name'), - value: z.string().optional().describe('Secret value (required when adding)') + action: z + .enum(['list', 'add', 'delete']) + .describe('Whether to list, add, or delete a secret'), + key: z.string().optional().describe('Secret key name (required for add/delete)'), + value: z.string().optional().describe('Secret value (required when adding)'), + description: z.string().optional().describe('Optional description when adding a secret') }) ) .output( z.object({ repoId: z.string().describe('Space ID'), action: z.string().describe('Action performed'), - key: z.string().describe('Secret key') + key: z.string().optional().describe('Secret key'), + secrets: z + .array( + z.object({ + key: z.string().optional().describe('Secret key'), + updatedAt: z.string().optional().describe('Last update timestamp'), + description: z.string().optional().describe('Secret description') + }) + ) + .optional() + .describe('Secrets returned for list action') }) ) .handleInvocation(async ctx => { let client = new HubClient({ token: ctx.auth.token }); + if (ctx.input.action === 'list') { + let secrets = await client.listSpaceSecrets({ repoId: ctx.input.repoId }); + return { + output: { + repoId: ctx.input.repoId, + action: ctx.input.action, + secrets: (secrets || []).map((secret: any) => ({ + key: secret.key || secret.name, + updatedAt: secret.updatedAt, + description: secret.description + })) + }, + message: `Found **${secrets?.length || 0}** secret(s) on Space **${ctx.input.repoId}**.` + }; + } + if (ctx.input.action === 'add') { - if (!ctx.input.value) { - throw new Error('Value is required when adding a secret'); - } + let key = requireHuggingFaceInput(ctx.input.key, 'Secret key', 'add secret'); + let value = requireHuggingFaceInput(ctx.input.value, 'Value', 'add secret'); await client.addSpaceSecret({ repoId: ctx.input.repoId, - key: ctx.input.key, - value: ctx.input.value + key, + value, + description: ctx.input.description }); } else { + let key = requireHuggingFaceInput(ctx.input.key, 'Secret key', 'delete secret'); await client.deleteSpaceSecret({ repoId: ctx.input.repoId, - key: ctx.input.key + key }); } @@ -161,7 +194,7 @@ export let manageSpaceSecretsTool = SlateTool.create(spec, { export let manageSpaceVariablesTool = SlateTool.create(spec, { name: 'Manage Space Variables', key: 'manage_space_variables', - description: `Add or delete environment variables on a Space. Variables are public configuration values available at runtime.`, + description: `List, add, or delete environment variables on a Space. Variables are public configuration values available at runtime.`, tags: { destructive: false } @@ -169,34 +202,69 @@ export let manageSpaceVariablesTool = SlateTool.create(spec, { .input( z.object({ repoId: z.string().describe('Full Space ID (e.g. "username/space-name")'), - action: z.enum(['add', 'delete']).describe('Whether to add or delete a variable'), - key: z.string().describe('Variable key name'), - value: z.string().optional().describe('Variable value (required when adding)') + action: z + .enum(['list', 'add', 'delete']) + .describe('Whether to list, add, or delete a variable'), + key: z.string().optional().describe('Variable key name (required for add/delete)'), + value: z.string().optional().describe('Variable value (required when adding)'), + description: z + .string() + .optional() + .describe('Optional description when adding a variable') }) ) .output( z.object({ repoId: z.string().describe('Space ID'), action: z.string().describe('Action performed'), - key: z.string().describe('Variable key') + key: z.string().optional().describe('Variable key'), + variables: z + .array( + z.object({ + key: z.string().optional().describe('Variable key'), + value: z.string().optional().describe('Variable value'), + updatedAt: z.string().optional().describe('Last update timestamp'), + description: z.string().optional().describe('Variable description') + }) + ) + .optional() + .describe('Variables returned for list action') }) ) .handleInvocation(async ctx => { let client = new HubClient({ token: ctx.auth.token }); + if (ctx.input.action === 'list') { + let variables = await client.listSpaceVariables({ repoId: ctx.input.repoId }); + return { + output: { + repoId: ctx.input.repoId, + action: ctx.input.action, + variables: (variables || []).map((variable: any) => ({ + key: variable.key || variable.name, + value: variable.value, + updatedAt: variable.updatedAt, + description: variable.description + })) + }, + message: `Found **${variables?.length || 0}** variable(s) on Space **${ctx.input.repoId}**.` + }; + } + if (ctx.input.action === 'add') { - if (!ctx.input.value) { - throw new Error('Value is required when adding a variable'); - } + let key = requireHuggingFaceInput(ctx.input.key, 'Variable key', 'add variable'); + let value = requireHuggingFaceInput(ctx.input.value, 'Value', 'add variable'); await client.addSpaceVariable({ repoId: ctx.input.repoId, - key: ctx.input.key, - value: ctx.input.value + key, + value, + description: ctx.input.description }); } else { + let key = requireHuggingFaceInput(ctx.input.key, 'Variable key', 'delete variable'); await client.deleteSpaceVariable({ repoId: ctx.input.repoId, - key: ctx.input.key + key }); } diff --git a/integrations/huggingface/vitest.config.ts b/integrations/huggingface/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/huggingface/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/iterable/package.json b/integrations/iterable/package.json index c744c2241b..9c67bb0c3a 100644 --- a/integrations/iterable/package.json +++ b/integrations/iterable/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/iterable/src/index.ts b/integrations/iterable/src/index.ts index e92244ba16..31bfef1463 100644 --- a/integrations/iterable/src/index.ts +++ b/integrations/iterable/src/index.ts @@ -5,6 +5,7 @@ import { exportData, getChannels, getUser, + listJourneys, manageCampaigns, manageCatalogs, manageLists, @@ -36,7 +37,8 @@ export let provider = Slate.create({ sendMessage, updateSubscriptions, getChannels, - exportData + exportData, + listJourneys ], triggers: [systemWebhook] }); diff --git a/integrations/iterable/src/lib/client.ts b/integrations/iterable/src/lib/client.ts index 67ea1ec9bd..6865873b11 100644 --- a/integrations/iterable/src/lib/client.ts +++ b/integrations/iterable/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { iterableApiError } from './errors'; export class IterableClient { private axios: ReturnType; @@ -18,6 +19,15 @@ export class IterableClient { }); } + private async request(operation: string, run: () => Promise<{ data: T }>): Promise { + try { + let response = await run(); + return response.data; + } catch (error) { + throw iterableApiError(error, operation); + } + } + // ─── Users ────────────────────────────────────────────────────── async updateUser(params: { @@ -28,8 +38,7 @@ export class IterableClient { mergeNestedObjects?: boolean; createNewFields?: boolean; }): Promise { - let response = await this.axios.post('/users/update', params); - return response.data; + return await this.request('update user', () => this.axios.post('/users/update', params)); } async bulkUpdateUsers( @@ -41,35 +50,41 @@ export class IterableClient { mergeNestedObjects?: boolean; }[] ): Promise { - let response = await this.axios.post('/users/bulkUpdate', { users }); - return response.data; + return await this.request('bulk update users', () => + this.axios.post('/users/bulkUpdate', { users }) + ); } async getUser(params: { email?: string; userId?: string }): Promise { if (params.userId) { - let response = await this.axios.get('/users/byUserId', { - params: { userId: params.userId } - }); - return response.data; + return await this.request('get user by userId', () => + this.axios.get('/users/byUserId', { + params: { userId: params.userId } + }) + ); } - let response = await this.axios.get(`/users/${encodeURIComponent(params.email!)}`, {}); - return response.data; + return await this.request('get user by email', () => + this.axios.get('/users/getByEmail', { + params: { email: params.email } + }) + ); } async deleteUser(params: { email?: string; userId?: string }): Promise { if (params.userId) { - let response = await this.axios.delete('/users/byUserId', { - params: { userId: params.userId } - }); - return response.data; + return await this.request('delete user by userId', () => + this.axios.delete('/users/byUserId', { + params: { userId: params.userId } + }) + ); } - let response = await this.axios.delete(`/users/${encodeURIComponent(params.email!)}`, {}); - return response.data; + return await this.request('delete user by email', () => + this.axios.delete(`/users/${encodeURIComponent(params.email!)}`, {}) + ); } async getUserFields(): Promise { - let response = await this.axios.get('/users/fields'); - return response.data; + return await this.request('get user fields', () => this.axios.get('/users/getFields')); } async mergeUsers(params: { @@ -83,8 +98,7 @@ export class IterableClient { if (params.sourceUserId) body.sourceUserId = params.sourceUserId; if (params.destinationEmail) body.destinationEmail = params.destinationEmail; if (params.destinationUserId) body.destinationUserId = params.destinationUserId; - let response = await this.axios.post('/users/merge', body); - return response.data; + return await this.request('merge users', () => this.axios.post('/users/merge', body)); } // ─── Events ───────────────────────────────────────────────────── @@ -99,8 +113,7 @@ export class IterableClient { templateId?: number; createNewFields?: boolean; }): Promise { - let response = await this.axios.post('/events/track', params); - return response.data; + return await this.request('track event', () => this.axios.post('/events/track', params)); } async trackBulkEvents( @@ -114,15 +127,17 @@ export class IterableClient { templateId?: number; }[] ): Promise { - let response = await this.axios.post('/events/bulkTrack', { events }); - return response.data; + return await this.request('bulk track events', () => + this.axios.post('/events/bulkTrack', { events }) + ); } async getUserEvents(email: string, limit?: number): Promise { - let response = await this.axios.get('/events', { - params: { email, limit: limit || 30 } - }); - return response.data; + return await this.request('get user events', () => + this.axios.get(`/events/${encodeURIComponent(email)}`, { + params: { limit: limit || 30 } + }) + ); } // ─── Commerce ─────────────────────────────────────────────────── @@ -149,8 +164,9 @@ export class IterableClient { createdAt?: number; createNewFields?: boolean; }): Promise { - let response = await this.axios.post('/commerce/trackPurchase', params); - return response.data; + return await this.request('track purchase', () => + this.axios.post('/commerce/trackPurchase', params) + ); } async updateCart(params: { @@ -170,76 +186,98 @@ export class IterableClient { }[]; createNewFields?: boolean; }): Promise { - let response = await this.axios.post('/commerce/updateCart', params); - return response.data; + return await this.request('update cart', () => + this.axios.post('/commerce/updateCart', params) + ); } // ─── Lists ────────────────────────────────────────────────────── async getLists(): Promise { - let response = await this.axios.get('/lists'); - return response.data; + return await this.request('list lists', () => this.axios.get('/lists')); } async createList(name: string): Promise { - let response = await this.axios.post('/lists', { name }); - return response.data; + return await this.request('create list', () => this.axios.post('/lists', { name })); } async deleteList(listId: number): Promise { - let response = await this.axios.delete(`/lists/${listId}`); - return response.data; + return await this.request('delete list', () => this.axios.delete(`/lists/${listId}`)); } async getListUsers(listId: number): Promise { - let response = await this.axios.get(`/lists/getUsers`, { - params: { listId } - }); - return response.data; + return await this.request('get list users', () => + this.axios.get(`/lists/getUsers`, { + params: { listId }, + responseType: 'text' + }) + ); } async subscribeToList( listId: number, subscribers: { email?: string; userId?: string; dataFields?: Record }[] ): Promise { - let response = await this.axios.post('/lists/subscribe', { - listId, - subscribers - }); - return response.data; + return await this.request('subscribe users to list', () => + this.axios.post('/lists/subscribe', { + listId, + subscribers + }) + ); } async unsubscribeFromList( listId: number, subscribers: { email?: string; userId?: string }[] ): Promise { - let response = await this.axios.post('/lists/unsubscribe', { - listId, - subscribers - }); - return response.data; + return await this.request('unsubscribe users from list', () => + this.axios.post('/lists/unsubscribe', { + listId, + subscribers + }) + ); } // ─── Campaigns ────────────────────────────────────────────────── - async getCampaigns(): Promise { - let response = await this.axios.get('/campaigns'); - return response.data; + async getCampaigns(params?: { + page?: number; + pageSize?: number; + sort?: string; + campaignState?: string[]; + }): Promise { + return await this.request('list campaigns', () => + this.axios.get('/campaigns', { params }) + ); } async createCampaign(params: { name: string; - listIds: number[]; + listIds?: number[]; templateId: number; suppressionListIds?: number[]; sendAt?: string; + scheduleSend?: boolean; sendMode?: string; startTimeZone?: string; defaultTimeZone?: string; dataFields?: Record; }): Promise { - let response = await this.axios.post('/campaigns/create', params); - return response.data; + return await this.request('create campaign', () => + this.axios.post('/campaigns/create', params) + ); + } + + async getCampaign(campaignId: number): Promise { + return await this.request('get campaign', () => + this.axios.get(`/campaigns/${campaignId}`) + ); + } + + async archiveCampaigns(campaignIds: number[]): Promise { + return await this.request('archive campaigns', () => + this.axios.post('/campaigns/archive', { campaignIds }) + ); } async getCampaignMetrics( @@ -250,8 +288,9 @@ export class IterableClient { let params: Record = { campaignId }; if (startDateTime) params.startDateTime = startDateTime; if (endDateTime) params.endDateTime = endDateTime; - let response = await this.axios.get('/campaigns/metrics', { params }); - return response.data; + return await this.request('get campaign metrics', () => + this.axios.get('/campaigns/metrics', { params, responseType: 'text' }) + ); } // ─── Templates ────────────────────────────────────────────────── @@ -261,16 +300,21 @@ export class IterableClient { messageMedium?: string; startDateTime?: string; endDateTime?: string; + page?: number; + pageSize?: number; + sort?: string; }): Promise { - let response = await this.axios.get('/templates', { params }); - return response.data; + return await this.request('list templates', () => + this.axios.get('/templates', { params }) + ); } async getEmailTemplate(templateId: number): Promise { - let response = await this.axios.get(`/templates/email/get`, { - params: { templateId } - }); - return response.data; + return await this.request('get email template', () => + this.axios.get(`/templates/email/get`, { + params: { templateId } + }) + ); } async updateEmailTemplate(params: { @@ -285,29 +329,33 @@ export class IterableClient { plainText?: string; metadata?: Record; }): Promise { - let response = await this.axios.post('/templates/email/update', params); - return response.data; + return await this.request('update email template', () => + this.axios.post('/templates/email/update', params) + ); } async getPushTemplate(templateId: number): Promise { - let response = await this.axios.get('/templates/push/get', { - params: { templateId } - }); - return response.data; + return await this.request('get push template', () => + this.axios.get('/templates/push/get', { + params: { templateId } + }) + ); } async getSmsTemplate(templateId: number): Promise { - let response = await this.axios.get('/templates/sms/get', { - params: { templateId } - }); - return response.data; + return await this.request('get sms template', () => + this.axios.get('/templates/sms/get', { + params: { templateId } + }) + ); } async getInAppTemplate(templateId: number): Promise { - let response = await this.axios.get('/templates/inapp/get', { - params: { templateId } - }); - return response.data; + return await this.request('get in-app template', () => + this.axios.get('/templates/inapp/get', { + params: { templateId } + }) + ); } // ─── Email ────────────────────────────────────────────────────── @@ -321,8 +369,7 @@ export class IterableClient { allowRepeatMarketingSends?: boolean; metadata?: Record; }): Promise { - let response = await this.axios.post('/email/target', params); - return response.data; + return await this.request('send email', () => this.axios.post('/email/target', params)); } // ─── Push ─────────────────────────────────────────────────────── @@ -336,8 +383,7 @@ export class IterableClient { allowRepeatMarketingSends?: boolean; metadata?: Record; }): Promise { - let response = await this.axios.post('/push/target', params); - return response.data; + return await this.request('send push', () => this.axios.post('/push/target', params)); } // ─── SMS ──────────────────────────────────────────────────────── @@ -351,8 +397,7 @@ export class IterableClient { allowRepeatMarketingSends?: boolean; metadata?: Record; }): Promise { - let response = await this.axios.post('/sms/target', params); - return response.data; + return await this.request('send sms', () => this.axios.post('/sms/target', params)); } // ─── In-App ───────────────────────────────────────────────────── @@ -366,8 +411,7 @@ export class IterableClient { allowRepeatMarketingSends?: boolean; metadata?: Record; }): Promise { - let response = await this.axios.post('/inApp/target', params); - return response.data; + return await this.request('send in-app', () => this.axios.post('/inApp/target', params)); } // ─── Web Push ─────────────────────────────────────────────────── @@ -381,47 +425,56 @@ export class IterableClient { allowRepeatMarketingSends?: boolean; metadata?: Record; }): Promise { - let response = await this.axios.post('/webPush/target', params); - return response.data; + return await this.request('send web push', () => + this.axios.post('/webPush/target', params) + ); } // ─── Channels & Message Types ─────────────────────────────────── async getChannels(): Promise { - let response = await this.axios.get('/channels'); - return response.data; + return await this.request('get channels', () => this.axios.get('/channels')); } async getMessageTypes(): Promise { - let response = await this.axios.get('/messageTypes'); - return response.data; + return await this.request('get message types', () => this.axios.get('/messageTypes')); } // ─── Catalogs ─────────────────────────────────────────────────── async getCatalogs(): Promise { - let response = await this.axios.get('/catalogs'); - return response.data; + return await this.request('list catalogs', () => this.axios.get('/catalogs')); } async createCatalog(catalogName: string): Promise { - let response = await this.axios.post('/catalogs', { catalogName }); - return response.data; + return await this.request('create catalog', () => + this.axios.post(`/catalogs/${encodeURIComponent(catalogName)}`) + ); } async deleteCatalog(catalogName: string): Promise { - let response = await this.axios.delete(`/catalogs/${encodeURIComponent(catalogName)}`); - return response.data; + return await this.request('delete catalog', () => + this.axios.delete(`/catalogs/${encodeURIComponent(catalogName)}`) + ); } async getCatalogItems( catalogName: string, params?: { page?: number; pageSize?: number } ): Promise { - let response = await this.axios.get(`/catalogs/${encodeURIComponent(catalogName)}/items`, { - params - }); - return response.data; + return await this.request('list catalog items', () => + this.axios.get(`/catalogs/${encodeURIComponent(catalogName)}/items`, { + params + }) + ); + } + + async getCatalogItem(catalogName: string, itemId: string): Promise { + return await this.request('get catalog item', () => + this.axios.get( + `/catalogs/${encodeURIComponent(catalogName)}/items/${encodeURIComponent(itemId)}` + ) + ); } async bulkUploadCatalogItems( @@ -432,66 +485,77 @@ export class IterableClient { let body: Record = { documents: items }; if (replaceUploadedFieldsOnly !== undefined) body.replaceUploadedFieldsOnly = replaceUploadedFieldsOnly; - let response = await this.axios.post( - `/catalogs/${encodeURIComponent(catalogName)}/items`, - body + return await this.request('bulk upload catalog items', () => + this.axios.post(`/catalogs/${encodeURIComponent(catalogName)}/items`, body) ); - return response.data; } async deleteCatalogItems(catalogName: string, itemIds: string[]): Promise { - let response = await this.axios.delete( - `/catalogs/${encodeURIComponent(catalogName)}/items`, - { + return await this.request('delete catalog items', () => + this.axios.delete(`/catalogs/${encodeURIComponent(catalogName)}/items`, { data: { itemIds } - } + }) ); - return response.data; } // ─── Snippets ─────────────────────────────────────────────────── async getSnippets(): Promise { - let response = await this.axios.get('/snippets'); - return response.data; + return await this.request('list snippets', () => this.axios.get('/snippets')); } async createSnippet(params: { name: string; content: string }): Promise { - let response = await this.axios.post('/snippets', params); - return response.data; + return await this.request('create snippet', () => this.axios.post('/snippets', params)); + } + + async getSnippet(name: string): Promise { + return await this.request('get snippet', () => + this.axios.get(`/snippets/${encodeURIComponent(name)}`) + ); } async updateSnippet(params: { name: string; content: string }): Promise { - let response = await this.axios.post('/snippets/update', params); - return response.data; + return await this.request('update snippet', () => + this.axios.put(`/snippets/${encodeURIComponent(params.name)}`, { + content: params.content + }) + ); } async deleteSnippet(name: string): Promise { - let response = await this.axios.post('/snippets/delete', { name }); - return response.data; + return await this.request('delete snippet', () => + this.axios.delete(`/snippets/${encodeURIComponent(name)}`) + ); } // ─── Export ───────────────────────────────────────────────────── async exportData(params: { + format: 'csv' | 'json'; dataTypeName: string; range?: string; startDateTime?: string; endDateTime?: string; delimiter?: string; + omitFields?: string; + onlyFields?: string[]; campaignId?: number; - }): Promise { - let response = await this.axios.post('/export/data', params); - return response.data; + }): Promise { + let { format, ...query } = params; + let path = format === 'csv' ? '/export/data.csv' : '/export/data.json'; + return await this.request(`export data ${format}`, () => + this.axios.get(path, { params: query, responseType: 'text' }) + ); } async exportUserEvents(params: { email?: string; userId?: string; includeCustomEvents?: boolean; - }): Promise { - let response = await this.axios.get('/export/userEvents', { params }); - return response.data; + }): Promise { + return await this.request('export user events', () => + this.axios.get('/export/userEvents', { params, responseType: 'text' }) + ); } // ─── Subscriptions ────────────────────────────────────────────── @@ -505,14 +569,25 @@ export class IterableClient { campaignId?: number; templateId?: number; }): Promise { - let response = await this.axios.post('/users/updateSubscriptions', params); - return response.data; + return await this.request('update subscriptions', () => + this.axios.post('/users/updateSubscriptions', params) + ); } // ─── Webhooks ─────────────────────────────────────────────────── async getWebhooks(): Promise { - let response = await this.axios.get('/webhooks'); - return response.data; + return await this.request('get webhooks', () => this.axios.get('/webhooks')); + } + + // ─── Journeys ─────────────────────────────────────────────────── + + async getJourneys(params?: { + page?: number; + pageSize?: number; + sort?: string; + state?: string[]; + }): Promise { + return await this.request('list journeys', () => this.axios.get('/journeys', { params })); } } diff --git a/integrations/iterable/src/lib/errors.ts b/integrations/iterable/src/lib/errors.ts new file mode 100644 index 0000000000..720b7b4a0a --- /dev/null +++ b/integrations/iterable/src/lib/errors.ts @@ -0,0 +1,100 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.msg); + addDetail(details, value.message); + addDetail(details, value.error); + addDetail(details, value.error_description); + addDetail(details, value.code); + addDetail(details, value.reason); + collectDetails(value.errors, details); + collectDetails(value.params, details); +}; + +let extractIterableMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + if (typeof response.data.code === 'string') return response.data.code; + if (typeof response.data.error === 'string') return response.data.error; + + return undefined; +}; + +export let iterableServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let iterableApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = iterableServiceError( + `Iterable API ${operation} failed: ${statusLabelFor(response)}${extractIterableMessage(error)}` + ); + serviceError.data.reason = 'iterable_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/iterable/src/lib/validation.ts b/integrations/iterable/src/lib/validation.ts new file mode 100644 index 0000000000..b7b318e663 --- /dev/null +++ b/integrations/iterable/src/lib/validation.ts @@ -0,0 +1,43 @@ +import { iterableServiceError } from './errors'; + +type UserIdentity = { + email?: string; + userId?: string; +}; + +export let requireUserIdentity = (input: UserIdentity, label = 'user') => { + if (!input.email && !input.userId) { + throw iterableServiceError(`Provide either email or userId to identify the ${label}.`); + } + + if (input.email && input.userId) { + throw iterableServiceError(`Provide either email or userId for the ${label}, not both.`); + } +}; + +export let requireField = (value: T | undefined | null, name: string): T => { + if (value === undefined || value === null || value === '') { + throw iterableServiceError(`${name} is required.`); + } + + return value; +}; + +export let requireArrayField = (value: T[] | undefined, name: string): T[] => { + if (!value?.length) { + throw iterableServiceError(`${name} must include at least one item.`); + } + + return value; +}; + +export let requireRecordField = ( + value: Record | undefined, + name: string +): Record => { + if (!value || Object.keys(value).length === 0) { + throw iterableServiceError(`${name} must include at least one field.`); + } + + return value; +}; diff --git a/integrations/iterable/src/tools.schema.test.ts b/integrations/iterable/src/tools.schema.test.ts new file mode 100644 index 0000000000..8368b1a761 --- /dev/null +++ b/integrations/iterable/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Iterable tool input schemas', provider.actions); diff --git a/integrations/iterable/src/tools/delete-user.ts b/integrations/iterable/src/tools/delete-user.ts index f433fd82c0..78acdd9c46 100644 --- a/integrations/iterable/src/tools/delete-user.ts +++ b/integrations/iterable/src/tools/delete-user.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; export let deleteUser = SlateTool.create(spec, { @@ -25,6 +26,8 @@ export let deleteUser = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireUserIdentity(ctx.input); + let client = new IterableClient({ token: ctx.auth.token, dataCenter: ctx.config.dataCenter diff --git a/integrations/iterable/src/tools/export-data.ts b/integrations/iterable/src/tools/export-data.ts index e415c500b9..b032e8b4be 100644 --- a/integrations/iterable/src/tools/export-data.ts +++ b/integrations/iterable/src/tools/export-data.ts @@ -1,16 +1,74 @@ -import { SlateTool } from 'slates'; +import { Buffer } from 'node:buffer'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { iterableServiceError } from '../lib/errors'; +import { requireField, requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; +let exportDataTypeSchema = z.enum([ + 'emailSend', + 'emailOpen', + 'emailClick', + 'hostedUnsubscribeClick', + 'emailComplaint', + 'emailBounce', + 'emailSendSkip', + 'pushSend', + 'pushOpen', + 'pushUninstall', + 'pushBounce', + 'pushSendSkip', + 'inAppSend', + 'inAppOpen', + 'inAppClick', + 'inAppClose', + 'inAppDelete', + 'inAppDelivery', + 'inAppSendSkip', + 'inAppRecall', + 'inboxSession', + 'inboxMessageImpression', + 'smsSend', + 'smsBounce', + 'smsClick', + 'smsReceived', + 'smsSendSkip', + 'webPushSend', + 'webPushClick', + 'webPushSendSkip', + 'emailSubscribe', + 'emailUnSubscribe', + 'purchase', + 'customEvent', + 'user', + 'smsUsageInfo', + 'embeddedSend', + 'embeddedSendSkip', + 'embeddedClick', + 'embeddedReceived', + 'embeddedImpression', + 'embeddedSession', + 'unknownSession', + 'journeyExit', + 'whatsAppBounce', + 'whatsAppClick', + 'whatsAppReceived', + 'whatsAppSeen', + 'whatsAppSend', + 'whatsAppSendSkip', + 'whatsAppUsageInfo' +]); + export let exportData = SlateTool.create(spec, { name: 'Export Data', key: 'export_data', - description: `Exports user data, event data, or campaign metrics from Iterable as CSV. Supports exporting by data type and date range. Also supports exporting a specific user's events.`, + description: `Exports Iterable project data or a specific user's events. Returned CSV or JSON stream content is provided as a Slate attachment, with structured output limited to metadata.`, instructions: [ - 'Use dataTypeName to specify what to export: "user", "customEvent", "emailSend", "emailOpen", "emailClick", "emailBounce", "emailComplaint", "emailSubscribe", "emailUnsubscribe", "pushSend", "pushOpen", "pushBounce", "smsSend", "smsClick", "smsBounce", "inAppSend", "inAppOpen", "inAppClick", etc.' + 'For exportType "data", provide dataTypeName and either range or both startDateTime and endDateTime.', + 'For exportType "userEvents", provide either email or userId.' ], - constraints: ['Exports are limited to 100GB total, with files up to 10MB each.'], + constraints: ['Synchronous data exports are rate-limited by Iterable to 4 requests/minute.'], tags: { destructive: false, readOnly: true @@ -21,19 +79,30 @@ export let exportData = SlateTool.create(spec, { exportType: z .enum(['data', 'userEvents']) .describe('Type of export: bulk data export or specific user event export'), - dataTypeName: z - .string() + format: z + .enum(['csv', 'json']) + .optional() + .describe( + 'Attachment format for data exports. Defaults to csv. User event exports are JSON stream.' + ), + dataTypeName: exportDataTypeSchema .optional() .describe('Data type to export (required for data export)'), range: z - .string() + .enum(['Today', 'Yesterday', 'BeforeToday', 'All']) .optional() - .describe('Predefined range: "Today", "Yesterday", "Before today", "All"'), + .describe('Predefined UTC date range'), startDateTime: z .string() .optional() - .describe('Start datetime for custom range (ISO 8601)'), - endDateTime: z.string().optional().describe('End datetime for custom range (ISO 8601)'), + .describe('Export starting from, formatted yyyy-MM-dd HH:mm:ss [ZZ]'), + endDateTime: z + .string() + .optional() + .describe('Export ending before, formatted yyyy-MM-dd HH:mm:ss [ZZ]'), + omitFields: z.string().optional().describe('Fields to omit, comma separated'), + delimiter: z.string().optional().describe('CSV delimiter for csv data exports'), + onlyFields: z.array(z.string()).optional().describe('Only export these fields'), campaignId: z.number().optional().describe('Filter export by campaign ID'), email: z.string().optional().describe('User email (for userEvents export)'), userId: z.string().optional().describe('User ID (for userEvents export)'), @@ -45,14 +114,11 @@ export let exportData = SlateTool.create(spec, { ) .output( z.object({ - exportId: z - .string() - .optional() - .describe('ID of the export job (for async data exports)'), - events: z - .array(z.record(z.string(), z.any())) - .optional() - .describe('User events (for userEvents export)'), + exportType: z.string().describe('Export type requested'), + format: z.string().describe('Attachment format'), + contentType: z.string().describe('Attachment MIME type'), + byteLength: z.number().describe('UTF-8 byte length of the attachment content'), + attachmentCount: z.number().describe('Number of Slate attachments returned'), message: z.string().describe('Result message') }) ) @@ -63,35 +129,61 @@ export let exportData = SlateTool.create(spec, { }); if (ctx.input.exportType === 'userEvents') { + requireUserIdentity(ctx.input); let result = await client.exportUserEvents({ email: ctx.input.email, userId: ctx.input.userId, includeCustomEvents: ctx.input.includeCustomEvents }); - let events = result.events || []; + let content = typeof result === 'string' ? result : JSON.stringify(result); + let contentType = 'application/x-json-stream'; return { output: { - events, - message: `Exported ${events.length} event(s) for user.` + exportType: ctx.input.exportType, + format: 'json', + contentType, + byteLength: Buffer.byteLength(content, 'utf8'), + attachmentCount: 1, + message: 'Exported user events as an attachment.' }, - message: `Exported **${events.length}** event(s) for user **${ctx.input.email || ctx.input.userId}**.` + attachments: [createTextAttachment(content, contentType)], + message: `Exported user events for **${ctx.input.email || ctx.input.userId}** as a JSON stream attachment.` }; } - // data export + let dataTypeName = requireField(ctx.input.dataTypeName, 'dataTypeName'); + if (!ctx.input.range && (!ctx.input.startDateTime || !ctx.input.endDateTime)) { + throw iterableServiceError( + 'Provide range or both startDateTime and endDateTime for data exports.' + ); + } + + let format = ctx.input.format ?? 'csv'; let result = await client.exportData({ - dataTypeName: ctx.input.dataTypeName!, + format, + dataTypeName, range: ctx.input.range, startDateTime: ctx.input.startDateTime, endDateTime: ctx.input.endDateTime, + delimiter: ctx.input.delimiter, + omitFields: ctx.input.omitFields, + onlyFields: ctx.input.onlyFields, campaignId: ctx.input.campaignId }); + let content = typeof result === 'string' ? result : JSON.stringify(result); + let contentType = format === 'csv' ? 'text/csv' : 'application/x-json-stream'; + return { output: { - exportId: result.exportId, - message: `Data export started for "${ctx.input.dataTypeName}".` + exportType: ctx.input.exportType, + format, + contentType, + byteLength: Buffer.byteLength(content, 'utf8'), + attachmentCount: 1, + message: `Exported "${dataTypeName}" as an attachment.` }, - message: `Started data export for **${ctx.input.dataTypeName}**. Export ID: **${result.exportId || 'N/A'}**.` + attachments: [createTextAttachment(content, contentType)], + message: `Exported **${dataTypeName}** as a **${format.toUpperCase()}** attachment.` }; }) .build(); diff --git a/integrations/iterable/src/tools/get-user.ts b/integrations/iterable/src/tools/get-user.ts index 6e8cb4711f..cfc6203731 100644 --- a/integrations/iterable/src/tools/get-user.ts +++ b/integrations/iterable/src/tools/get-user.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; export let getUser = SlateTool.create(spec, { @@ -31,6 +32,8 @@ export let getUser = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireUserIdentity(ctx.input); + let client = new IterableClient({ token: ctx.auth.token, dataCenter: ctx.config.dataCenter diff --git a/integrations/iterable/src/tools/index.ts b/integrations/iterable/src/tools/index.ts index e3a87750fa..65843f153d 100644 --- a/integrations/iterable/src/tools/index.ts +++ b/integrations/iterable/src/tools/index.ts @@ -2,6 +2,7 @@ export * from './delete-user'; export * from './export-data'; export * from './get-channels'; export * from './get-user'; +export * from './list-journeys'; export * from './manage-campaigns'; export * from './manage-catalogs'; export * from './manage-lists'; diff --git a/integrations/iterable/src/tools/list-journeys.ts b/integrations/iterable/src/tools/list-journeys.ts new file mode 100644 index 0000000000..74fd8207b9 --- /dev/null +++ b/integrations/iterable/src/tools/list-journeys.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { IterableClient } from '../lib/client'; +import { spec } from '../spec'; + +export let listJourneys = SlateTool.create(spec, { + name: 'List Journeys', + key: 'list_journeys', + description: `List Iterable journeys (workflows) in the current project. Use this to inspect available automation workflows and archived workflow inventory.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + page: z.number().optional().describe('Page number, starting at 1'), + pageSize: z.number().optional().describe('Page size, up to 50'), + sort: z.string().optional().describe('Sort field with optional direction prefix'), + state: z + .array(z.string()) + .optional() + .describe('Filter by journey state. Use Archived to list archived journeys.') + }) + ) + .output( + z.object({ + journeys: z.array(z.record(z.string(), z.any())).describe('Iterable journeys'), + journeyCount: z.number().describe('Number of journeys returned'), + message: z.string().describe('Result message') + }) + ) + .handleInvocation(async ctx => { + let client = new IterableClient({ + token: ctx.auth.token, + dataCenter: ctx.config.dataCenter + }); + + let result = await client.getJourneys({ + page: ctx.input.page, + pageSize: ctx.input.pageSize, + sort: ctx.input.sort, + state: ctx.input.state + }); + let journeys = result.journeys || result.workflows || result.params?.journeys || []; + + return { + output: { + journeys, + journeyCount: journeys.length, + message: `Found ${journeys.length} journey(s).` + }, + message: `Retrieved **${journeys.length}** Iterable journey(s).` + }; + }) + .build(); diff --git a/integrations/iterable/src/tools/manage-campaigns.ts b/integrations/iterable/src/tools/manage-campaigns.ts index 87b910c431..6abc04171a 100644 --- a/integrations/iterable/src/tools/manage-campaigns.ts +++ b/integrations/iterable/src/tools/manage-campaigns.ts @@ -1,12 +1,13 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireArrayField, requireField } from '../lib/validation'; import { spec } from '../spec'; export let manageCampaigns = SlateTool.create(spec, { name: 'Manage Campaigns', key: 'manage_campaigns', - description: `List existing campaigns, create new blast campaigns, or retrieve campaign metrics. Campaigns can target email, push, SMS, in-app, and web push channels.`, + description: `List existing campaigns, retrieve campaign details, create new campaigns from templates, archive campaigns, or retrieve campaign metrics. Campaigns can target email, push, SMS, in-app, embedded, and web push channels.`, tags: { destructive: false, readOnly: false @@ -15,9 +16,9 @@ export let manageCampaigns = SlateTool.create(spec, { .input( z.object({ action: z - .enum(['list', 'create', 'metrics']) + .enum(['list', 'get', 'create', 'archive', 'metrics']) .describe( - 'Operation: list all campaigns, create a new blast campaign, or get campaign metrics' + 'Operation: list campaigns, get one campaign, create a campaign, archive campaigns, or get campaign metrics' ), name: z.string().optional().describe('Campaign name (required for create)'), listIds: z @@ -32,8 +33,22 @@ export let manageCampaigns = SlateTool.create(spec, { .array(z.number()) .optional() .describe('List IDs to suppress from the campaign'), + scheduleSend: z + .boolean() + .optional() + .describe( + 'Whether Iterable should immediately schedule the campaign. Defaults to false.' + ), sendAt: z.string().optional().describe('ISO 8601 datetime to schedule the campaign'), campaignId: z.number().optional().describe('Campaign ID (required for metrics)'), + campaignIds: z.array(z.number()).optional().describe('Campaign IDs to archive'), + page: z.number().optional().describe('Page number for listing campaigns'), + pageSize: z.number().optional().describe('Page size for listing campaigns'), + sort: z.string().optional().describe('Sort field with optional direction prefix'), + campaignState: z + .array(z.string()) + .optional() + .describe('Filter listed campaigns by state, e.g. Draft, Ready, Archived'), startDateTime: z.string().optional().describe('Start datetime for metrics range'), endDateTime: z.string().optional().describe('End datetime for metrics range') }) @@ -54,10 +69,13 @@ export let manageCampaigns = SlateTool.create(spec, { .optional() .describe('List of campaigns'), campaignId: z.number().optional().describe('Newly created campaign ID'), + campaign: z.record(z.string(), z.any()).optional().describe('Campaign details'), + archivedCampaignIds: z.array(z.number()).optional().describe('Campaign IDs archived'), metrics: z .record(z.string(), z.any()) .optional() .describe('Campaign performance metrics'), + metricsText: z.string().optional().describe('Raw campaign metrics response'), message: z.string().describe('Result message') }) ) @@ -68,7 +86,12 @@ export let manageCampaigns = SlateTool.create(spec, { }); if (ctx.input.action === 'list') { - let result = await client.getCampaigns(); + let result = await client.getCampaigns({ + page: ctx.input.page, + pageSize: ctx.input.pageSize, + sort: ctx.input.sort, + campaignState: ctx.input.campaignState + }); let campaigns = (result.campaigns || []).map((c: any) => ({ campaignId: c.id, name: c.name, @@ -86,35 +109,69 @@ export let manageCampaigns = SlateTool.create(spec, { }; } + if (ctx.input.action === 'get') { + let campaignId = requireField(ctx.input.campaignId, 'campaignId'); + let campaign = await client.getCampaign(campaignId); + return { + output: { + campaign, + message: `Retrieved campaign ${campaignId}.` + }, + message: `Retrieved campaign **${campaignId}**.` + }; + } + if (ctx.input.action === 'create') { + let name = requireField(ctx.input.name, 'name'); + let templateId = requireField(ctx.input.templateId, 'templateId'); let result = await client.createCampaign({ - name: ctx.input.name!, + name, listIds: ctx.input.listIds!, - templateId: ctx.input.templateId!, + templateId, suppressionListIds: ctx.input.suppressionListIds, + scheduleSend: ctx.input.scheduleSend ?? false, sendAt: ctx.input.sendAt }); return { output: { campaignId: result.campaignId, - message: `Campaign "${ctx.input.name}" created.` + message: `Campaign "${name}" created.` + }, + message: `Created campaign **${name}** with ID **${result.campaignId}**.` + }; + } + + if (ctx.input.action === 'archive') { + let campaignIds = ctx.input.campaignIds?.length + ? ctx.input.campaignIds + : ctx.input.campaignId !== undefined + ? [ctx.input.campaignId] + : undefined; + campaignIds = requireArrayField(campaignIds, 'campaignIds'); + await client.archiveCampaigns(campaignIds); + return { + output: { + archivedCampaignIds: campaignIds, + message: `Archived ${campaignIds.length} campaign(s).` }, - message: `Created campaign **${ctx.input.name}** with ID **${result.campaignId}**.` + message: `Archived **${campaignIds.length}** campaign(s).` }; } // metrics + let campaignId = requireField(ctx.input.campaignId, 'campaignId'); let result = await client.getCampaignMetrics( - ctx.input.campaignId!, + campaignId, ctx.input.startDateTime, ctx.input.endDateTime ); return { output: { - metrics: result, - message: `Retrieved metrics for campaign ${ctx.input.campaignId}.` + metrics: typeof result === 'object' && result !== null ? result : undefined, + metricsText: typeof result === 'string' ? result : undefined, + message: `Retrieved metrics for campaign ${campaignId}.` }, - message: `Retrieved metrics for campaign **${ctx.input.campaignId}**.` + message: `Retrieved metrics for campaign **${campaignId}**.` }; }) .build(); diff --git a/integrations/iterable/src/tools/manage-catalogs.ts b/integrations/iterable/src/tools/manage-catalogs.ts index c9469bc133..9ba0b91157 100644 --- a/integrations/iterable/src/tools/manage-catalogs.ts +++ b/integrations/iterable/src/tools/manage-catalogs.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireArrayField, requireField, requireRecordField } from '../lib/validation'; import { spec } from '../spec'; export let manageCatalogs = SlateTool.create(spec, { @@ -25,6 +26,7 @@ export let manageCatalogs = SlateTool.create(spec, { 'createCatalog', 'deleteCatalog', 'listItems', + 'getItem', 'uploadItems', 'deleteItems' ]) @@ -39,6 +41,7 @@ export let manageCatalogs = SlateTool.create(spec, { .describe( 'Items to upload. Object where keys are item IDs and values are item field objects (for uploadItems)' ), + itemId: z.string().optional().describe('Item ID to retrieve (for getItem)'), itemIds: z.array(z.string()).optional().describe('Item IDs to delete (for deleteItems)'), replaceUploadedFieldsOnly: z .boolean() @@ -60,6 +63,7 @@ export let manageCatalogs = SlateTool.create(spec, { .optional() .describe('List of catalogs'), items: z.array(z.record(z.string(), z.any())).optional().describe('Catalog items'), + item: z.record(z.string(), z.any()).optional().describe('Catalog item details'), message: z.string().describe('Result message') }) ) @@ -87,27 +91,30 @@ export let manageCatalogs = SlateTool.create(spec, { } if (ctx.input.action === 'createCatalog') { - await client.createCatalog(ctx.input.catalogName!); + let catalogName = requireField(ctx.input.catalogName, 'catalogName'); + await client.createCatalog(catalogName); return { output: { - message: `Catalog "${ctx.input.catalogName}" created.` + message: `Catalog "${catalogName}" created.` }, - message: `Created catalog **${ctx.input.catalogName}**.` + message: `Created catalog **${catalogName}**.` }; } if (ctx.input.action === 'deleteCatalog') { - await client.deleteCatalog(ctx.input.catalogName!); + let catalogName = requireField(ctx.input.catalogName, 'catalogName'); + await client.deleteCatalog(catalogName); return { output: { - message: `Catalog "${ctx.input.catalogName}" deleted.` + message: `Catalog "${catalogName}" deleted.` }, - message: `Deleted catalog **${ctx.input.catalogName}**.` + message: `Deleted catalog **${catalogName}**.` }; } if (ctx.input.action === 'listItems') { - let result = await client.getCatalogItems(ctx.input.catalogName!, { + let catalogName = requireField(ctx.input.catalogName, 'catalogName'); + let result = await client.getCatalogItems(catalogName, { page: ctx.input.page, pageSize: ctx.input.pageSize }); @@ -116,34 +123,51 @@ export let manageCatalogs = SlateTool.create(spec, { return { output: { items, - message: `Found ${items.length} item(s) in catalog "${ctx.input.catalogName}".` + message: `Found ${items.length} item(s) in catalog "${catalogName}".` }, - message: `Retrieved **${items.length}** item(s) from catalog **${ctx.input.catalogName}**.` + message: `Retrieved **${items.length}** item(s) from catalog **${catalogName}**.` + }; + } + + if (ctx.input.action === 'getItem') { + let catalogName = requireField(ctx.input.catalogName, 'catalogName'); + let itemId = requireField(ctx.input.itemId, 'itemId'); + let item = await client.getCatalogItem(catalogName, itemId); + return { + output: { + item, + message: `Retrieved item "${itemId}" from catalog "${catalogName}".` + }, + message: `Retrieved item **${itemId}** from catalog **${catalogName}**.` }; } if (ctx.input.action === 'uploadItems') { + let catalogName = requireField(ctx.input.catalogName, 'catalogName'); + let items = requireRecordField(ctx.input.items, 'items'); await client.bulkUploadCatalogItems( - ctx.input.catalogName!, - ctx.input.items!, + catalogName, + items, ctx.input.replaceUploadedFieldsOnly ); - let count = Object.keys(ctx.input.items!).length; + let count = Object.keys(items).length; return { output: { - message: `Upload of ${count} item(s) to catalog "${ctx.input.catalogName}" accepted.` + message: `Upload of ${count} item(s) to catalog "${catalogName}" accepted.` }, - message: `Uploaded **${count}** item(s) to catalog **${ctx.input.catalogName}**. The upload is processed asynchronously.` + message: `Uploaded **${count}** item(s) to catalog **${catalogName}**. The upload is processed asynchronously.` }; } // deleteItems - await client.deleteCatalogItems(ctx.input.catalogName!, ctx.input.itemIds!); + let catalogName = requireField(ctx.input.catalogName, 'catalogName'); + let itemIds = requireArrayField(ctx.input.itemIds, 'itemIds'); + await client.deleteCatalogItems(catalogName, itemIds); return { output: { - message: `Deleted ${ctx.input.itemIds!.length} item(s) from catalog "${ctx.input.catalogName}".` + message: `Deleted ${itemIds.length} item(s) from catalog "${catalogName}".` }, - message: `Deleted **${ctx.input.itemIds!.length}** item(s) from catalog **${ctx.input.catalogName}**.` + message: `Deleted **${itemIds.length}** item(s) from catalog **${catalogName}**.` }; }) .build(); diff --git a/integrations/iterable/src/tools/manage-lists.ts b/integrations/iterable/src/tools/manage-lists.ts index 442e8d2495..798a2336c9 100644 --- a/integrations/iterable/src/tools/manage-lists.ts +++ b/integrations/iterable/src/tools/manage-lists.ts @@ -1,8 +1,26 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireArrayField, requireField, requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; +let parseListUsers = (result: unknown) => { + if (typeof result === 'string') { + return result + .split(/\r?\n|,/) + .map(user => user.trim()) + .filter(Boolean); + } + + let value = result as any; + let users = value?.users || value?.params?.users || value?.emails || []; + if (!Array.isArray(users)) { + return []; + } + + return users.map(user => String(user)).filter(Boolean); +}; + export let manageLists = SlateTool.create(spec, { name: 'Manage Lists', key: 'manage_lists', @@ -15,9 +33,9 @@ export let manageLists = SlateTool.create(spec, { .input( z.object({ action: z - .enum(['list', 'create', 'delete', 'subscribe', 'unsubscribe']) + .enum(['list', 'create', 'delete', 'getUsers', 'subscribe', 'unsubscribe']) .describe( - 'Operation to perform: list all lists, create a new list, delete a list, subscribe users to a list, or unsubscribe users from a list' + 'Operation to perform: list all lists, create a new list, delete a list, get users in a list, subscribe users to a list, or unsubscribe users from a list' ), listName: z.string().optional().describe('Name for a new list (required for create)'), listId: z @@ -61,6 +79,8 @@ export let manageLists = SlateTool.create(spec, { .number() .optional() .describe('Number of failed subscribe/unsubscribe operations'), + users: z.array(z.string()).optional().describe('Users returned by getUsers'), + userCount: z.number().optional().describe('Number of users returned by getUsers'), message: z.string().describe('Result message') }) ) @@ -88,56 +108,83 @@ export let manageLists = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - let result = await client.createList(ctx.input.listName!); + let listName = requireField(ctx.input.listName, 'listName'); + let result = await client.createList(listName); return { output: { listId: result.listId, - message: `List "${ctx.input.listName}" created.` + message: `List "${listName}" created.` }, - message: `Created list **${ctx.input.listName}** with ID **${result.listId}**.` + message: `Created list **${listName}** with ID **${result.listId}**.` }; } if (ctx.input.action === 'delete') { - await client.deleteList(ctx.input.listId!); + let listId = requireField(ctx.input.listId, 'listId'); + await client.deleteList(listId); + return { + output: { + message: `List ${listId} deleted.` + }, + message: `Deleted list **${listId}**.` + }; + } + + if (ctx.input.action === 'getUsers') { + let listId = requireField(ctx.input.listId, 'listId'); + let users = parseListUsers(await client.getListUsers(listId)); return { output: { - message: `List ${ctx.input.listId} deleted.` + users, + userCount: users.length, + message: `Found ${users.length} user(s) in list ${listId}.` }, - message: `Deleted list **${ctx.input.listId}**.` + message: `Retrieved **${users.length}** user(s) from list **${listId}**.` }; } if (ctx.input.action === 'subscribe') { - let subs = (ctx.input.subscribers || []).map(s => ({ + let listId = requireField(ctx.input.listId, 'listId'); + let subscribers = requireArrayField(ctx.input.subscribers, 'subscribers'); + for (let subscriber of subscribers) { + requireUserIdentity(subscriber, 'subscriber'); + } + + let subs = subscribers.map(s => ({ email: s.email, userId: s.userId, dataFields: s.subscriberFields })); - let result = await client.subscribeToList(ctx.input.listId!, subs); + let result = await client.subscribeToList(listId, subs); return { output: { successCount: result.successCount, failCount: result.failCount, - message: `Subscribed ${result.successCount || subs.length} user(s) to list ${ctx.input.listId}.` + message: `Subscribed ${result.successCount || subs.length} user(s) to list ${listId}.` }, - message: `Subscribed **${result.successCount || subs.length}** user(s) to list **${ctx.input.listId}**.` + message: `Subscribed **${result.successCount || subs.length}** user(s) to list **${listId}**.` }; } // unsubscribe - let subs = (ctx.input.subscribers || []).map(s => ({ + let listId = requireField(ctx.input.listId, 'listId'); + let subscribers = requireArrayField(ctx.input.subscribers, 'subscribers'); + for (let subscriber of subscribers) { + requireUserIdentity(subscriber, 'subscriber'); + } + + let subs = subscribers.map(s => ({ email: s.email, userId: s.userId })); - let result = await client.unsubscribeFromList(ctx.input.listId!, subs); + let result = await client.unsubscribeFromList(listId, subs); return { output: { successCount: result.successCount, failCount: result.failCount, - message: `Unsubscribed ${result.successCount || subs.length} user(s) from list ${ctx.input.listId}.` + message: `Unsubscribed ${result.successCount || subs.length} user(s) from list ${listId}.` }, - message: `Unsubscribed **${result.successCount || subs.length}** user(s) from list **${ctx.input.listId}**.` + message: `Unsubscribed **${result.successCount || subs.length}** user(s) from list **${listId}**.` }; }) .build(); diff --git a/integrations/iterable/src/tools/manage-snippets.ts b/integrations/iterable/src/tools/manage-snippets.ts index 655263531e..f96f960404 100644 --- a/integrations/iterable/src/tools/manage-snippets.ts +++ b/integrations/iterable/src/tools/manage-snippets.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireField } from '../lib/validation'; import { spec } from '../spec'; export let manageSnippets = SlateTool.create(spec, { @@ -14,11 +15,13 @@ export let manageSnippets = SlateTool.create(spec, { }) .input( z.object({ - action: z.enum(['list', 'create', 'update', 'delete']).describe('Operation to perform'), + action: z + .enum(['list', 'get', 'create', 'update', 'delete']) + .describe('Operation to perform'), name: z .string() .optional() - .describe('Snippet name (required for create, update, delete)'), + .describe('Snippet name or ID (required for get, create, update, delete)'), content: z .string() .optional() @@ -38,6 +41,7 @@ export let manageSnippets = SlateTool.create(spec, { ) .optional() .describe('List of snippets'), + snippet: z.record(z.string(), z.any()).optional().describe('Snippet details'), message: z.string().describe('Result message') }) ) @@ -64,39 +68,56 @@ export let manageSnippets = SlateTool.create(spec, { }; } + if (ctx.input.action === 'get') { + let name = requireField(ctx.input.name, 'name'); + let snippet = await client.getSnippet(name); + return { + output: { + snippet, + message: `Retrieved snippet "${name}".` + }, + message: `Retrieved snippet **${name}**.` + }; + } + if (ctx.input.action === 'create') { + let name = requireField(ctx.input.name, 'name'); + let content = requireField(ctx.input.content, 'content'); await client.createSnippet({ - name: ctx.input.name!, - content: ctx.input.content! + name, + content }); return { output: { - message: `Snippet "${ctx.input.name}" created.` + message: `Snippet "${name}" created.` }, - message: `Created snippet **${ctx.input.name}**.` + message: `Created snippet **${name}**.` }; } if (ctx.input.action === 'update') { + let name = requireField(ctx.input.name, 'name'); + let content = requireField(ctx.input.content, 'content'); await client.updateSnippet({ - name: ctx.input.name!, - content: ctx.input.content! + name, + content }); return { output: { - message: `Snippet "${ctx.input.name}" updated.` + message: `Snippet "${name}" updated.` }, - message: `Updated snippet **${ctx.input.name}**.` + message: `Updated snippet **${name}**.` }; } // delete - await client.deleteSnippet(ctx.input.name!); + let name = requireField(ctx.input.name, 'name'); + await client.deleteSnippet(name); return { output: { - message: `Snippet "${ctx.input.name}" deleted.` + message: `Snippet "${name}" deleted.` }, - message: `Deleted snippet **${ctx.input.name}**.` + message: `Deleted snippet **${name}**.` }; }) .build(); diff --git a/integrations/iterable/src/tools/manage-templates.ts b/integrations/iterable/src/tools/manage-templates.ts index 9a8a2a5377..a674b2bcda 100644 --- a/integrations/iterable/src/tools/manage-templates.ts +++ b/integrations/iterable/src/tools/manage-templates.ts @@ -1,6 +1,8 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { iterableServiceError } from '../lib/errors'; +import { requireField } from '../lib/validation'; import { spec } from '../spec'; export let manageTemplates = SlateTool.create(spec, { @@ -29,6 +31,17 @@ export let manageTemplates = SlateTool.create(spec, { .enum(['Email', 'Push', 'SMS', 'InApp']) .optional() .describe('Filter by channel when listing, or channel type when getting a template'), + startDateTime: z + .string() + .optional() + .describe('Only list templates created at or after this datetime'), + endDateTime: z + .string() + .optional() + .describe('Only list templates created before this datetime'), + page: z.number().optional().describe('Page number for listing templates'), + pageSize: z.number().optional().describe('Page size for listing templates'), + sort: z.string().optional().describe('Sort field with optional direction prefix'), templateId: z.number().optional().describe('Template ID (required for get and update)'), name: z.string().optional().describe('New name for the email template (for update)'), fromName: z.string().optional().describe('Sender name (for email update)'), @@ -71,7 +84,12 @@ export let manageTemplates = SlateTool.create(spec, { if (ctx.input.action === 'list') { let result = await client.getTemplates({ templateType: ctx.input.templateType, - messageMedium: ctx.input.messageMedium + messageMedium: ctx.input.messageMedium, + startDateTime: ctx.input.startDateTime, + endDateTime: ctx.input.endDateTime, + page: ctx.input.page, + pageSize: ctx.input.pageSize, + sort: ctx.input.sort }); let templates = (result.templates || []).map((t: any) => ({ templateId: t.templateId, @@ -93,32 +111,37 @@ export let manageTemplates = SlateTool.create(spec, { if (ctx.input.action === 'get') { let template: any; let medium = ctx.input.messageMedium || 'Email'; + let templateId = requireField(ctx.input.templateId, 'templateId'); switch (medium) { case 'Push': - template = await client.getPushTemplate(ctx.input.templateId!); + template = await client.getPushTemplate(templateId); break; case 'SMS': - template = await client.getSmsTemplate(ctx.input.templateId!); + template = await client.getSmsTemplate(templateId); break; case 'InApp': - template = await client.getInAppTemplate(ctx.input.templateId!); + template = await client.getInAppTemplate(templateId); break; default: - template = await client.getEmailTemplate(ctx.input.templateId!); + template = await client.getEmailTemplate(templateId); break; } return { output: { template, - message: `Retrieved ${medium} template ${ctx.input.templateId}.` + message: `Retrieved ${medium} template ${templateId}.` }, - message: `Retrieved **${medium}** template **${ctx.input.templateId}**.` + message: `Retrieved **${medium}** template **${templateId}**.` }; } // update (email only via API) + if (ctx.input.messageMedium && ctx.input.messageMedium !== 'Email') { + throw iterableServiceError('Only Email templates can be updated by this tool.'); + } + let templateId = requireField(ctx.input.templateId, 'templateId'); let result = await client.updateEmailTemplate({ - templateId: ctx.input.templateId!, + templateId, name: ctx.input.name, fromName: ctx.input.fromName, fromEmail: ctx.input.fromEmail, @@ -130,9 +153,9 @@ export let manageTemplates = SlateTool.create(spec, { }); return { output: { - message: result.msg || `Email template ${ctx.input.templateId} updated.` + message: result.msg || `Email template ${templateId} updated.` }, - message: `Updated email template **${ctx.input.templateId}**.` + message: `Updated email template **${templateId}**.` }; }) .build(); diff --git a/integrations/iterable/src/tools/send-message.ts b/integrations/iterable/src/tools/send-message.ts index 6b14bdc9df..2f9b2e6da3 100644 --- a/integrations/iterable/src/tools/send-message.ts +++ b/integrations/iterable/src/tools/send-message.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; export let sendMessage = SlateTool.create(spec, { @@ -47,6 +48,14 @@ export let sendMessage = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireUserIdentity( + { + email: ctx.input.recipientEmail, + userId: ctx.input.recipientUserId + }, + 'recipient' + ); + let client = new IterableClient({ token: ctx.auth.token, dataCenter: ctx.config.dataCenter diff --git a/integrations/iterable/src/tools/track-event.ts b/integrations/iterable/src/tools/track-event.ts index 4ab8fe5300..a23253191b 100644 --- a/integrations/iterable/src/tools/track-event.ts +++ b/integrations/iterable/src/tools/track-event.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; export let trackEvent = SlateTool.create(spec, { @@ -43,6 +44,8 @@ export let trackEvent = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireUserIdentity(ctx.input); + let client = new IterableClient({ token: ctx.auth.token, dataCenter: ctx.config.dataCenter diff --git a/integrations/iterable/src/tools/track-purchase.ts b/integrations/iterable/src/tools/track-purchase.ts index 90f5c3442c..746a208820 100644 --- a/integrations/iterable/src/tools/track-purchase.ts +++ b/integrations/iterable/src/tools/track-purchase.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireArrayField, requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; let commerceItemSchema = z.object({ @@ -57,6 +58,9 @@ export let trackPurchase = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireUserIdentity(ctx.input); + requireArrayField(ctx.input.items, 'items'); + let client = new IterableClient({ token: ctx.auth.token, dataCenter: ctx.config.dataCenter diff --git a/integrations/iterable/src/tools/update-cart.ts b/integrations/iterable/src/tools/update-cart.ts index a16c22e947..d673cb3db4 100644 --- a/integrations/iterable/src/tools/update-cart.ts +++ b/integrations/iterable/src/tools/update-cart.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireArrayField, requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; let cartItemSchema = z.object({ @@ -46,6 +47,9 @@ export let updateCart = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireUserIdentity(ctx.input); + requireArrayField(ctx.input.items, 'items'); + let client = new IterableClient({ token: ctx.auth.token, dataCenter: ctx.config.dataCenter diff --git a/integrations/iterable/src/tools/update-subscriptions.ts b/integrations/iterable/src/tools/update-subscriptions.ts index 92493c4478..5be722af2f 100644 --- a/integrations/iterable/src/tools/update-subscriptions.ts +++ b/integrations/iterable/src/tools/update-subscriptions.ts @@ -1,6 +1,8 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { iterableServiceError } from '../lib/errors'; +import { requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; export let updateSubscriptions = SlateTool.create(spec, { @@ -39,6 +41,17 @@ export let updateSubscriptions = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireUserIdentity(ctx.input); + if ( + ctx.input.emailListIds === undefined && + ctx.input.unsubscribedChannelIds === undefined && + ctx.input.unsubscribedMessageTypeIds === undefined + ) { + throw iterableServiceError( + 'Provide at least one of emailListIds, unsubscribedChannelIds, or unsubscribedMessageTypeIds.' + ); + } + let client = new IterableClient({ token: ctx.auth.token, dataCenter: ctx.config.dataCenter diff --git a/integrations/iterable/src/tools/upsert-user.ts b/integrations/iterable/src/tools/upsert-user.ts index c1a5bbed55..c01d51970b 100644 --- a/integrations/iterable/src/tools/upsert-user.ts +++ b/integrations/iterable/src/tools/upsert-user.ts @@ -1,14 +1,15 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { IterableClient } from '../lib/client'; +import { requireUserIdentity } from '../lib/validation'; import { spec } from '../spec'; export let upsertUser = SlateTool.create(spec, { name: 'Create or Update User', key: 'upsert_user', - description: `Creates a new user or updates an existing user profile in Iterable. Identify the user by **email** and/or **userId**. Attach custom data fields to the profile for personalization in campaigns and journeys.`, + description: `Creates a new user or updates an existing user profile in Iterable. Identify the user by **email** or **userId**. Attach custom data fields to the profile for personalization in campaigns and journeys.`, instructions: [ - 'Provide either email or userId (or both) to identify the user.', + 'Provide either email or userId to identify the user, not both.', 'Use preferUserId: true if creating a user with only a userId and no email.' ], tags: { @@ -49,6 +50,8 @@ export let upsertUser = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireUserIdentity(ctx.input); + let client = new IterableClient({ token: ctx.auth.token, dataCenter: ctx.config.dataCenter diff --git a/integrations/iterable/vitest.config.ts b/integrations/iterable/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/iterable/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/kibana/docs/SPEC.md b/integrations/kibana/docs/SPEC.md index 84aeeaed9f..b8568b7692 100644 --- a/integrations/kibana/docs/SPEC.md +++ b/integrations/kibana/docs/SPEC.md @@ -30,13 +30,16 @@ To run APIs in non-default spaces, you must add s/{space_id}/ to the path. All A Export sets of saved objects that you want to import into Kibana, resolve import errors, and rotate an encryption key for encrypted saved objects with the saved objects APIs. These objects include dashboards, visualizations, maps, data views, Canvas workpads, and other saved objects. -- Import/export saved objects in NDJSON format for migration between environments. +- Export saved objects in NDJSON format for migration between environments. The + integration returns exported NDJSON as a Slate attachment and keeps structured + output to metadata such as MIME type, byte length, line count, and attachment + count. - Copy or share saved objects between spaces. - Saved objects are not backwards-compatible across Kibana versions. ### Data Views (Index Patterns) -Create, read, update, and delete data views that define which Elasticsearch indices Kibana queries. An index pattern identifies one or more Elasticsearch indices that you want to explore with Kibana. Kibana looks for index names that match the specified pattern. Supports runtime fields and field formatting configuration. +Create, read, update, and delete data views that define which Elasticsearch indices Kibana queries. An index pattern identifies one or more Elasticsearch indices that you want to explore with Kibana. Kibana looks for index names that match the specified pattern. Supports runtime fields and field formatting configuration. The integration can also get, set, and unset the default data view for the current Kibana space. ### Spaces @@ -52,6 +55,7 @@ When a condition is met, the rule tracks it as an alert and runs the actions tha - Create, update, delete, enable/disable, mute/unmute, and snooze alerting rules. - Rule types include Elasticsearch query, index threshold, metric threshold, log threshold, and more. +- List available rule types to discover rule type IDs, action groups, authorized consumers, and license availability before creating a rule. - Configure action frequency (on every check, on status change, or throttled intervals). - Rules are authorized using API keys scoped to the creating user's privileges. @@ -60,6 +64,7 @@ When a condition is met, the rule tracks it as an alert and runs the actions tha Manage connectors that integrate with external services for rule-triggered notifications. Supported connector types include email, Slack, PagerDuty, webhook, Jira, ServiceNow, Microsoft Teams, Opsgenie, and more. - Create, update, delete, and test connectors. +- List available connector types to discover connector type IDs, supported features, and license/config availability before creating a connector. - The Webhook connector uses axios to send a request to a web service. Webhook connectors support basic auth, OAuth 2.0, and SSL authentication. ### Cases @@ -79,6 +84,7 @@ You must have all privileges for the SLOs feature in the Observability section o Manage Elastic Agents and their policies programmatically. - Create and manage agent policies and integration (package) policies. +- List and manage Fleet package policies that attach Elastic integrations to agent policies. - To get a list of valid enrollment tokens from Fleet, call GET /api/fleet/enrollment_api_keys. - Enroll, unenroll, and upgrade agents; manage tags and assign policies. @@ -90,6 +96,11 @@ Elasticsearch API user: uses an Elasticsearch client, cURL, or Kibana Dev Tools Manage role-based access control including Kibana feature privileges. Create and manage roles with specific space-level feature access. Invalidate user sessions. +### Error Handling + +Integration validation failures and upstream Kibana API failures are wrapped in +`ServiceError` from `@lowerdeck/error` for user-facing tool behavior. + ### Detection Rules (Elastic Security) Create and manage security detection rules for SIEM use cases. Supports various rule types including query-based, threshold, EQL, indicator match, and machine learning rules. Configure automated actions/notifications when detections fire. diff --git a/integrations/kibana/package.json b/integrations/kibana/package.json index 89ba9606b5..933c282d2a 100644 --- a/integrations/kibana/package.json +++ b/integrations/kibana/package.json @@ -7,12 +7,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/kibana/src/index.ts b/integrations/kibana/src/index.ts index 9cabf58fbb..a1c93e4eb5 100644 --- a/integrations/kibana/src/index.ts +++ b/integrations/kibana/src/index.ts @@ -8,16 +8,22 @@ import { getKibanaStatus, listAgentPolicies, listConnectors, + listConnectorTypes, listDataViews, listFleetAgents, + listPackagePolicies, listRoles, + listRuleTypes, listSpaces, manageAgentPolicy, manageCase, manageConnector, manageDataView, + manageDefaultDataView, + managePackagePolicy, manageRole, manageRule, + manageRuleSnooze, manageSavedObject, manageSLO, manageSpace, @@ -36,10 +42,14 @@ export let provider = Slate.create({ exportSavedObjects, listDataViews, manageDataView, + manageDefaultDataView, listSpaces, manageSpace, + listRuleTypes, searchRules, manageRule, + manageRuleSnooze, + listConnectorTypes, listConnectors, manageConnector, executeConnector, @@ -52,6 +62,8 @@ export let provider = Slate.create({ manageAgentPolicy, listFleetAgents, getEnrollmentTokens, + listPackagePolicies, + managePackagePolicy, listRoles, manageRole, getKibanaStatus diff --git a/integrations/kibana/src/lib/client.ts b/integrations/kibana/src/lib/client.ts index ccf54ec257..62596792f5 100644 --- a/integrations/kibana/src/lib/client.ts +++ b/integrations/kibana/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { kibanaApiError } from './errors'; export interface KibanaClientConfig { kibanaUrl: string; @@ -22,6 +23,11 @@ export class KibanaClient { 'kbn-xsrf': 'true' } }); + + this.axios.interceptors?.response?.use( + (response: any) => response, + (error: unknown) => Promise.reject(kibanaApiError(error)) + ); } private getApiPath(path: string): string { @@ -185,6 +191,19 @@ export class KibanaClient { await this.axios.delete(this.getApiPath(`/api/data_views/data_view/${dataViewId}`)); } + async getDefaultDataView(): Promise { + let response = await this.axios.get(this.getApiPath('/api/data_views/default')); + return response.data; + } + + async setDefaultDataView(dataViewId: string | null, force?: boolean): Promise { + let body: Record = { data_view_id: dataViewId }; + if (force !== undefined) body.force = force; + + let response = await this.axios.post(this.getApiPath('/api/data_views/default'), body); + return response.data; + } + // ─── Spaces ──────────────────────────────────────────────────── async getSpaces(): Promise { @@ -257,6 +276,11 @@ export class KibanaClient { return response.data; } + async getRuleTypes(): Promise { + let response = await this.axios.get(this.getApiPath('/api/alerting/rule_types')); + return response.data; + } + async getRule(ruleId: string): Promise { let response = await this.axios.get(this.getApiPath(`/api/alerting/rule/${ruleId}`)); return response.data; @@ -354,6 +378,20 @@ export class KibanaClient { await this.axios.post(this.getApiPath(`/api/alerting/rule/${ruleId}/_unmute_all`)); } + async scheduleRuleSnooze(ruleId: string, schedule: Record): Promise { + let response = await this.axios.post( + this.getApiPath(`/api/alerting/rule/${ruleId}/snooze_schedule`), + { schedule } + ); + return response.data; + } + + async deleteRuleSnooze(ruleId: string, scheduleId: string): Promise { + await this.axios.delete( + this.getApiPath(`/api/alerting/rule/${ruleId}/snooze_schedule/${scheduleId}`) + ); + } + // ─── Connectors ──────────────────────────────────────────────── async getConnectors(): Promise { @@ -415,8 +453,10 @@ export class KibanaClient { return response.data; } - async getConnectorTypes(): Promise { - let response = await this.axios.get(this.getApiPath('/api/actions/connector_types')); + async getConnectorTypes(featureId?: string): Promise { + let response = await this.axios.get(this.getApiPath('/api/actions/connector_types'), { + params: featureId ? { feature_id: featureId } : undefined + }); return response.data; } @@ -667,6 +707,62 @@ export class KibanaClient { return response.data; } + async getPackagePolicies(params?: { + page?: number; + perPage?: number; + sortField?: string; + sortOrder?: 'asc' | 'desc'; + showUpgradeable?: boolean; + kuery?: string; + format?: 'simplified' | 'legacy'; + withAgentCount?: boolean; + }): Promise { + let response = await this.axios.get(this.getApiPath('/api/fleet/package_policies'), { + params + }); + return response.data; + } + + async getPackagePolicy(packagePolicyId: string): Promise { + let response = await this.axios.get( + this.getApiPath(`/api/fleet/package_policies/${packagePolicyId}`) + ); + return response.data; + } + + async createPackagePolicy( + packagePolicy: Record, + format?: 'simplified' | 'legacy' + ): Promise { + let response = await this.axios.post( + this.getApiPath('/api/fleet/package_policies'), + packagePolicy, + { params: format ? { format } : undefined } + ); + return response.data; + } + + async updatePackagePolicy( + packagePolicyId: string, + packagePolicy: Record, + format?: 'simplified' | 'legacy' + ): Promise { + let response = await this.axios.put( + this.getApiPath(`/api/fleet/package_policies/${packagePolicyId}`), + packagePolicy, + { params: format ? { format } : undefined } + ); + return response.data; + } + + async deletePackagePolicy(packagePolicyId: string, force?: boolean): Promise { + let response = await this.axios.delete( + this.getApiPath(`/api/fleet/package_policies/${packagePolicyId}`), + { params: force ? { force: true } : undefined } + ); + return response.data; + } + // ─── Roles ───────────────────────────────────────────────────── async getRoles(): Promise { diff --git a/integrations/kibana/src/lib/errors.ts b/integrations/kibana/src/lib/errors.ts new file mode 100644 index 0000000000..28be6a7b58 --- /dev/null +++ b/integrations/kibana/src/lib/errors.ts @@ -0,0 +1,96 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.error); + addDetail(details, value.errorType); + addDetail(details, value.message); + addDetail(details, value.statusCode); + collectDetails(value.attributes, details); + collectDetails(value.errors, details); +}; + +let extractKibanaMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) { + return undefined; + } + + let code = response.data.errorType ?? response.data.error; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +}; + +export let kibanaServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let kibanaApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = kibanaServiceError( + `Kibana API ${operation} failed: ${statusLabelFor(response)}${extractKibanaMessage(error)}` + ); + serviceError.data.reason = 'kibana_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/kibana/src/tools.schema.test.ts b/integrations/kibana/src/tools.schema.test.ts new file mode 100644 index 0000000000..a1d8681fbd --- /dev/null +++ b/integrations/kibana/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Kibana tool input schemas', provider.actions); diff --git a/integrations/kibana/src/tools/export-saved-objects.ts b/integrations/kibana/src/tools/export-saved-objects.ts index bebba8ee96..7b6ef58bf1 100644 --- a/integrations/kibana/src/tools/export-saved-objects.ts +++ b/integrations/kibana/src/tools/export-saved-objects.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -39,7 +39,10 @@ Specify either object types to export all objects of those types, or provide spe ) .output( z.object({ - ndjson: z.string().describe('Exported saved objects in NDJSON format') + contentType: z.string().describe('MIME type of the exported attachment'), + contentLength: z.number().describe('Size of the exported NDJSON in bytes'), + lineCount: z.number().describe('Number of NDJSON lines returned by Kibana'), + attachmentCount: z.number().describe('Number of attachments returned') }) ) .handleInvocation(async ctx => { @@ -54,10 +57,17 @@ Specify either object types to export all objects of those types, or provide spe excludeExportDetails: ctx.input.excludeExportDetails }); - let lineCount = ndjson.trim().split('\n').length; + let lineCount = ndjson.trim() ? ndjson.trim().split('\n').length : 0; + let contentType = 'application/x-ndjson'; return { - output: { ndjson }, + output: { + contentType, + contentLength: Buffer.byteLength(ndjson, 'utf8'), + lineCount, + attachmentCount: 1 + }, + attachments: [createTextAttachment(ndjson, contentType)], message: `Exported **${lineCount}** saved objects in NDJSON format.` }; }) diff --git a/integrations/kibana/src/tools/index.ts b/integrations/kibana/src/tools/index.ts index edb8710539..5658067baa 100644 --- a/integrations/kibana/src/tools/index.ts +++ b/integrations/kibana/src/tools/index.ts @@ -1,11 +1,16 @@ export * from './export-saved-objects'; export * from './get-status'; +export * from './list-connector-types'; +export * from './list-rule-types'; export * from './manage-case'; export * from './manage-connector'; export * from './manage-data-view'; +export * from './manage-default-data-view'; export * from './manage-fleet'; +export * from './manage-package-policy'; export * from './manage-role'; export * from './manage-rule'; +export * from './manage-rule-snooze'; export * from './manage-saved-object'; export * from './manage-slo'; export * from './manage-space'; diff --git a/integrations/kibana/src/tools/list-connector-types.ts b/integrations/kibana/src/tools/list-connector-types.ts new file mode 100644 index 0000000000..c02aa66ac0 --- /dev/null +++ b/integrations/kibana/src/tools/list-connector-types.ts @@ -0,0 +1,86 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listConnectorTypes = SlateTool.create(spec, { + name: 'List Connector Types', + key: 'list_connector_types', + description: `List Kibana connector types available for rules and cases, including license and feature availability. Use this before creating connectors to discover connectorTypeId values and supported features.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + featureId: z + .string() + .optional() + .describe('Optional feature ID filter, such as "alerting" or "cases"') + }) + ) + .output( + z.object({ + connectorTypes: z + .array( + z.object({ + connectorTypeId: z.string().describe('Connector type ID, such as ".webhook"'), + name: z.string().describe('Human-readable connector type name'), + description: z.string().optional().describe('Connector type description'), + enabled: z.boolean().optional().describe('Whether this connector type is enabled'), + enabledInConfig: z + .boolean() + .optional() + .describe('Whether this connector type is enabled in Kibana config'), + enabledInLicense: z + .boolean() + .optional() + .describe('Whether the current license enables this connector type'), + minimumLicenseRequired: z + .string() + .optional() + .describe('Minimum Elastic license required'), + supportedFeatureIds: z + .array(z.string()) + .optional() + .describe('Kibana features supported by this connector type'), + isDeprecated: z.boolean().optional().describe('Whether the type is deprecated'), + isExperimental: z + .boolean() + .optional() + .describe('Whether the type is experimental or technical preview'), + isSystemActionType: z + .boolean() + .optional() + .describe('Whether this is a system action connector type'), + source: z.string().optional().describe('Source of the connector type definition') + }) + ) + .describe('Connector types available in Kibana') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let connectorTypes = await client.getConnectorTypes(ctx.input.featureId); + + let mapped = connectorTypes.map((type: any) => ({ + connectorTypeId: type.id, + name: type.name, + description: type.description, + enabled: type.enabled, + enabledInConfig: type.enabled_in_config, + enabledInLicense: type.enabled_in_license, + minimumLicenseRequired: type.minimum_license_required, + supportedFeatureIds: type.supported_feature_ids, + isDeprecated: type.is_deprecated, + isExperimental: type.is_experimental, + isSystemActionType: type.is_system_action_type, + source: type.source + })); + + return { + output: { connectorTypes: mapped }, + message: `Found **${mapped.length}** connector types.` + }; + }) + .build(); diff --git a/integrations/kibana/src/tools/list-rule-types.ts b/integrations/kibana/src/tools/list-rule-types.ts new file mode 100644 index 0000000000..94e75e943b --- /dev/null +++ b/integrations/kibana/src/tools/list-rule-types.ts @@ -0,0 +1,78 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listRuleTypes = SlateTool.create(spec, { + name: 'List Rule Types', + key: 'list_rule_types', + description: `List Kibana alerting rule types available to the authenticated user. Use this before creating rules to discover ruleTypeId values, action groups, required license level, and authorized consumers.`, + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + ruleTypes: z + .array( + z.object({ + ruleTypeId: z.string().describe('Rule type ID, such as ".es-query"'), + name: z.string().describe('Human-readable rule type name'), + actionGroups: z + .array( + z.object({ + id: z.string(), + name: z.string() + }) + ) + .optional() + .describe('Action groups supported by this rule type'), + defaultActionGroupId: z.string().optional().describe('Default action group ID'), + recoveryActionGroup: z + .object({ + id: z.string(), + name: z.string() + }) + .optional() + .describe('Recovery action group for resolved alerts'), + authorizedConsumers: z + .record(z.string(), z.object({ read: z.boolean(), all: z.boolean() })) + .optional() + .describe('Consumers and privileges available for this rule type'), + producer: z.string().optional().describe('Producer plugin or feature'), + enabledInLicense: z + .boolean() + .optional() + .describe('Whether the current license enables this rule type'), + minimumLicenseRequired: z + .string() + .optional() + .describe('Minimum Elastic license required') + }) + ) + .describe('Alerting rule types available in Kibana') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let ruleTypes = await client.getRuleTypes(); + + let mapped = ruleTypes.map((type: any) => ({ + ruleTypeId: type.id, + name: type.name, + actionGroups: type.action_groups, + defaultActionGroupId: type.default_action_group_id, + recoveryActionGroup: type.recovery_action_group, + authorizedConsumers: type.authorized_consumers, + producer: type.producer, + enabledInLicense: type.enabled_in_license, + minimumLicenseRequired: type.minimum_license_required + })); + + return { + output: { ruleTypes: mapped }, + message: `Found **${mapped.length}** alerting rule types.` + }; + }) + .build(); diff --git a/integrations/kibana/src/tools/manage-case.ts b/integrations/kibana/src/tools/manage-case.ts index 45bd9758b8..d16f803442 100644 --- a/integrations/kibana/src/tools/manage-case.ts +++ b/integrations/kibana/src/tools/manage-case.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -155,7 +156,7 @@ export let manageCase = SlateTool.create(spec, { } = ctx.input; if (action === 'get') { - if (!caseId) throw new Error('caseId is required for get action'); + if (!caseId) throw kibanaServiceError('caseId is required for get action'); let c = await client.getCase(caseId, includeComments); return { output: mapCase(c), @@ -164,9 +165,9 @@ export let manageCase = SlateTool.create(spec, { } if (action === 'create') { - if (!title) throw new Error('title is required for create action'); - if (!description) throw new Error('description is required for create action'); - if (!owner) throw new Error('owner is required for create action'); + if (!title) throw kibanaServiceError('title is required for create action'); + if (!description) throw kibanaServiceError('description is required for create action'); + if (!owner) throw kibanaServiceError('owner is required for create action'); let c = await client.createCase({ title, description, @@ -181,8 +182,8 @@ export let manageCase = SlateTool.create(spec, { } if (action === 'update') { - if (!caseId) throw new Error('caseId is required for update action'); - if (!version) throw new Error('version is required for update action'); + if (!caseId) throw kibanaServiceError('caseId is required for update action'); + if (!version) throw kibanaServiceError('version is required for update action'); let result = await client.updateCase(caseId, version, { title, description, @@ -198,7 +199,7 @@ export let manageCase = SlateTool.create(spec, { } if (action === 'delete') { - if (!caseId) throw new Error('caseId is required for delete action'); + if (!caseId) throw kibanaServiceError('caseId is required for delete action'); await client.deleteCases([caseId]); return { output: { @@ -210,7 +211,7 @@ export let manageCase = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw kibanaServiceError(`Unknown action: ${action}`); }) .build(); @@ -249,6 +250,12 @@ export let addCaseComment = SlateTool.create(spec, { let { caseId, owner, comment, alertId, alertIndex } = ctx.input; let type = alertId ? 'alert' : 'user'; + if (type === 'user' && !comment) { + throw kibanaServiceError('comment is required when adding a user comment'); + } + if (type === 'alert' && !alertIndex) { + throw kibanaServiceError('alertIndex is required when adding an alert comment'); + } let result = await client.addCaseComment(caseId, { type, diff --git a/integrations/kibana/src/tools/manage-connector.ts b/integrations/kibana/src/tools/manage-connector.ts index 4b8aab95a4..20ff508132 100644 --- a/integrations/kibana/src/tools/manage-connector.ts +++ b/integrations/kibana/src/tools/manage-connector.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -106,7 +107,7 @@ Supported types include email, Slack, PagerDuty, webhook, Jira, ServiceNow, Micr let { action, connectorId, name, connectorTypeId, config, secrets } = ctx.input; if (action === 'get') { - if (!connectorId) throw new Error('connectorId is required for get action'); + if (!connectorId) throw kibanaServiceError('connectorId is required for get action'); let c = await client.getConnector(connectorId); return { output: { @@ -123,8 +124,9 @@ Supported types include email, Slack, PagerDuty, webhook, Jira, ServiceNow, Micr } if (action === 'create') { - if (!name) throw new Error('name is required for create action'); - if (!connectorTypeId) throw new Error('connectorTypeId is required for create action'); + if (!name) throw kibanaServiceError('name is required for create action'); + if (!connectorTypeId) + throw kibanaServiceError('connectorTypeId is required for create action'); let c = await client.createConnector({ name, connectorTypeId, config, secrets }); return { output: { @@ -140,7 +142,7 @@ Supported types include email, Slack, PagerDuty, webhook, Jira, ServiceNow, Micr } if (action === 'update') { - if (!connectorId) throw new Error('connectorId is required for update action'); + if (!connectorId) throw kibanaServiceError('connectorId is required for update action'); let c = await client.updateConnector(connectorId, { name, config, secrets }); return { output: { @@ -154,7 +156,7 @@ Supported types include email, Slack, PagerDuty, webhook, Jira, ServiceNow, Micr } if (action === 'delete') { - if (!connectorId) throw new Error('connectorId is required for delete action'); + if (!connectorId) throw kibanaServiceError('connectorId is required for delete action'); await client.deleteConnector(connectorId); return { output: { @@ -167,7 +169,7 @@ Supported types include email, Slack, PagerDuty, webhook, Jira, ServiceNow, Micr }; } - throw new Error(`Unknown action: ${action}`); + throw kibanaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/kibana/src/tools/manage-data-view.ts b/integrations/kibana/src/tools/manage-data-view.ts index b651bdc642..245f46af03 100644 --- a/integrations/kibana/src/tools/manage-data-view.ts +++ b/integrations/kibana/src/tools/manage-data-view.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -123,7 +124,7 @@ Supports configuring runtime fields, time fields, field formats, and source filt } = ctx.input; if (action === 'get') { - if (!dataViewId) throw new Error('dataViewId is required for get action'); + if (!dataViewId) throw kibanaServiceError('dataViewId is required for get action'); let result = await client.getDataView(dataViewId); let dv = result.data_view ?? result; return { @@ -141,7 +142,8 @@ Supports configuring runtime fields, time fields, field formats, and source filt } if (action === 'create') { - if (!title) throw new Error('title (index pattern) is required for create action'); + if (!title) + throw kibanaServiceError('title (index pattern) is required for create action'); let result = await client.createDataView({ title, name, @@ -167,7 +169,7 @@ Supports configuring runtime fields, time fields, field formats, and source filt } if (action === 'update') { - if (!dataViewId) throw new Error('dataViewId is required for update action'); + if (!dataViewId) throw kibanaServiceError('dataViewId is required for update action'); let result = await client.updateDataView(dataViewId, { title, name, @@ -192,7 +194,7 @@ Supports configuring runtime fields, time fields, field formats, and source filt } if (action === 'delete') { - if (!dataViewId) throw new Error('dataViewId is required for delete action'); + if (!dataViewId) throw kibanaServiceError('dataViewId is required for delete action'); await client.deleteDataView(dataViewId); return { output: { @@ -204,6 +206,6 @@ Supports configuring runtime fields, time fields, field formats, and source filt }; } - throw new Error(`Unknown action: ${action}`); + throw kibanaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/kibana/src/tools/manage-default-data-view.ts b/integrations/kibana/src/tools/manage-default-data-view.ts new file mode 100644 index 0000000000..a13c7a1eb9 --- /dev/null +++ b/integrations/kibana/src/tools/manage-default-data-view.ts @@ -0,0 +1,80 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let manageDefaultDataView = SlateTool.create(spec, { + name: 'Manage Default Data View', + key: 'manage_default_data_view', + description: `Get, set, or unset the default Kibana data view for the current space. The default data view is used when no specific data view is selected.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z.enum(['get', 'set', 'unset']).describe('Action to perform'), + dataViewId: z + .string() + .optional() + .describe('Data view ID to set as default. Required for set.'), + force: z + .boolean() + .optional() + .describe('Overwrite an existing default data view when setting the default') + }) + ) + .output( + z.object({ + defaultDataViewId: z + .string() + .nullable() + .optional() + .describe('Current default data view ID, or null when unset'), + acknowledged: z.boolean().optional().describe('Whether Kibana acknowledged the change') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let { action, dataViewId, force } = ctx.input; + + if (action === 'get') { + let result = await client.getDefaultDataView(); + return { + output: { defaultDataViewId: result.data_view_id ?? null }, + message: result.data_view_id + ? `Default data view is \`${result.data_view_id}\`.` + : 'No default data view is configured.' + }; + } + + if (action === 'set') { + if (!dataViewId) { + throw kibanaServiceError('dataViewId is required for set action'); + } + + let result = await client.setDefaultDataView(dataViewId, force); + return { + output: { + defaultDataViewId: dataViewId, + acknowledged: result.acknowledged + }, + message: `Set default data view to \`${dataViewId}\`.` + }; + } + + if (action === 'unset') { + let result = await client.setDefaultDataView(null, force); + return { + output: { + defaultDataViewId: null, + acknowledged: result.acknowledged + }, + message: 'Unset the default data view.' + }; + } + + throw kibanaServiceError(`Unknown action: ${action}`); + }) + .build(); diff --git a/integrations/kibana/src/tools/manage-fleet.ts b/integrations/kibana/src/tools/manage-fleet.ts index e6b9fc6e2b..335652b87e 100644 --- a/integrations/kibana/src/tools/manage-fleet.ts +++ b/integrations/kibana/src/tools/manage-fleet.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -102,7 +103,7 @@ export let manageAgentPolicy = SlateTool.create(spec, { let { action, policyId, name, description, namespace, monitoringEnabled } = ctx.input; if (action === 'get') { - if (!policyId) throw new Error('policyId is required for get action'); + if (!policyId) throw kibanaServiceError('policyId is required for get action'); let result = await client.getAgentPolicy(policyId); let p = result.item ?? result; return { @@ -121,7 +122,7 @@ export let manageAgentPolicy = SlateTool.create(spec, { } if (action === 'create') { - if (!name) throw new Error('name is required for create action'); + if (!name) throw kibanaServiceError('name is required for create action'); let result = await client.createAgentPolicy({ name, description, @@ -144,7 +145,7 @@ export let manageAgentPolicy = SlateTool.create(spec, { } if (action === 'update') { - if (!policyId) throw new Error('policyId is required for update action'); + if (!policyId) throw kibanaServiceError('policyId is required for update action'); let updateParams: Record = {}; if (name !== undefined) updateParams.name = name; if (description !== undefined) updateParams.description = description; @@ -168,7 +169,7 @@ export let manageAgentPolicy = SlateTool.create(spec, { } if (action === 'delete') { - if (!policyId) throw new Error('policyId is required for delete action'); + if (!policyId) throw kibanaServiceError('policyId is required for delete action'); await client.deleteAgentPolicy(policyId); return { output: { @@ -180,7 +181,7 @@ export let manageAgentPolicy = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw kibanaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/kibana/src/tools/manage-package-policy.ts b/integrations/kibana/src/tools/manage-package-policy.ts new file mode 100644 index 0000000000..8ac7321176 --- /dev/null +++ b/integrations/kibana/src/tools/manage-package-policy.ts @@ -0,0 +1,191 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +let packagePolicyOutputSchema = z.object({ + packagePolicyId: z.string().describe('Package policy ID'), + name: z.string().optional().describe('Package policy name'), + namespace: z.string().optional().describe('Policy namespace'), + enabled: z.boolean().optional().describe('Whether the package policy is enabled'), + packageName: z.string().optional().describe('Elastic package name'), + packageVersion: z.string().optional().describe('Elastic package version'), + policyIds: z.array(z.string()).optional().describe('Assigned Fleet agent policy IDs'), + agentCount: z.number().optional().describe('Number of agents using the package policy'), + updatedAt: z.string().optional().describe('Last update timestamp'), + createdAt: z.string().optional().describe('Creation timestamp'), + packagePolicy: z + .record(z.string(), z.any()) + .optional() + .describe('Raw package policy returned by Kibana'), + deleted: z.boolean().optional().describe('Whether the package policy was deleted') +}); + +let mapPackagePolicy = (policy: any) => ({ + packagePolicyId: policy.id, + name: policy.name, + namespace: policy.namespace, + enabled: policy.enabled, + packageName: policy.package?.name, + packageVersion: policy.package?.version, + policyIds: policy.policy_ids, + agentCount: policy.agents, + updatedAt: policy.updated_at, + createdAt: policy.created_at, + packagePolicy: policy +}); + +export let listPackagePolicies = SlateTool.create(spec, { + name: 'List Package Policies', + key: 'list_package_policies', + description: `List Fleet package policies in Kibana. Package policies attach Elastic integrations, such as Nginx or System, to Fleet agent policies.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + kuery: z.string().optional().describe('KQL query string to filter results'), + page: z.number().optional().describe('Page number'), + perPage: z.number().optional().describe('Number of results per page'), + sortField: z.string().optional().describe('Field to sort results by'), + sortOrder: z.enum(['asc', 'desc']).optional().describe('Sort order'), + showUpgradeable: z + .boolean() + .optional() + .describe('Only show package policies with available upgrades'), + format: z.enum(['simplified', 'legacy']).optional().describe('Response format'), + withAgentCount: z.boolean().optional().describe('Include agent counts') + }) + ) + .output( + z.object({ + total: z.number().describe('Total number of package policies'), + page: z.number().describe('Current page'), + perPage: z.number().describe('Results per page'), + packagePolicies: z.array(packagePolicyOutputSchema).describe('Package policies') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.getPackagePolicies({ + kuery: ctx.input.kuery, + page: ctx.input.page, + perPage: ctx.input.perPage, + sortField: ctx.input.sortField, + sortOrder: ctx.input.sortOrder, + showUpgradeable: ctx.input.showUpgradeable, + format: ctx.input.format, + withAgentCount: ctx.input.withAgentCount + }); + + let packagePolicies = (result.items ?? []).map(mapPackagePolicy); + + return { + output: { + total: result.total ?? 0, + page: result.page ?? 1, + perPage: result.perPage ?? 20, + packagePolicies + }, + message: `Found **${result.total ?? 0}** package policies.` + }; + }) + .build(); + +export let managePackagePolicy = SlateTool.create(spec, { + name: 'Manage Package Policy', + key: 'manage_package_policy', + description: `Get, create, update, or delete a Fleet package policy. Package policies attach Elastic integration packages to Fleet agent policies. Provide packagePolicy as the raw Kibana package policy request body for create and update.`, + instructions: [ + 'Use list_package_policies to find existing packagePolicyId values.', + 'For create and update, packagePolicy should match Kibana API fields such as name, namespace, package, policy_ids, inputs, and enabled.', + 'Deleting requires Fleet agent policy and integrations privileges.' + ], + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z.enum(['get', 'create', 'update', 'delete']).describe('Action to perform'), + packagePolicyId: z + .string() + .optional() + .describe('Package policy ID. Required for get, update, and delete.'), + packagePolicy: z + .record(z.string(), z.any()) + .optional() + .describe('Raw package policy request body. Required for create and update.'), + format: z.enum(['simplified', 'legacy']).optional().describe('Response format'), + force: z + .boolean() + .optional() + .describe('Force delete a managed package policy. Applies to delete only.') + }) + ) + .output(packagePolicyOutputSchema) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let { action, packagePolicyId, packagePolicy, format, force } = ctx.input; + + if (action === 'get') { + if (!packagePolicyId) { + throw kibanaServiceError('packagePolicyId is required for get action'); + } + + let result = await client.getPackagePolicy(packagePolicyId); + return { + output: mapPackagePolicy(result.item ?? result), + message: `Retrieved package policy \`${packagePolicyId}\`.` + }; + } + + if (action === 'create') { + if (!packagePolicy) { + throw kibanaServiceError('packagePolicy is required for create action'); + } + + let result = await client.createPackagePolicy(packagePolicy, format); + let policy = result.item ?? result; + return { + output: mapPackagePolicy(policy), + message: `Created package policy \`${policy.name ?? policy.id}\`.` + }; + } + + if (action === 'update') { + if (!packagePolicyId) { + throw kibanaServiceError('packagePolicyId is required for update action'); + } + if (!packagePolicy) { + throw kibanaServiceError('packagePolicy is required for update action'); + } + + let result = await client.updatePackagePolicy(packagePolicyId, packagePolicy, format); + let policy = result.item ?? result; + return { + output: mapPackagePolicy(policy), + message: `Updated package policy \`${packagePolicyId}\`.` + }; + } + + if (action === 'delete') { + if (!packagePolicyId) { + throw kibanaServiceError('packagePolicyId is required for delete action'); + } + + let result = await client.deletePackagePolicy(packagePolicyId, force); + return { + output: { + packagePolicyId: result.id ?? packagePolicyId, + deleted: true + }, + message: `Deleted package policy \`${packagePolicyId}\`.` + }; + } + + throw kibanaServiceError(`Unknown action: ${action}`); + }) + .build(); diff --git a/integrations/kibana/src/tools/manage-role.ts b/integrations/kibana/src/tools/manage-role.ts index d0f458674f..3d16ce22d3 100644 --- a/integrations/kibana/src/tools/manage-role.ts +++ b/integrations/kibana/src/tools/manage-role.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -195,6 +196,6 @@ export let manageRole = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw kibanaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/kibana/src/tools/manage-rule-snooze.ts b/integrations/kibana/src/tools/manage-rule-snooze.ts new file mode 100644 index 0000000000..062b84f6c1 --- /dev/null +++ b/integrations/kibana/src/tools/manage-rule-snooze.ts @@ -0,0 +1,98 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +let recurringScheduleSchema = z.object({ + every: z.string().optional().describe('Recurring interval, such as "1w" or "15d"'), + occurrences: z.number().optional().describe('Number of recurrences'), + end: z.string().optional().describe('ISO timestamp when recurrence ends'), + onWeekDay: z + .array(z.string()) + .optional() + .describe('Weekday recurrence values such as ["MO"]'), + onMonth: z.array(z.number()).optional().describe('Months for a recurring schedule'), + onMonthDay: z + .array(z.number()) + .optional() + .describe('Days of the month for a recurring schedule') +}); + +let customSnoozeScheduleSchema = z.object({ + duration: z.string().describe('Snooze duration, such as "1h" or "30m"'), + start: z.string().describe('ISO timestamp when the snooze starts'), + timezone: z.string().optional().describe('IANA timezone for the schedule'), + recurring: recurringScheduleSchema.optional().describe('Optional recurring schedule') +}); + +let snoozeScheduleSchema = z.object({ + custom: customSnoozeScheduleSchema.describe('Custom snooze schedule') +}); + +export let manageRuleSnooze = SlateTool.create(spec, { + name: 'Manage Rule Snooze', + key: 'manage_rule_snooze', + description: `Schedule or delete a Kibana alerting rule snooze schedule. Snooze schedules temporarily suppress rule notifications during maintenance windows or planned downtime.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z.enum(['schedule', 'delete']).describe('Action to perform'), + ruleId: z.string().describe('ID of the rule'), + scheduleId: z.string().optional().describe('Snooze schedule ID. Required for delete.'), + schedule: snoozeScheduleSchema + .optional() + .describe('Snooze schedule. Required for schedule.') + }) + ) + .output( + z.object({ + ruleId: z.string().describe('Rule ID'), + scheduleId: z.string().optional().describe('Snooze schedule ID'), + schedule: z.record(z.string(), z.any()).optional().describe('Created schedule details'), + deleted: z.boolean().optional().describe('Whether a snooze schedule was deleted') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let { action, ruleId, scheduleId, schedule } = ctx.input; + + if (action === 'schedule') { + if (!schedule) { + throw kibanaServiceError('schedule is required for schedule action'); + } + + let result = await client.scheduleRuleSnooze(ruleId, schedule); + let createdSchedule = result.schedule ?? result; + return { + output: { + ruleId, + scheduleId: createdSchedule.id, + schedule: createdSchedule + }, + message: `Scheduled snooze for rule \`${ruleId}\`.` + }; + } + + if (action === 'delete') { + if (!scheduleId) { + throw kibanaServiceError('scheduleId is required for delete action'); + } + + await client.deleteRuleSnooze(ruleId, scheduleId); + return { + output: { + ruleId, + scheduleId, + deleted: true + }, + message: `Deleted snooze schedule \`${scheduleId}\` for rule \`${ruleId}\`.` + }; + } + + throw kibanaServiceError(`Unknown action: ${action}`); + }) + .build(); diff --git a/integrations/kibana/src/tools/manage-rule.ts b/integrations/kibana/src/tools/manage-rule.ts index 39171fccdb..e2b84a606e 100644 --- a/integrations/kibana/src/tools/manage-rule.ts +++ b/integrations/kibana/src/tools/manage-rule.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -151,7 +152,7 @@ Supports Elasticsearch query, index threshold, metric threshold, log threshold, })); if (action === 'get') { - if (!ruleId) throw new Error('ruleId is required for get action'); + if (!ruleId) throw kibanaServiceError('ruleId is required for get action'); let rule = await client.getRule(ruleId); return { output: mapRule(rule), @@ -160,11 +161,11 @@ Supports Elasticsearch query, index threshold, metric threshold, log threshold, } if (action === 'create') { - if (!name) throw new Error('name is required for create action'); - if (!ruleTypeId) throw new Error('ruleTypeId is required for create action'); - if (!consumer) throw new Error('consumer is required for create action'); - if (!schedule) throw new Error('schedule is required for create action'); - if (!ruleParams) throw new Error('ruleParams is required for create action'); + if (!name) throw kibanaServiceError('name is required for create action'); + if (!ruleTypeId) throw kibanaServiceError('ruleTypeId is required for create action'); + if (!consumer) throw kibanaServiceError('consumer is required for create action'); + if (!schedule) throw kibanaServiceError('schedule is required for create action'); + if (!ruleParams) throw kibanaServiceError('ruleParams is required for create action'); let rule = await client.createRule({ name, @@ -185,7 +186,7 @@ Supports Elasticsearch query, index threshold, metric threshold, log threshold, } if (action === 'update') { - if (!ruleId) throw new Error('ruleId is required for update action'); + if (!ruleId) throw kibanaServiceError('ruleId is required for update action'); let rule = await client.updateRule(ruleId, { name, schedule, @@ -202,7 +203,7 @@ Supports Elasticsearch query, index threshold, metric threshold, log threshold, } if (action === 'delete') { - if (!ruleId) throw new Error('ruleId is required for delete action'); + if (!ruleId) throw kibanaServiceError('ruleId is required for delete action'); await client.deleteRule(ruleId); return { output: { @@ -220,7 +221,7 @@ Supports Elasticsearch query, index threshold, metric threshold, log threshold, } if (action === 'enable') { - if (!ruleId) throw new Error('ruleId is required for enable action'); + if (!ruleId) throw kibanaServiceError('ruleId is required for enable action'); await client.enableRule(ruleId); let rule = await client.getRule(ruleId); return { @@ -230,7 +231,7 @@ Supports Elasticsearch query, index threshold, metric threshold, log threshold, } if (action === 'disable') { - if (!ruleId) throw new Error('ruleId is required for disable action'); + if (!ruleId) throw kibanaServiceError('ruleId is required for disable action'); await client.disableRule(ruleId); let rule = await client.getRule(ruleId); return { @@ -240,7 +241,7 @@ Supports Elasticsearch query, index threshold, metric threshold, log threshold, } if (action === 'mute') { - if (!ruleId) throw new Error('ruleId is required for mute action'); + if (!ruleId) throw kibanaServiceError('ruleId is required for mute action'); await client.muteAllAlerts(ruleId); let rule = await client.getRule(ruleId); return { @@ -250,7 +251,7 @@ Supports Elasticsearch query, index threshold, metric threshold, log threshold, } if (action === 'unmute') { - if (!ruleId) throw new Error('ruleId is required for unmute action'); + if (!ruleId) throw kibanaServiceError('ruleId is required for unmute action'); await client.unmuteAllAlerts(ruleId); let rule = await client.getRule(ruleId); return { @@ -259,6 +260,6 @@ Supports Elasticsearch query, index threshold, metric threshold, log threshold, }; } - throw new Error(`Unknown action: ${action}`); + throw kibanaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/kibana/src/tools/manage-saved-object.ts b/integrations/kibana/src/tools/manage-saved-object.ts index aaa9c79c22..c4da4801f7 100644 --- a/integrations/kibana/src/tools/manage-saved-object.ts +++ b/integrations/kibana/src/tools/manage-saved-object.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -81,7 +82,7 @@ Provide the action to perform along with the object type and ID.`, let mappedRefs = references?.map(r => ({ id: r.referenceId, name: r.name, type: r.type })); if (action === 'get') { - if (!objectId) throw new Error('objectId is required for get action'); + if (!objectId) throw kibanaServiceError('objectId is required for get action'); let obj = await client.getSavedObject(objectType, objectId); return { output: { @@ -100,7 +101,7 @@ Provide the action to perform along with the object type and ID.`, } if (action === 'create') { - if (!attributes) throw new Error('attributes are required for create action'); + if (!attributes) throw kibanaServiceError('attributes are required for create action'); let obj = await client.createSavedObject(objectType, attributes, { objectId, references: mappedRefs, @@ -123,8 +124,8 @@ Provide the action to perform along with the object type and ID.`, } if (action === 'update') { - if (!objectId) throw new Error('objectId is required for update action'); - if (!attributes) throw new Error('attributes are required for update action'); + if (!objectId) throw kibanaServiceError('objectId is required for update action'); + if (!attributes) throw kibanaServiceError('attributes are required for update action'); let obj = await client.updateSavedObject(objectType, objectId, attributes, { references: mappedRefs }); @@ -145,7 +146,7 @@ Provide the action to perform along with the object type and ID.`, } if (action === 'delete') { - if (!objectId) throw new Error('objectId is required for delete action'); + if (!objectId) throw kibanaServiceError('objectId is required for delete action'); await client.deleteSavedObject(objectType, objectId, force); return { output: { @@ -157,6 +158,6 @@ Provide the action to perform along with the object type and ID.`, }; } - throw new Error(`Unknown action: ${action}`); + throw kibanaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/kibana/src/tools/manage-slo.ts b/integrations/kibana/src/tools/manage-slo.ts index f038c9a26b..3021e92dea 100644 --- a/integrations/kibana/src/tools/manage-slo.ts +++ b/integrations/kibana/src/tools/manage-slo.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -174,7 +175,7 @@ Supports KQL, metric custom, and histogram indicator types with occurrences or t } = ctx.input; if (action === 'get') { - if (!sloId) throw new Error('sloId is required for get action'); + if (!sloId) throw kibanaServiceError('sloId is required for get action'); let s = await client.getSLO(sloId); return { output: { @@ -195,11 +196,12 @@ Supports KQL, metric custom, and histogram indicator types with occurrences or t } if (action === 'create') { - if (!name) throw new Error('name is required for create action'); - if (!indicator) throw new Error('indicator is required for create action'); - if (!timeWindow) throw new Error('timeWindow is required for create action'); - if (!budgetingMethod) throw new Error('budgetingMethod is required for create action'); - if (!objective) throw new Error('objective is required for create action'); + if (!name) throw kibanaServiceError('name is required for create action'); + if (!indicator) throw kibanaServiceError('indicator is required for create action'); + if (!timeWindow) throw kibanaServiceError('timeWindow is required for create action'); + if (!budgetingMethod) + throw kibanaServiceError('budgetingMethod is required for create action'); + if (!objective) throw kibanaServiceError('objective is required for create action'); let s = await client.createSLO({ name, @@ -230,7 +232,7 @@ Supports KQL, metric custom, and histogram indicator types with occurrences or t } if (action === 'update') { - if (!sloId) throw new Error('sloId is required for update action'); + if (!sloId) throw kibanaServiceError('sloId is required for update action'); let updateParams: Record = {}; if (name !== undefined) updateParams.name = name; if (description !== undefined) updateParams.description = description; @@ -261,7 +263,7 @@ Supports KQL, metric custom, and histogram indicator types with occurrences or t } if (action === 'delete') { - if (!sloId) throw new Error('sloId is required for delete action'); + if (!sloId) throw kibanaServiceError('sloId is required for delete action'); await client.deleteSLO(sloId); return { output: { @@ -273,6 +275,6 @@ Supports KQL, metric custom, and histogram indicator types with occurrences or t }; } - throw new Error(`Unknown action: ${action}`); + throw kibanaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/kibana/src/tools/manage-space.ts b/integrations/kibana/src/tools/manage-space.ts index 7f9eaab66a..4990851b0b 100644 --- a/integrations/kibana/src/tools/manage-space.ts +++ b/integrations/kibana/src/tools/manage-space.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { kibanaServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -100,7 +101,7 @@ Rules and connectors are isolated to the space in which they were created.`, } if (action === 'create') { - if (!name) throw new Error('name is required for create action'); + if (!name) throw kibanaServiceError('name is required for create action'); let space = await client.createSpace({ id: spaceId, name, @@ -155,6 +156,6 @@ Rules and connectors are isolated to the space in which they were created.`, }; } - throw new Error(`Unknown action: ${action}`); + throw kibanaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/kit/README.md b/integrations/kit/README.md index 1973f75281..38742a6f29 100644 --- a/integrations/kit/README.md +++ b/integrations/kit/README.md @@ -1,6 +1,6 @@ # Kit -Manage email subscribers, send broadcast emails, and automate marketing workflows for creators. Create, update, tag, and segment subscribers. Build and schedule broadcast campaigns with HTML support and audience targeting. Manage email sequences (drip campaigns), sign-up forms, and landing pages. Create and list custom fields for subscriber profiles. Import purchase data and track e-commerce activity. Retrieve email templates and account information. Set up webhooks to receive notifications for subscriber events such as activations, unsubscribes, bounces, form subscriptions, tag changes, link clicks, sequence completions, and product purchases. +Manage email subscribers, send broadcast emails, and automate marketing workflows for creators. Create, update, tag, and segment subscribers. Build and schedule broadcast campaigns with HTML support, audience targeting, performance stats, and link-click reporting. Manage email sequences and their individual email steps, sign-up forms, landing pages, reusable snippets, and published posts. Create and list custom fields for subscriber profiles. Import purchase data and track e-commerce activity. Retrieve email templates, account information, and account-level engagement or growth stats. Set up webhooks to receive notifications for subscriber events such as activations, unsubscribes, bounces, form subscriptions, tag changes, link clicks, sequence completions, and product purchases. ## License diff --git a/integrations/kit/docs/SPEC.md b/integrations/kit/docs/SPEC.md index 9257f2798f..ab47fad17e 100644 --- a/integrations/kit/docs/SPEC.md +++ b/integrations/kit/docs/SPEC.md @@ -1,5 +1,3 @@ -Now let me fetch the OAuth details page for more specifics on the OAuth flow.Now I have comprehensive information. Let me compile the specification. - # Slates Specification for Kit ## Overview @@ -62,11 +60,11 @@ List and manage sign-up forms and landing pages. Add subscribers to specific for ### Broadcasts -Kit provides a fully functional Broadcast API with full HTML support and segmentation targeting. Create, update, list, and delete broadcast emails. Schedule broadcasts for future delivery and target specific subscriber segments. +Kit provides a fully functional Broadcast API with full HTML support and segmentation targeting. Create, update, list, get, and delete broadcast emails. Schedule broadcasts for future delivery, target specific subscriber segments, retrieve broadcast stats, list sent broadcast stats, and inspect link click performance for sent broadcasts. ### Sequences (Email Courses) -Manage email sequences (drip campaigns). List sequences, view subscribers for a sequence, and add subscribers to sequences. +Manage email sequences (drip campaigns). Create, update, delete, and list sequences, view subscribers for a sequence, add subscribers to sequences, and manage the individual email steps inside a sequence. ### Segments @@ -82,7 +80,15 @@ List available email templates for use with broadcasts. ### Account Information -Retrieve account details and creator profile information, including plan type, timezone, and profile settings. +Retrieve account details and creator profile information. Retrieve account-level email engagement stats and subscriber growth stats. + +### Posts + +List and get Kit posts. Include post content only when the body is needed. + +### Snippets + +Create, update, archive, restore, list, and get reusable snippets for Liquid-powered broadcast and sequence email content. ## Events diff --git a/integrations/kit/package.json b/integrations/kit/package.json index 0f2989b3b1..0f04f06dc9 100644 --- a/integrations/kit/package.json +++ b/integrations/kit/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/kit/slate.json b/integrations/kit/slate.json index 5a3910ee0a..6939ce82e7 100644 --- a/integrations/kit/slate.json +++ b/integrations/kit/slate.json @@ -1,14 +1,18 @@ { "name": "@metorial/kit", - "description": "Manage email subscribers, send broadcast emails, and automate marketing workflows for creators. Create, update, tag, and segment subscribers. Build and schedule broadcast campaigns with HTML support and audience targeting. Manage email sequences (drip campaigns), sign-up forms, and landing pages. Create and list custom fields for subscriber profiles. Import purchase data and track e-commerce activity. Retrieve email templates and account information. Set up webhooks to receive notifications for subscriber events such as activations, unsubscribes, bounces, form subscriptions, tag changes, link clicks, sequence completions, and product purchases.", + "description": "Manage email subscribers, send broadcast emails, and automate marketing workflows for creators. Create, update, tag, and segment subscribers. Build and schedule broadcast campaigns with HTML support, audience targeting, performance stats, and link-click reporting. Manage sequences, sequence email steps, sign-up forms, landing pages, posts, snippets, and custom fields. Import purchase data and track e-commerce activity. Retrieve email templates, account information, engagement stats, and growth stats.", "categories": ["crm-and-sales-tools", "email-and-messaging"], "skills": [ "manage email subscribers", "send broadcast emails", "tag and segment subscribers", "manage email sequences", + "manage sequence emails", "create custom fields", + "manage snippets", + "list posts", "schedule email campaigns", + "inspect broadcast stats", "manage sign-up forms", "track purchase data", "configure webhooks", diff --git a/integrations/kit/src/auth.ts b/integrations/kit/src/auth.ts index 3429eaea7d..8cca052369 100644 --- a/integrations/kit/src/auth.ts +++ b/integrations/kit/src/auth.ts @@ -1,10 +1,23 @@ -import { createAxios, SlateAuth } from 'slates'; +import { + createApiServiceError, + createAxios, + normalizeOAuthTokenResponse, + SlateAuth +} from 'slates'; import { z } from 'zod'; +import { kitApiError } from './lib/errors'; let httpClient = createAxios({ baseURL: 'https://api.kit.com/v4' }); +httpClient.interceptors.response.use( + response => response, + error => { + throw kitApiError(error, 'auth request'); + } +); + export let auth = SlateAuth.create() .output( z.object({ @@ -52,37 +65,42 @@ export let auth = SlateAuth.create() redirect_uri: ctx.redirectUri }); - let data = response.data; - let expiresAt = data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined; + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Kit', + operation: 'token exchange' + }); return { output: { - token: data.access_token, - refreshToken: data.refresh_token, - expiresAt + token: token.token, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt } }; }, handleTokenRefresh: async (ctx: any) => { + if (!ctx.output.refreshToken) { + throw createApiServiceError('Kit refresh token is missing; reconnect the account.'); + } + let response = await httpClient.post('/oauth/token', { grant_type: 'refresh_token', refresh_token: ctx.output.refreshToken, client_id: ctx.clientId }); - let data = response.data; - let expiresAt = data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined; + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Kit', + operation: 'token refresh', + previousRefreshToken: ctx.output.refreshToken + }); return { output: { - token: data.access_token, - refreshToken: data.refresh_token ?? ctx.output.refreshToken, - expiresAt + token: token.token, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt } }; }, diff --git a/integrations/kit/src/index.ts b/integrations/kit/src/index.ts index ec3dbb956c..4400809c92 100644 --- a/integrations/kit/src/index.ts +++ b/integrations/kit/src/index.ts @@ -4,16 +4,26 @@ import { addSubscriberToForm, createSubscriber, getAccount, + getAccountStats, + getBroadcastLinkClicks, getBroadcastStats, getSubscriber, + getSubscriberStats, + listBroadcastStats, listEmailTemplates, + listFormSubscribers, listForms, listSegments, listSubscribers, + listSubscriberTags, + listTagSubscribers, manageBroadcasts, manageCustomFields, + managePosts, managePurchases, + manageSequenceEmails, manageSequences, + manageSnippets, manageTags, tagSubscriber, unsubscribe, @@ -25,22 +35,32 @@ export let provider = Slate.create({ spec, tools: [ getAccount, + getAccountStats, listSubscribers, getSubscriber, createSubscriber, updateSubscriber, unsubscribe, + getSubscriberStats, + listSubscriberTags, manageTags, tagSubscriber, + listTagSubscribers, manageCustomFields, listForms, + listFormSubscribers, addSubscriberToForm, manageBroadcasts, getBroadcastStats, + listBroadcastStats, + getBroadcastLinkClicks, manageSequences, + manageSequenceEmails, listSegments, managePurchases, - listEmailTemplates + listEmailTemplates, + managePosts, + manageSnippets ], triggers: [subscriberEvent, tagEvent, purchaseEvent, formSubscribeEvent] }); diff --git a/integrations/kit/src/lib/client.ts b/integrations/kit/src/lib/client.ts index 6b19df7f46..469ac963c8 100644 --- a/integrations/kit/src/lib/client.ts +++ b/integrations/kit/src/lib/client.ts @@ -1,24 +1,66 @@ -import { createAxios } from 'slates'; +import { createAxios, pickDefined } from 'slates'; +import { kitApiError, kitServiceError } from './errors'; import type { KitAccount, + KitAccountEmailStats, + KitAccountGrowthStats, KitBroadcast, - KitBroadcastStats, + KitBroadcastClick, + KitBroadcastStatsSummary, KitCustomField, KitEmailTemplate, KitForm, KitPaginatedResponse, + KitPagination, + KitPost, KitPurchase, KitSegment, KitSequence, + KitSequenceEmail, + KitSnippet, KitSubscriber, + KitSubscriberStats, KitTag, KitWebhook } from './types'; +type PaginationParams = { + after?: string; + before?: string; + perPage?: number; + includeTotalCount?: boolean; +}; + +let defaultPagination: KitPagination = { + has_previous_page: false, + has_next_page: false, + start_cursor: null, + end_cursor: null, + per_page: 0 +}; + +let paginated = (body: Record, key: string): KitPaginatedResponse => ({ + data: Array.isArray(body[key]) ? body[key] : Array.isArray(body.data) ? body.data : [], + pagination: body.pagination ?? defaultPagination +}); + +let applyPagination = (query: Record, params?: PaginationParams) => { + if (params?.after) query.after = params.after; + if (params?.before) query.before = params.before; + if (params?.perPage) query.per_page = params.perPage; + if (params?.includeTotalCount !== undefined) { + query.include_total_count = params.includeTotalCount; + } +}; + export class Client { private http: ReturnType; constructor(private config: { token: string }) { + if (!config.token) { + throw kitServiceError('Kit authentication token is required.'); + } + this.http = createAxios({ baseURL: 'https://api.kit.com/v4', headers: { @@ -28,47 +70,60 @@ export class Client { }); this.http.interceptors.request.use(reqConfig => { - // OAuth tokens are longer; API keys are shorter. Both work via different headers. - // We try Bearer first; if the token looks like an API key, also set that header. - // Kit accepts both simultaneously without issue. let headers = (reqConfig.headers ?? {}) as Record; headers.Authorization = `Bearer ${this.config.token}`; headers['X-Kit-Api-Key'] = this.config.token; reqConfig.headers = headers as any; return reqConfig; }); - } - // ────────────────────────────────────────────── - // Account - // ────────────────────────────────────────────── + this.http.interceptors.response.use( + response => response, + error => { + throw kitApiError(error); + } + ); + } async getAccount(): Promise { let response = await this.http.get('/account'); return response.data; } - // ────────────────────────────────────────────── - // Subscribers - // ────────────────────────────────────────────── + async getEmailStats(): Promise<{ stats: KitAccountEmailStats }> { + let response = await this.http.get('/account/email_stats'); + return response.data; + } - async listSubscribers(params?: { - after?: string; - before?: string; - perPage?: number; - status?: string; - sortField?: string; - sortOrder?: string; - createdAfter?: string; - createdBefore?: string; - updatedAfter?: string; - updatedBefore?: string; - emailAddress?: string; - }): Promise> { + async getGrowthStats(params?: { + starting?: string; + ending?: string; + }): Promise<{ stats: KitAccountGrowthStats }> { + let response = await this.http.get('/account/growth_stats', { + params: pickDefined({ + starting: params?.starting, + ending: params?.ending + }) + }); + return response.data; + } + + async listSubscribers( + params?: PaginationParams & { + status?: string; + sortField?: string; + sortOrder?: string; + createdAfter?: string; + createdBefore?: string; + updatedAfter?: string; + updatedBefore?: string; + emailAddress?: string; + include?: string[]; + slim?: boolean; + } + ): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); if (params?.status) query.status = params.status; if (params?.sortField) query.sort_field = params.sortField; if (params?.sortOrder) query.sort_order = params.sortOrder; @@ -77,9 +132,11 @@ export class Client { if (params?.updatedAfter) query.updated_after = params.updatedAfter; if (params?.updatedBefore) query.updated_before = params.updatedBefore; if (params?.emailAddress) query.email_address = params.emailAddress; + if (params?.include?.length) query.include = params.include.join(','); + if (params?.slim !== undefined) query.slim = params.slim; let response = await this.http.get('/subscribers', { params: query }); - return response.data; + return paginated(response.data, 'subscribers'); } async getSubscriber(subscriberId: number): Promise<{ subscriber: KitSubscriber }> { @@ -93,14 +150,15 @@ export class Client { state?: string; fields?: Record; }): Promise<{ subscriber: KitSubscriber }> { - let body: Record = { - email_address: data.emailAddress - }; - if (data.firstName) body.first_name = data.firstName; - if (data.state) body.state = data.state; - if (data.fields) body.fields = data.fields; - - let response = await this.http.post('/subscribers', body); + let response = await this.http.post( + '/subscribers', + pickDefined({ + email_address: data.emailAddress, + first_name: data.firstName, + state: data.state, + fields: data.fields + }) + ); return response.data; } @@ -112,12 +170,14 @@ export class Client { fields?: Record; } ): Promise<{ subscriber: KitSubscriber }> { - let body: Record = {}; - if (data.emailAddress) body.email_address = data.emailAddress; - if (data.firstName) body.first_name = data.firstName; - if (data.fields) body.fields = data.fields; - - let response = await this.http.put(`/subscribers/${subscriberId}`, body); + let response = await this.http.put( + `/subscribers/${subscriberId}`, + pickDefined({ + email_address: data.emailAddress, + first_name: data.firstName, + fields: data.fields + }) + ); return response.data; } @@ -126,39 +186,41 @@ export class Client { return response.data; } - async listTagsForSubscriber( + async getSubscriberStats( subscriberId: number, params?: { - after?: string; - before?: string; - perPage?: number; + emailSentAfter?: string; + emailSentBefore?: string; } + ): Promise<{ subscriber: { id: number; stats: KitSubscriberStats } }> { + let response = await this.http.get(`/subscribers/${subscriberId}/stats`, { + params: pickDefined({ + email_sent_after: params?.emailSentAfter, + email_sent_before: params?.emailSentBefore + }) + }); + return response.data; + } + + async listTagsForSubscriber( + subscriberId: number, + params?: PaginationParams ): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); - let response = await this.http.get(`/subscribers/${subscriberId}/tags`, { params: query }); - return response.data; + let response = await this.http.get(`/subscribers/${subscriberId}/tags`, { + params: query + }); + return paginated(response.data, 'tags'); } - // ────────────────────────────────────────────── - // Tags - // ────────────────────────────────────────────── - - async listTags(params?: { - after?: string; - before?: string; - perPage?: number; - }): Promise> { + async listTags(params?: PaginationParams): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); let response = await this.http.get('/tags', { params: query }); - return response.data; + return paginated(response.data, 'tags'); } async createTag(name: string): Promise<{ tag: KitTag }> { @@ -188,35 +250,28 @@ export class Client { } async removeTagFromSubscriberByEmail(tagId: number, emailAddress: string): Promise { - await this.http.post(`/tags/${tagId}/subscribers/remove`, { email_address: emailAddress }); + await this.http.delete(`/tags/${tagId}/subscribers`, { + data: { email_address: emailAddress } + }); } async listSubscribersForTag( tagId: number, - params?: { - after?: string; - before?: string; - perPage?: number; + params?: PaginationParams & { status?: string; } ): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); if (params?.status) query.status = params.status; let response = await this.http.get(`/tags/${tagId}/subscribers`, { params: query }); - return response.data; + return paginated(response.data, 'subscribers'); } - // ────────────────────────────────────────────── - // Custom Fields - // ────────────────────────────────────────────── - async listCustomFields(): Promise> { let response = await this.http.get('/custom_fields'); - return response.data; + return paginated(response.data, 'custom_fields'); } async createCustomField(label: string): Promise<{ custom_field: KitCustomField }> { @@ -236,26 +291,19 @@ export class Client { await this.http.delete(`/custom_fields/${customFieldId}`); } - // ────────────────────────────────────────────── - // Forms - // ────────────────────────────────────────────── - - async listForms(params?: { - type?: string; - status?: string; - after?: string; - before?: string; - perPage?: number; - }): Promise> { + async listForms( + params?: PaginationParams & { + type?: string; + status?: string; + } + ): Promise> { let query: Record = {}; + applyPagination(query, params); if (params?.type) query.type = params.type; if (params?.status) query.status = params.status; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; let response = await this.http.get('/forms', { params: query }); - return response.data; + return paginated(response.data, 'forms'); } async addSubscriberToForm( @@ -278,39 +326,26 @@ export class Client { async listSubscribersForForm( formId: number, - params?: { - after?: string; - before?: string; - perPage?: number; + params?: PaginationParams & { status?: string; } ): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); if (params?.status) query.status = params.status; let response = await this.http.get(`/forms/${formId}/subscribers`, { params: query }); - return response.data; + return paginated(response.data, 'subscribers'); } - // ────────────────────────────────────────────── - // Broadcasts - // ────────────────────────────────────────────── - - async listBroadcasts(params?: { - after?: string; - before?: string; - perPage?: number; - }): Promise> { + async listBroadcasts( + params?: PaginationParams + ): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); let response = await this.http.get('/broadcasts', { params: query }); - return response.data; + return paginated(response.data, 'broadcasts'); } async getBroadcast(broadcastId: number): Promise<{ broadcast: KitBroadcast }> { @@ -327,29 +362,30 @@ export class Client { emailLayoutTemplate?: string; public?: boolean; publishedAt?: string; - sendAt?: string; + sendAt?: string | null; thumbnailAlt?: string; thumbnailUrl?: string; previewText?: string; - subscriberFilter?: Record[]; + subscriberFilter?: Record[]; }): Promise<{ broadcast: KitBroadcast }> { - let body: Record = {}; - if (data.subject !== undefined) body.subject = data.subject; - if (data.content !== undefined) body.content = data.content; - if (data.description !== undefined) body.description = data.description; - if (data.emailAddress !== undefined) body.email_address = data.emailAddress; - if (data.emailTemplateId !== undefined) body.email_template_id = data.emailTemplateId; - if (data.emailLayoutTemplate !== undefined) - body.email_layout_template = data.emailLayoutTemplate; - if (data.public !== undefined) body.public = data.public; - if (data.publishedAt !== undefined) body.published_at = data.publishedAt; - if (data.sendAt !== undefined) body.send_at = data.sendAt; - if (data.thumbnailAlt !== undefined) body.thumbnail_alt = data.thumbnailAlt; - if (data.thumbnailUrl !== undefined) body.thumbnail_url = data.thumbnailUrl; - if (data.previewText !== undefined) body.preview_text = data.previewText; - if (data.subscriberFilter !== undefined) body.subscriber_filter = data.subscriberFilter; - - let response = await this.http.post('/broadcasts', body); + let response = await this.http.post( + '/broadcasts', + pickDefined({ + subject: data.subject, + content: data.content, + description: data.description, + email_address: data.emailAddress, + email_template_id: data.emailTemplateId, + email_layout_template: data.emailLayoutTemplate, + public: data.public, + published_at: data.publishedAt, + send_at: data.sendAt, + thumbnail_alt: data.thumbnailAlt, + thumbnail_url: data.thumbnailUrl, + preview_text: data.previewText, + subscriber_filter: data.subscriberFilter + }) + ); return response.data; } @@ -364,30 +400,31 @@ export class Client { emailLayoutTemplate?: string; public?: boolean; publishedAt?: string; - sendAt?: string; + sendAt?: string | null; thumbnailAlt?: string; thumbnailUrl?: string; previewText?: string; - subscriberFilter?: Record[]; + subscriberFilter?: Record[]; } ): Promise<{ broadcast: KitBroadcast }> { - let body: Record = {}; - if (data.subject !== undefined) body.subject = data.subject; - if (data.content !== undefined) body.content = data.content; - if (data.description !== undefined) body.description = data.description; - if (data.emailAddress !== undefined) body.email_address = data.emailAddress; - if (data.emailTemplateId !== undefined) body.email_template_id = data.emailTemplateId; - if (data.emailLayoutTemplate !== undefined) - body.email_layout_template = data.emailLayoutTemplate; - if (data.public !== undefined) body.public = data.public; - if (data.publishedAt !== undefined) body.published_at = data.publishedAt; - if (data.sendAt !== undefined) body.send_at = data.sendAt; - if (data.thumbnailAlt !== undefined) body.thumbnail_alt = data.thumbnailAlt; - if (data.thumbnailUrl !== undefined) body.thumbnail_url = data.thumbnailUrl; - if (data.previewText !== undefined) body.preview_text = data.previewText; - if (data.subscriberFilter !== undefined) body.subscriber_filter = data.subscriberFilter; - - let response = await this.http.put(`/broadcasts/${broadcastId}`, body); + let response = await this.http.put( + `/broadcasts/${broadcastId}`, + pickDefined({ + subject: data.subject, + content: data.content, + description: data.description, + email_address: data.emailAddress, + email_template_id: data.emailTemplateId, + email_layout_template: data.emailLayoutTemplate, + public: data.public, + published_at: data.publishedAt, + send_at: data.sendAt, + thumbnail_alt: data.thumbnailAlt, + thumbnail_url: data.thumbnailUrl, + preview_text: data.previewText, + subscriber_filter: data.subscriberFilter + }) + ); return response.data; } @@ -395,29 +432,125 @@ export class Client { await this.http.delete(`/broadcasts/${broadcastId}`); } - async getBroadcastStats(broadcastId: number): Promise<{ broadcast: KitBroadcastStats }> { + async getBroadcastStats( + broadcastId: number + ): Promise<{ broadcast: { id: number; stats: KitBroadcastStatsSummary['stats'] } }> { let response = await this.http.get(`/broadcasts/${broadcastId}/stats`); return response.data; } - // ────────────────────────────────────────────── - // Sequences - // ────────────────────────────────────────────── + async listBroadcastStats( + params?: PaginationParams & { + sentAfter?: string; + sentBefore?: string; + } + ): Promise> { + let query: Record = {}; + applyPagination(query, params); + if (params?.sentAfter) query.sent_after = params.sentAfter; + if (params?.sentBefore) query.sent_before = params.sentBefore; - async listSequences(params?: { - after?: string; - before?: string; - perPage?: number; - }): Promise> { + let response = await this.http.get('/broadcasts/stats', { params: query }); + return paginated(response.data, 'broadcasts'); + } + + async getBroadcastLinkClicks( + broadcastId: number, + params?: PaginationParams + ): Promise<{ broadcastId: number; clicks: KitBroadcastClick[]; pagination: KitPagination }> { + let query: Record = {}; + applyPagination(query, params); + + let response = await this.http.get(`/broadcasts/${broadcastId}/clicks`, { + params: query + }); + return { + broadcastId: response.data.broadcast?.id ?? broadcastId, + clicks: response.data.broadcast?.clicks ?? [], + pagination: response.data.pagination ?? defaultPagination + }; + } + + async listSequences(params?: PaginationParams): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); let response = await this.http.get('/sequences', { params: query }); + return paginated(response.data, 'sequences'); + } + + async getSequence(sequenceId: number): Promise<{ sequence: KitSequence }> { + let response = await this.http.get(`/sequences/${sequenceId}`); + return response.data; + } + + async createSequence(data: { + name: string; + emailAddress?: string; + emailTemplateId?: number; + sendDays?: string[]; + sendHour?: number; + timeZone?: string; + active?: boolean; + repeat?: boolean; + hold?: boolean; + excludeSubscriberSources?: Record[]; + }): Promise<{ sequence: KitSequence }> { + let response = await this.http.post( + '/sequences', + pickDefined({ + name: data.name, + email_address: data.emailAddress, + email_template_id: data.emailTemplateId, + send_days: data.sendDays, + send_hour: data.sendHour, + time_zone: data.timeZone, + active: data.active, + repeat: data.repeat, + hold: data.hold, + exclude_subscriber_sources: data.excludeSubscriberSources + }) + ); + return response.data; + } + + async updateSequence( + sequenceId: number, + data: { + name?: string; + emailAddress?: string; + emailTemplateId?: number | null; + sendDays?: string[]; + sendHour?: number; + timeZone?: string; + active?: boolean; + repeat?: boolean; + hold?: boolean; + excludeSubscriberSources?: Record[]; + } + ): Promise<{ sequence: KitSequence }> { + let response = await this.http.put( + `/sequences/${sequenceId}`, + pickDefined({ + name: data.name, + email_address: data.emailAddress, + email_template_id: data.emailTemplateId, + send_days: data.sendDays, + send_hour: data.sendHour, + time_zone: data.timeZone, + active: data.active, + repeat: data.repeat, + hold: data.hold, + exclude_subscriber_sources: data.excludeSubscriberSources + }) + ); return response.data; } + async deleteSequence(sequenceId: number): Promise { + await this.http.delete(`/sequences/${sequenceId}`); + } + async addSubscriberToSequence( sequenceId: number, subscriberId: number @@ -440,59 +573,125 @@ export class Client { async listSubscribersForSequence( sequenceId: number, - params?: { - after?: string; - before?: string; - perPage?: number; + params?: PaginationParams & { status?: string; } ): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); if (params?.status) query.status = params.status; let response = await this.http.get(`/sequences/${sequenceId}/subscribers`, { params: query }); + return paginated(response.data, 'subscribers'); + } + + async listSequenceEmails( + sequenceId: number, + params?: PaginationParams & { + includeContent?: boolean; + } + ): Promise> { + let query: Record = {}; + applyPagination(query, params); + if (params?.includeContent !== undefined) query.include_content = params.includeContent; + + let response = await this.http.get(`/sequences/${sequenceId}/emails`, { + params: query + }); + return paginated(response.data, 'emails'); + } + + async getSequenceEmail( + sequenceId: number, + emailId: number + ): Promise<{ email: KitSequenceEmail }> { + let response = await this.http.get(`/sequences/${sequenceId}/emails/${emailId}`); return response.data; } - // ────────────────────────────────────────────── - // Segments - // ────────────────────────────────────────────── + async createSequenceEmail( + sequenceId: number, + data: { + subject: string; + delayValue: number; + delayUnit: string; + previewText?: string | null; + content?: string | null; + emailTemplateId?: number | null; + published?: boolean; + sendDays?: string[] | null; + position?: number | null; + } + ): Promise<{ email: KitSequenceEmail }> { + let response = await this.http.post( + `/sequences/${sequenceId}/emails`, + pickDefined({ + subject: data.subject, + delay_value: data.delayValue, + delay_unit: data.delayUnit, + preview_text: data.previewText, + content: data.content, + email_template_id: data.emailTemplateId, + published: data.published, + send_days: data.sendDays, + position: data.position + }) + ); + return response.data; + } + + async updateSequenceEmail( + sequenceId: number, + emailId: number, + data: { + subject?: string; + delayValue?: number; + delayUnit?: string; + previewText?: string | null; + content?: string | null; + emailTemplateId?: number | null; + published?: boolean; + sendDays?: string[] | null; + position?: number | null; + } + ): Promise<{ email: KitSequenceEmail }> { + let response = await this.http.put( + `/sequences/${sequenceId}/emails/${emailId}`, + pickDefined({ + subject: data.subject, + delay_value: data.delayValue, + delay_unit: data.delayUnit, + preview_text: data.previewText, + content: data.content, + email_template_id: data.emailTemplateId, + published: data.published, + send_days: data.sendDays, + position: data.position + }) + ); + return response.data; + } - async listSegments(params?: { - after?: string; - before?: string; - perPage?: number; - }): Promise> { + async deleteSequenceEmail(sequenceId: number, emailId: number): Promise { + await this.http.delete(`/sequences/${sequenceId}/emails/${emailId}`); + } + + async listSegments(params?: PaginationParams): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); let response = await this.http.get('/segments', { params: query }); - return response.data; + return paginated(response.data, 'segments'); } - // ────────────────────────────────────────────── - // Purchases - // ────────────────────────────────────────────── - - async listPurchases(params?: { - after?: string; - before?: string; - perPage?: number; - }): Promise> { + async listPurchases(params?: PaginationParams): Promise> { let query: Record = {}; - if (params?.after) query.after = params.after; - if (params?.before) query.before = params.before; - if (params?.perPage) query.per_page = params.perPage; + applyPagination(query, params); let response = await this.http.get('/purchases', { params: query }); - return response.data; + return paginated(response.data, 'purchases'); } async getPurchase(purchaseId: number): Promise<{ purchase: KitPurchase }> { @@ -519,42 +718,125 @@ export class Client { quantity: number; }>; }): Promise<{ purchase: KitPurchase }> { - let body: Record = { - email_address: data.emailAddress, - transaction_id: data.transactionId, - products: data.products.map(p => ({ - name: p.name, - sku: p.sku, - pid: p.pid, - lid: p.lid, - unit_price: p.unitPrice, - quantity: p.quantity - })) - }; - if (data.currency !== undefined) body.currency = data.currency; - if (data.transactionTime !== undefined) body.transaction_time = data.transactionTime; - if (data.subtotal !== undefined) body.subtotal = data.subtotal; - if (data.tax !== undefined) body.tax = data.tax; - if (data.discount !== undefined) body.discount = data.discount; - if (data.total !== undefined) body.total = data.total; - if (data.status !== undefined) body.status = data.status; + let response = await this.http.post( + '/purchases', + pickDefined({ + email_address: data.emailAddress, + transaction_id: data.transactionId, + currency: data.currency, + transaction_time: data.transactionTime, + subtotal: data.subtotal, + tax: data.tax, + discount: data.discount, + total: data.total, + status: data.status, + products: data.products.map(p => + pickDefined({ + name: p.name, + sku: p.sku, + pid: p.pid, + lid: p.lid, + unit_price: p.unitPrice, + quantity: p.quantity + }) + ) + }) + ); + return response.data; + } + + async listEmailTemplates( + params?: PaginationParams + ): Promise> { + let query: Record = {}; + applyPagination(query, params); + + let response = await this.http.get('/email_templates', { params: query }); + return paginated(response.data, 'email_templates'); + } + + async listPosts( + params?: PaginationParams & { + includeContent?: boolean; + } + ): Promise> { + let query: Record = {}; + applyPagination(query, params); + if (params?.includeContent !== undefined) query.include_content = params.includeContent; + + let response = await this.http.get('/posts', { params: query }); + return paginated(response.data, 'posts'); + } - let response = await this.http.post('/purchases', body); + async getPost(postId: number): Promise<{ post: KitPost }> { + let response = await this.http.get(`/posts/${postId}`); return response.data; } - // ────────────────────────────────────────────── - // Email Templates - // ────────────────────────────────────────────── + async listSnippets( + params?: PaginationParams & { + snippetType?: string; + archived?: boolean; + includeContent?: boolean; + } + ): Promise> { + let query: Record = {}; + applyPagination(query, params); + if (params?.snippetType) query.snippet_type = params.snippetType; + if (params?.archived !== undefined) query.archived = params.archived; + if (params?.includeContent !== undefined) query.include_content = params.includeContent; - async listEmailTemplates(): Promise> { - let response = await this.http.get('/email_templates'); + let response = await this.http.get('/snippets', { params: query }); + return paginated(response.data, 'snippets'); + } + + async getSnippet(snippetId: number): Promise<{ snippet: KitSnippet }> { + let response = await this.http.get(`/snippets/${snippetId}`); + return response.data; + } + + async createSnippet(data: { + name: string; + snippetType: 'inline' | 'block'; + content?: string; + documentHtml?: string; + }): Promise<{ snippet: KitSnippet }> { + let response = await this.http.post( + '/snippets', + pickDefined({ + name: data.name, + snippet_type: data.snippetType, + content: data.content, + document_attributes: + data.documentHtml !== undefined ? { value_html: data.documentHtml } : undefined + }) + ); return response.data; } - // ────────────────────────────────────────────── - // Webhooks - // ────────────────────────────────────────────── + async updateSnippet( + snippetId: number, + data: { + name?: string; + snippetType?: 'inline' | 'block'; + archived?: boolean; + content?: string; + documentHtml?: string; + } + ): Promise<{ snippet: KitSnippet }> { + let response = await this.http.put( + `/snippets/${snippetId}`, + pickDefined({ + name: data.name, + snippet_type: data.snippetType, + archived: data.archived, + content: data.content, + document_attributes: + data.documentHtml !== undefined ? { value_html: data.documentHtml } : undefined + }) + ); + return response.data; + } async createWebhook( targetUrl: string, @@ -567,24 +849,23 @@ export class Client { initiatorValue?: string; } ): Promise<{ webhook: KitWebhook }> { - let eventPayload: Record = { name: event.name }; - if (event.formId !== undefined) eventPayload.form_id = event.formId; - if (event.sequenceId !== undefined) eventPayload.sequence_id = event.sequenceId; - if (event.tagId !== undefined) eventPayload.tag_id = event.tagId; - if (event.productId !== undefined) eventPayload.product_id = event.productId; - if (event.initiatorValue !== undefined) - eventPayload.initiator_value = event.initiatorValue; - let response = await this.http.post('/webhooks', { target_url: targetUrl, - event: eventPayload + event: pickDefined({ + name: event.name, + form_id: event.formId, + sequence_id: event.sequenceId, + tag_id: event.tagId, + product_id: event.productId, + initiator_value: event.initiatorValue + }) }); return response.data; } async listWebhooks(): Promise> { let response = await this.http.get('/webhooks'); - return response.data; + return paginated(response.data, 'webhooks'); } async deleteWebhook(webhookId: number): Promise { diff --git a/integrations/kit/src/lib/errors.ts b/integrations/kit/src/lib/errors.ts new file mode 100644 index 0000000000..69657360c6 --- /dev/null +++ b/integrations/kit/src/lib/errors.ts @@ -0,0 +1,103 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[], prefix?: string) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details, prefix); + } + return; + } + + if (!isRecord(value)) { + if (prefix && (typeof value === 'string' || typeof value === 'number')) { + pushDetail(details, `${prefix}: ${value}`); + return; + } + + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.error); + pushDetail(details, value.error_description); + pushDetail(details, value.code); + + for (let [key, child] of Object.entries(value)) { + if ( + key === 'message' || + key === 'error' || + key === 'error_description' || + key === 'code' + ) { + continue; + } + + collectDetails(child, details, key); + } +}; + +let extractKitMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let kitServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let kitApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = kitServiceError( + `Kit API ${operation} failed: ${statusLabelFor(response)}${extractKitMessage(error)}` + ); + + serviceError.data.reason = 'kit_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/kit/src/lib/types.ts b/integrations/kit/src/lib/types.ts index c3faa9c3e1..08b6840939 100644 --- a/integrations/kit/src/lib/types.ts +++ b/integrations/kit/src/lib/types.ts @@ -1,23 +1,60 @@ +export interface KitPagination { + has_previous_page: boolean; + has_next_page: boolean; + start_cursor: string | null; + end_cursor: string | null; + per_page: number; + total_count?: number; +} + +export interface KitPaginatedResponse { + data: T[]; + pagination: KitPagination; +} + export interface KitSubscriber { id: number; first_name: string | null; email_address: string; state: string; created_at: string; + updated_at?: string; + canceled_at?: string | null; fields: Record; + added_at?: string; + tags?: Array<{ id: number; name: string }>; + attribution?: Record | null; + location?: Record | null; +} + +export interface KitSubscriberStats { + sent: number; + opened: number; + clicked: number; + bounced: number; + open_rate: number; + click_rate: number; + last_sent: string | null; + last_opened: string | null; + last_clicked: string | null; + sends_since_last_open: number | null; + sends_since_last_click: number | null; } export interface KitTag { id: number; name: string; created_at: string; - updated_at: string; + updated_at?: string; + subscriber_count?: number; + tagged_at?: string; } export interface KitCustomField { id: number; key: string; label: string; + name?: string; created_at: string; updated_at: string; } @@ -46,21 +83,36 @@ export interface KitBroadcast { email_layout_template: string | null; content: string | null; public: boolean; - subscriber_filter: Record[]; + subscriber_filter: Record[]; } export interface KitBroadcastStats { - id: number; - subject: string; recipients: number; open_rate: number; + emails_opened?: number; click_rate: number; + unsubscribe_rate?: number; unsubscribes: number; total_clicks: number; show_total_clicks: boolean; status: string; progress: number; - send_at: string | null; + open_tracking_disabled?: boolean; + click_tracking_disabled?: boolean; +} + +export interface KitBroadcastStatsSummary { + id: number; + subject?: string; + send_at?: string | null; + stats: KitBroadcastStats; +} + +export interface KitBroadcastClick { + url: string; + unique_clicks: number; + click_to_delivery_rate: number; + click_to_open_rate: number; } export interface KitSequence { @@ -68,7 +120,32 @@ export interface KitSequence { name: string; hold: boolean; repeat: boolean; + active?: boolean; created_at: string; + updated_at?: string; + email_address?: string | null; + email_template_id?: number | null; + email_count?: number; + subscriber_count?: number; + send_days?: string[]; + send_hour?: number; + time_zone?: string; + exclude_subscriber_sources?: Record[]; +} + +export interface KitSequenceEmail { + id: number; + sequence_id: number; + subject: string; + preview_text: string | null; + email_address?: string | null; + email_template_id: number | null; + published: boolean; + position: number | null; + delay_value: number; + delay_unit: string; + send_days: string[] | null; + content?: string | null; } export interface KitSegment { @@ -108,28 +185,73 @@ export interface KitAccount { }; } +export interface KitAccountEmailStats { + sent: number; + clicked: number; + opened: number; + email_stats_mode: string; + open_tracking_enabled: boolean; + click_tracking_enabled: boolean; + starting: string; + ending: string; +} + +export interface KitAccountGrowthStats { + cancellations: number; + net_new_subscribers: number; + new_subscribers: number; + subscribers: number; + starting: string; + ending: string; +} + export interface KitEmailTemplate { id: number; name: string; } +export interface KitPost { + id: number; + publication_id: number; + created_at: string; + title: string; + slug: string | null; + description: string | null; + meta_description: string | null; + status: string; + published_at: string | null; + sent_at: string | null; + thumbnail_alt: string | null; + thumbnail_url: string | null; + is_paid: boolean; + public_url: string | null; + content?: string | null; +} + +export interface KitSnippet { + id: number; + name: string; + snippet_type: string; + archived: boolean; + key: string; + created_at: string; + updated_at: string; + content?: string | null; + document?: { + id: number; + value: unknown; + value_html: string | null; + value_plain: string | null; + version: number; + } | null; +} + export interface KitWebhook { id: number; target_url: string; event: { name: string; - [key: string]: any; + [key: string]: unknown; }; created_at: string; } - -export interface KitPaginatedResponse { - data: T[]; - pagination: { - has_previous_page: boolean; - has_next_page: boolean; - start_cursor: string | null; - end_cursor: string | null; - per_page: number; - }; -} diff --git a/integrations/kit/src/tools.schema.test.ts b/integrations/kit/src/tools.schema.test.ts new file mode 100644 index 0000000000..9286230ca2 --- /dev/null +++ b/integrations/kit/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Kit tool input schemas', provider.actions); diff --git a/integrations/kit/src/tools/add-subscriber-to-form.ts b/integrations/kit/src/tools/add-subscriber-to-form.ts index ce3f00691d..0950dcbafb 100644 --- a/integrations/kit/src/tools/add-subscriber-to-form.ts +++ b/integrations/kit/src/tools/add-subscriber-to-form.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let addSubscriberToForm = SlateTool.create(spec, { @@ -32,7 +33,7 @@ export let addSubscriberToForm = SlateTool.create(spec, { let client = new Client({ token: ctx.auth.token }); if (!ctx.input.subscriberId && !ctx.input.emailAddress) { - throw new Error('Provide either subscriberId or emailAddress'); + throw kitServiceError('Provide either subscriberId or emailAddress'); } let data: any; diff --git a/integrations/kit/src/tools/get-account-stats.ts b/integrations/kit/src/tools/get-account-stats.ts new file mode 100644 index 0000000000..bf51fd6871 --- /dev/null +++ b/integrations/kit/src/tools/get-account-stats.ts @@ -0,0 +1,80 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getAccountStats = SlateTool.create(spec, { + name: 'Get Account Stats', + key: 'get_account_stats', + description: `Retrieve account-level email engagement stats or subscriber growth stats from Kit.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + statsType: z.enum(['email', 'growth']).describe('Which account stats endpoint to call'), + starting: z.string().optional().describe('Growth stats start date in yyyy-mm-dd format'), + ending: z.string().optional().describe('Growth stats end date in yyyy-mm-dd format') + }) + ) + .output( + z.object({ + statsType: z.string().describe('Stats type returned'), + sent: z.number().optional().describe('Emails sent'), + opened: z.number().optional().describe('Emails opened'), + clicked: z.number().optional().describe('Emails clicked'), + emailStatsMode: z.string().optional().describe('Email stats window'), + openTrackingEnabled: z.boolean().optional().describe('Whether open tracking is enabled'), + clickTrackingEnabled: z + .boolean() + .optional() + .describe('Whether click tracking is enabled'), + cancellations: z.number().optional().describe('Subscriber cancellations'), + netNewSubscribers: z.number().optional().describe('Net new subscribers'), + newSubscribers: z.number().optional().describe('New subscribers'), + subscribers: z.number().optional().describe('Subscriber total'), + starting: z.string().describe('Stats window start'), + ending: z.string().describe('Stats window end') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + if (ctx.input.statsType === 'email') { + let data = await client.getEmailStats(); + return { + output: { + statsType: 'email', + sent: data.stats.sent, + opened: data.stats.opened, + clicked: data.stats.clicked, + emailStatsMode: data.stats.email_stats_mode, + openTrackingEnabled: data.stats.open_tracking_enabled, + clickTrackingEnabled: data.stats.click_tracking_enabled, + starting: data.stats.starting, + ending: data.stats.ending + }, + message: `Email stats: ${data.stats.sent} sent, ${data.stats.opened} opened, ${data.stats.clicked} clicked.` + }; + } + + let data = await client.getGrowthStats({ + starting: ctx.input.starting, + ending: ctx.input.ending + }); + + return { + output: { + statsType: 'growth', + cancellations: data.stats.cancellations, + netNewSubscribers: data.stats.net_new_subscribers, + newSubscribers: data.stats.new_subscribers, + subscribers: data.stats.subscribers, + starting: data.stats.starting, + ending: data.stats.ending + }, + message: `Growth stats: ${data.stats.net_new_subscribers} net new subscribers.` + }; + }) + .build(); diff --git a/integrations/kit/src/tools/get-broadcast-link-clicks.ts b/integrations/kit/src/tools/get-broadcast-link-clicks.ts new file mode 100644 index 0000000000..2467f2f4a4 --- /dev/null +++ b/integrations/kit/src/tools/get-broadcast-link-clicks.ts @@ -0,0 +1,62 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getBroadcastLinkClicks = SlateTool.create(spec, { + name: 'Get Broadcast Link Clicks', + key: 'get_broadcast_link_clicks', + description: `List link click performance for a broadcast, including unique clicks, click-to-delivery rate, and click-to-open rate.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + broadcastId: z.number().describe('The broadcast ID to inspect'), + perPage: z.number().optional().describe('Number of links per page (max 1000)'), + afterCursor: z.string().optional().describe('Pagination cursor to fetch next page'), + beforeCursor: z.string().optional().describe('Pagination cursor to fetch previous page') + }) + ) + .output( + z.object({ + broadcastId: z.number().describe('Broadcast ID'), + clicks: z.array( + z.object({ + url: z.string().describe('Clicked URL'), + uniqueClicks: z.number().describe('Unique click count'), + clickToDeliveryRate: z.number().describe('Click-to-delivery rate'), + clickToOpenRate: z.number().describe('Click-to-open rate') + }) + ), + hasNextPage: z.boolean().describe('Whether more results are available'), + endCursor: z.string().nullable().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.getBroadcastLinkClicks(ctx.input.broadcastId, { + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + + let clicks = result.clicks.map(click => ({ + url: click.url, + uniqueClicks: click.unique_clicks, + clickToDeliveryRate: click.click_to_delivery_rate, + clickToOpenRate: click.click_to_open_rate + })); + + return { + output: { + broadcastId: result.broadcastId, + clicks, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor + }, + message: `Found **${clicks.length}** tracked links for broadcast \`${result.broadcastId}\`.` + }; + }) + .build(); diff --git a/integrations/kit/src/tools/get-broadcast-stats.ts b/integrations/kit/src/tools/get-broadcast-stats.ts index 7075c8e165..c01cd8ac96 100644 --- a/integrations/kit/src/tools/get-broadcast-stats.ts +++ b/integrations/kit/src/tools/get-broadcast-stats.ts @@ -6,7 +6,7 @@ import { spec } from '../spec'; export let getBroadcastStats = SlateTool.create(spec, { name: 'Get Broadcast Stats', key: 'get_broadcast_stats', - description: `Retrieve performance statistics for a broadcast including open rate, click rate, recipient count, and unsubscribes.`, + description: `Retrieve performance statistics for a broadcast including recipients, opens, clicks, unsubscribes, status, and send progress.`, tags: { readOnly: true } @@ -19,34 +19,48 @@ export let getBroadcastStats = SlateTool.create(spec, { .output( z.object({ broadcastId: z.number().describe('Broadcast ID'), - subject: z.string().describe('Email subject line'), recipients: z.number().describe('Total number of recipients'), openRate: z.number().describe('Open rate (0-1)'), + emailsOpened: z.number().optional().describe('Number of emails opened'), clickRate: z.number().describe('Click rate (0-1)'), + unsubscribeRate: z.number().optional().describe('Unsubscribe rate (0-1)'), unsubscribes: z.number().describe('Number of unsubscribes'), totalClicks: z.number().describe('Total click count'), + showTotalClicks: z.boolean().describe('Whether total click count is available'), status: z.string().describe('Broadcast status'), - sendAt: z.string().nullable().describe('When the broadcast was sent') + progress: z.number().describe('Send progress'), + openTrackingDisabled: z + .boolean() + .optional() + .describe('Whether open tracking is disabled'), + clickTrackingDisabled: z + .boolean() + .optional() + .describe('Whether click tracking is disabled') }) ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); let data = await client.getBroadcastStats(ctx.input.broadcastId); - let b = data.broadcast; + let stats = data.broadcast.stats; return { output: { - broadcastId: b.id, - subject: b.subject, - recipients: b.recipients, - openRate: b.open_rate, - clickRate: b.click_rate, - unsubscribes: b.unsubscribes, - totalClicks: b.total_clicks, - status: b.status, - sendAt: b.send_at + broadcastId: data.broadcast.id, + recipients: stats.recipients, + openRate: stats.open_rate, + emailsOpened: stats.emails_opened, + clickRate: stats.click_rate, + unsubscribeRate: stats.unsubscribe_rate, + unsubscribes: stats.unsubscribes, + totalClicks: stats.total_clicks, + showTotalClicks: stats.show_total_clicks, + status: stats.status, + progress: stats.progress, + openTrackingDisabled: stats.open_tracking_disabled, + clickTrackingDisabled: stats.click_tracking_disabled }, - message: `Broadcast **${b.subject}**: ${b.recipients} recipients, ${(b.open_rate * 100).toFixed(1)}% open rate, ${(b.click_rate * 100).toFixed(1)}% click rate.` + message: `Broadcast \`${data.broadcast.id}\`: ${stats.recipients} recipients, ${(stats.open_rate * 100).toFixed(1)}% open rate, ${(stats.click_rate * 100).toFixed(1)}% click rate.` }; }) .build(); diff --git a/integrations/kit/src/tools/get-subscriber-stats.ts b/integrations/kit/src/tools/get-subscriber-stats.ts new file mode 100644 index 0000000000..595b1f2b32 --- /dev/null +++ b/integrations/kit/src/tools/get-subscriber-stats.ts @@ -0,0 +1,69 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getSubscriberStats = SlateTool.create(spec, { + name: 'Get Subscriber Stats', + key: 'get_subscriber_stats', + description: `Retrieve email engagement stats for a specific subscriber, optionally filtered by email sent date.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + subscriberId: z.number().describe('Subscriber ID'), + emailSentAfter: z + .string() + .optional() + .describe('Only include stats for emails sent after this yyyy-mm-dd date'), + emailSentBefore: z + .string() + .optional() + .describe('Only include stats for emails sent before this yyyy-mm-dd date') + }) + ) + .output( + z.object({ + subscriberId: z.number().describe('Subscriber ID'), + sent: z.number().describe('Emails sent'), + opened: z.number().describe('Emails opened'), + clicked: z.number().describe('Emails clicked'), + bounced: z.number().describe('Emails bounced'), + openRate: z.number().describe('Open rate (0-1)'), + clickRate: z.number().describe('Click rate (0-1)'), + lastSent: z.string().nullable().describe('Most recent sent timestamp'), + lastOpened: z.string().nullable().describe('Most recent opened timestamp'), + lastClicked: z.string().nullable().describe('Most recent clicked timestamp'), + sendsSinceLastOpen: z.number().nullable().describe('Sends since last open'), + sendsSinceLastClick: z.number().nullable().describe('Sends since last click') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let data = await client.getSubscriberStats(ctx.input.subscriberId, { + emailSentAfter: ctx.input.emailSentAfter, + emailSentBefore: ctx.input.emailSentBefore + }); + let stats = data.subscriber.stats; + + return { + output: { + subscriberId: data.subscriber.id, + sent: stats.sent, + opened: stats.opened, + clicked: stats.clicked, + bounced: stats.bounced, + openRate: stats.open_rate, + clickRate: stats.click_rate, + lastSent: stats.last_sent, + lastOpened: stats.last_opened, + lastClicked: stats.last_clicked, + sendsSinceLastOpen: stats.sends_since_last_open, + sendsSinceLastClick: stats.sends_since_last_click + }, + message: `Subscriber \`${data.subscriber.id}\`: ${stats.sent} sent, ${stats.opened} opened, ${stats.clicked} clicked.` + }; + }) + .build(); diff --git a/integrations/kit/src/tools/index.ts b/integrations/kit/src/tools/index.ts index 22850a8014..7debefd3b2 100644 --- a/integrations/kit/src/tools/index.ts +++ b/integrations/kit/src/tools/index.ts @@ -1,16 +1,26 @@ export { addSubscriberToForm } from './add-subscriber-to-form'; export { createSubscriber } from './create-subscriber'; export { getAccount } from './get-account'; +export { getAccountStats } from './get-account-stats'; +export { getBroadcastLinkClicks } from './get-broadcast-link-clicks'; export { getBroadcastStats } from './get-broadcast-stats'; export { getSubscriber } from './get-subscriber'; +export { getSubscriberStats } from './get-subscriber-stats'; +export { listBroadcastStats } from './list-broadcast-stats'; export { listEmailTemplates } from './list-email-templates'; +export { listFormSubscribers } from './list-form-subscribers'; export { listForms } from './list-forms'; export { listSegments } from './list-segments'; +export { listSubscriberTags } from './list-subscriber-tags'; export { listSubscribers } from './list-subscribers'; +export { listTagSubscribers } from './list-tag-subscribers'; export { manageBroadcasts } from './manage-broadcasts'; export { manageCustomFields } from './manage-custom-fields'; +export { managePosts } from './manage-posts'; export { managePurchases } from './manage-purchases'; +export { manageSequenceEmails } from './manage-sequence-emails'; export { manageSequences } from './manage-sequences'; +export { manageSnippets } from './manage-snippets'; export { manageTags } from './manage-tags'; export { tagSubscriber } from './tag-subscriber'; export { unsubscribe } from './unsubscribe'; diff --git a/integrations/kit/src/tools/list-broadcast-stats.ts b/integrations/kit/src/tools/list-broadcast-stats.ts new file mode 100644 index 0000000000..7e414f5a7e --- /dev/null +++ b/integrations/kit/src/tools/list-broadcast-stats.ts @@ -0,0 +1,79 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let broadcastStatsSchema = z.object({ + broadcastId: z.number().describe('Broadcast ID'), + subject: z.string().optional().describe('Broadcast subject'), + sendAt: z.string().nullable().optional().describe('Scheduled or sent time'), + recipients: z.number().describe('Total number of recipients'), + openRate: z.number().describe('Open rate (0-1)'), + emailsOpened: z.number().optional().describe('Number of emails opened'), + clickRate: z.number().describe('Click rate (0-1)'), + unsubscribeRate: z.number().optional().describe('Unsubscribe rate (0-1)'), + unsubscribes: z.number().describe('Number of unsubscribes'), + totalClicks: z.number().describe('Total click count'), + status: z.string().describe('Broadcast status'), + progress: z.number().describe('Send progress') +}); + +export let listBroadcastStats = SlateTool.create(spec, { + name: 'List Broadcast Stats', + key: 'list_broadcast_stats', + description: `List performance statistics for broadcasts with optional sent date filters and cursor pagination.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + sentAfter: z.string().optional().describe('Filter broadcasts sent after this date'), + sentBefore: z.string().optional().describe('Filter broadcasts sent before this date'), + perPage: z.number().optional().describe('Number of results per page (max 1000)'), + afterCursor: z.string().optional().describe('Pagination cursor to fetch next page'), + beforeCursor: z.string().optional().describe('Pagination cursor to fetch previous page') + }) + ) + .output( + z.object({ + broadcasts: z.array(broadcastStatsSchema).describe('Broadcast stats'), + hasNextPage: z.boolean().describe('Whether more results are available'), + endCursor: z.string().nullable().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listBroadcastStats({ + sentAfter: ctx.input.sentAfter, + sentBefore: ctx.input.sentBefore, + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + + let broadcasts = result.data.map(b => ({ + broadcastId: b.id, + subject: b.subject, + sendAt: b.send_at, + recipients: b.stats.recipients, + openRate: b.stats.open_rate, + emailsOpened: b.stats.emails_opened, + clickRate: b.stats.click_rate, + unsubscribeRate: b.stats.unsubscribe_rate, + unsubscribes: b.stats.unsubscribes, + totalClicks: b.stats.total_clicks, + status: b.stats.status, + progress: b.stats.progress + })); + + return { + output: { + broadcasts, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor + }, + message: `Found stats for **${broadcasts.length}** broadcasts.` + }; + }) + .build(); diff --git a/integrations/kit/src/tools/list-form-subscribers.ts b/integrations/kit/src/tools/list-form-subscribers.ts new file mode 100644 index 0000000000..4453178dd1 --- /dev/null +++ b/integrations/kit/src/tools/list-form-subscribers.ts @@ -0,0 +1,69 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let formSubscriberSchema = z.object({ + subscriberId: z.number().describe('Subscriber ID'), + emailAddress: z.string().describe('Subscriber email address'), + firstName: z.string().nullable().describe('Subscriber first name'), + state: z.string().describe('Subscriber state'), + createdAt: z.string().describe('Subscriber creation timestamp'), + addedAt: z.string().optional().describe('When the subscriber was added to the form') +}); + +export let listFormSubscribers = SlateTool.create(spec, { + name: 'List Form Subscribers', + key: 'list_form_subscribers', + description: `List subscribers who were added to a specific Kit form or landing page.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + formId: z.number().describe('Form ID'), + status: z + .enum(['active', 'inactive', 'bounced', 'complained', 'cancelled', 'all']) + .optional() + .describe('Subscriber status filter'), + perPage: z.number().optional().describe('Number of results per page (max 1000)'), + afterCursor: z.string().optional().describe('Pagination cursor to fetch next page'), + beforeCursor: z.string().optional().describe('Pagination cursor to fetch previous page') + }) + ) + .output( + z.object({ + subscribers: z.array(formSubscriberSchema).describe('Form subscribers'), + hasNextPage: z.boolean().describe('Whether more results are available'), + endCursor: z.string().nullable().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listSubscribersForForm(ctx.input.formId, { + status: ctx.input.status, + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + + let subscribers = result.data.map(s => ({ + subscriberId: s.id, + emailAddress: s.email_address, + firstName: s.first_name, + state: s.state, + createdAt: s.created_at, + addedAt: s.added_at + })); + + return { + output: { + subscribers, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor + }, + message: `Found **${subscribers.length}** subscribers for form \`${ctx.input.formId}\`.` + }; + }) + .build(); diff --git a/integrations/kit/src/tools/list-subscriber-tags.ts b/integrations/kit/src/tools/list-subscriber-tags.ts new file mode 100644 index 0000000000..672d2deef1 --- /dev/null +++ b/integrations/kit/src/tools/list-subscriber-tags.ts @@ -0,0 +1,58 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listSubscriberTags = SlateTool.create(spec, { + name: 'List Subscriber Tags', + key: 'list_subscriber_tags', + description: `List tags currently applied to a specific subscriber.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + subscriberId: z.number().describe('Subscriber ID'), + perPage: z.number().optional().describe('Number of results per page (max 1000)'), + afterCursor: z.string().optional().describe('Pagination cursor to fetch next page'), + beforeCursor: z.string().optional().describe('Pagination cursor to fetch previous page') + }) + ) + .output( + z.object({ + tags: z.array( + z.object({ + tagId: z.number().describe('Tag ID'), + name: z.string().describe('Tag name'), + taggedAt: z.string().optional().describe('When the tag was applied') + }) + ), + hasNextPage: z.boolean().describe('Whether more results are available'), + endCursor: z.string().nullable().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listTagsForSubscriber(ctx.input.subscriberId, { + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + + let tags = result.data.map(tag => ({ + tagId: tag.id, + name: tag.name, + taggedAt: tag.tagged_at + })); + + return { + output: { + tags, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor + }, + message: `Found **${tags.length}** tags for subscriber \`${ctx.input.subscriberId}\`.` + }; + }) + .build(); diff --git a/integrations/kit/src/tools/list-tag-subscribers.ts b/integrations/kit/src/tools/list-tag-subscribers.ts new file mode 100644 index 0000000000..90b7d3f16f --- /dev/null +++ b/integrations/kit/src/tools/list-tag-subscribers.ts @@ -0,0 +1,67 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listTagSubscribers = SlateTool.create(spec, { + name: 'List Tag Subscribers', + key: 'list_tag_subscribers', + description: `List subscribers who currently have a specific Kit tag.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + tagId: z.number().describe('Tag ID'), + status: z + .enum(['active', 'inactive', 'bounced', 'complained', 'cancelled', 'all']) + .optional() + .describe('Subscriber status filter'), + perPage: z.number().optional().describe('Number of results per page (max 1000)'), + afterCursor: z.string().optional().describe('Pagination cursor to fetch next page'), + beforeCursor: z.string().optional().describe('Pagination cursor to fetch previous page') + }) + ) + .output( + z.object({ + subscribers: z.array( + z.object({ + subscriberId: z.number().describe('Subscriber ID'), + emailAddress: z.string().describe('Subscriber email address'), + firstName: z.string().nullable().describe('Subscriber first name'), + state: z.string().describe('Subscriber state'), + createdAt: z.string().describe('Subscriber creation timestamp') + }) + ), + hasNextPage: z.boolean().describe('Whether more results are available'), + endCursor: z.string().nullable().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listSubscribersForTag(ctx.input.tagId, { + status: ctx.input.status, + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + + let subscribers = result.data.map(s => ({ + subscriberId: s.id, + emailAddress: s.email_address, + firstName: s.first_name, + state: s.state, + createdAt: s.created_at + })); + + return { + output: { + subscribers, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor + }, + message: `Found **${subscribers.length}** subscribers for tag \`${ctx.input.tagId}\`.` + }; + }) + .build(); diff --git a/integrations/kit/src/tools/manage-broadcasts.ts b/integrations/kit/src/tools/manage-broadcasts.ts index 34e39cafbe..5509397014 100644 --- a/integrations/kit/src/tools/manage-broadcasts.ts +++ b/integrations/kit/src/tools/manage-broadcasts.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; let broadcastOutputSchema = z.object({ @@ -84,7 +85,9 @@ export let manageBroadcasts = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.broadcastId) throw new Error('Broadcast ID is required for get'); + if (!ctx.input.broadcastId) { + throw kitServiceError('Broadcast ID is required for get'); + } let data = await client.getBroadcast(ctx.input.broadcastId); return { output: { broadcast: mapBroadcast(data.broadcast) }, @@ -110,7 +113,9 @@ export let manageBroadcasts = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.broadcastId) throw new Error('Broadcast ID is required for update'); + if (!ctx.input.broadcastId) { + throw kitServiceError('Broadcast ID is required for update'); + } let data = await client.updateBroadcast(ctx.input.broadcastId, { subject: ctx.input.subject, content: ctx.input.content, @@ -128,7 +133,9 @@ export let manageBroadcasts = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.broadcastId) throw new Error('Broadcast ID is required for delete'); + if (!ctx.input.broadcastId) { + throw kitServiceError('Broadcast ID is required for delete'); + } await client.deleteBroadcast(ctx.input.broadcastId); return { output: { deleted: true }, @@ -136,6 +143,6 @@ export let manageBroadcasts = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw kitServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/kit/src/tools/manage-custom-fields.ts b/integrations/kit/src/tools/manage-custom-fields.ts index 2dd897bb99..80e2e10d26 100644 --- a/integrations/kit/src/tools/manage-custom-fields.ts +++ b/integrations/kit/src/tools/manage-custom-fields.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCustomFields = SlateTool.create(spec, { @@ -67,7 +68,9 @@ export let manageCustomFields = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.label) throw new Error('Label is required for create'); + if (!ctx.input.label) { + throw kitServiceError('Label is required for create'); + } let data = await client.createCustomField(ctx.input.label); let f = data.custom_field; return { @@ -83,8 +86,12 @@ export let manageCustomFields = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.customFieldId) throw new Error('Custom field ID is required for update'); - if (!ctx.input.label) throw new Error('Label is required for update'); + if (!ctx.input.customFieldId) { + throw kitServiceError('Custom field ID is required for update'); + } + if (!ctx.input.label) { + throw kitServiceError('Label is required for update'); + } let data = await client.updateCustomField(ctx.input.customFieldId, ctx.input.label); let f = data.custom_field; return { @@ -100,7 +107,9 @@ export let manageCustomFields = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.customFieldId) throw new Error('Custom field ID is required for delete'); + if (!ctx.input.customFieldId) { + throw kitServiceError('Custom field ID is required for delete'); + } await client.deleteCustomField(ctx.input.customFieldId); return { output: { deleted: true }, @@ -108,6 +117,6 @@ export let manageCustomFields = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw kitServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/kit/src/tools/manage-posts.ts b/integrations/kit/src/tools/manage-posts.ts new file mode 100644 index 0000000000..9088075779 --- /dev/null +++ b/integrations/kit/src/tools/manage-posts.ts @@ -0,0 +1,107 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let postSchema = z.object({ + postId: z.number().describe('Post ID'), + publicationId: z.number().describe('Publication ID'), + title: z.string().describe('Post title'), + slug: z.string().nullable().describe('Post slug'), + description: z.string().nullable().describe('Post description'), + metaDescription: z.string().nullable().describe('Post meta description'), + status: z.string().describe('Post status'), + createdAt: z.string().describe('Post creation timestamp'), + publishedAt: z.string().nullable().describe('Published timestamp'), + sentAt: z.string().nullable().describe('Sent timestamp'), + thumbnailAlt: z.string().nullable().describe('Thumbnail alt text'), + thumbnailUrl: z.string().nullable().describe('Thumbnail URL'), + isPaid: z.boolean().describe('Whether the post is paid'), + publicUrl: z.string().nullable().describe('Public post URL'), + content: z.string().nullable().optional().describe('Post HTML content') +}); + +export let managePosts = SlateTool.create(spec, { + name: 'Manage Posts', + key: 'manage_posts', + description: `List and get Kit posts. Use includeContent when listing only if post bodies are needed.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + action: z.enum(['list', 'get']).describe('The operation to perform'), + postId: z.number().optional().describe('Post ID (required for get)'), + includeContent: z + .boolean() + .optional() + .describe('Include post HTML content in list responses'), + perPage: z.number().optional().describe('Number of results per page (max 1000)'), + afterCursor: z.string().optional().describe('Pagination cursor to fetch next page'), + beforeCursor: z.string().optional().describe('Pagination cursor to fetch previous page') + }) + ) + .output( + z.object({ + posts: z.array(postSchema).optional().describe('List of posts'), + post: postSchema.optional().describe('Single post'), + hasNextPage: z.boolean().optional().describe('Whether more results are available'), + endCursor: z.string().nullable().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let mapPost = (post: any) => ({ + postId: post.id, + publicationId: post.publication_id, + title: post.title, + slug: post.slug, + description: post.description, + metaDescription: post.meta_description, + status: post.status, + createdAt: post.created_at, + publishedAt: post.published_at, + sentAt: post.sent_at, + thumbnailAlt: post.thumbnail_alt, + thumbnailUrl: post.thumbnail_url, + isPaid: post.is_paid, + publicUrl: post.public_url, + content: post.content + }); + + if (ctx.input.action === 'list') { + let result = await client.listPosts({ + includeContent: ctx.input.includeContent, + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + let posts = result.data.map(mapPost); + return { + output: { + posts, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor + }, + message: `Found **${posts.length}** posts.` + }; + } + + if (ctx.input.action === 'get') { + if (!ctx.input.postId) { + throw kitServiceError('Post ID is required for get'); + } + + let data = await client.getPost(ctx.input.postId); + return { + output: { post: mapPost(data.post) }, + message: `Retrieved post **${data.post.title}**.` + }; + } + + throw kitServiceError(`Unknown action: ${ctx.input.action}`); + }) + .build(); diff --git a/integrations/kit/src/tools/manage-purchases.ts b/integrations/kit/src/tools/manage-purchases.ts index b1037e7c44..a0436bdf98 100644 --- a/integrations/kit/src/tools/manage-purchases.ts +++ b/integrations/kit/src/tools/manage-purchases.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let managePurchases = SlateTool.create(spec, { @@ -101,7 +102,9 @@ export let managePurchases = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.purchaseId) throw new Error('Purchase ID is required for get'); + if (!ctx.input.purchaseId) { + throw kitServiceError('Purchase ID is required for get'); + } let data = await client.getPurchase(ctx.input.purchaseId); return { output: { purchase: mapPurchase(data.purchase) }, @@ -110,10 +113,15 @@ export let managePurchases = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.emailAddress) throw new Error('Email address is required for create'); - if (!ctx.input.transactionId) throw new Error('Transaction ID is required for create'); - if (!ctx.input.products || ctx.input.products.length === 0) - throw new Error('At least one product is required for create'); + if (!ctx.input.emailAddress) { + throw kitServiceError('Email address is required for create'); + } + if (!ctx.input.transactionId) { + throw kitServiceError('Transaction ID is required for create'); + } + if (!ctx.input.products || ctx.input.products.length === 0) { + throw kitServiceError('At least one product is required for create'); + } let data = await client.createPurchase({ emailAddress: ctx.input.emailAddress, @@ -133,6 +141,6 @@ export let managePurchases = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw kitServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/kit/src/tools/manage-sequence-emails.ts b/integrations/kit/src/tools/manage-sequence-emails.ts new file mode 100644 index 0000000000..608c5cca6e --- /dev/null +++ b/integrations/kit/src/tools/manage-sequence-emails.ts @@ -0,0 +1,199 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let daySchema = z.enum([ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday' +]); + +let sequenceEmailSchema = z.object({ + emailId: z.number().describe('Sequence email ID'), + sequenceId: z.number().describe('Sequence ID'), + subject: z.string().describe('Email subject line'), + previewText: z.string().nullable().describe('Preview text'), + emailAddress: z.string().nullable().optional().describe('Sending email address'), + emailTemplateId: z.number().nullable().describe('Email template ID'), + published: z.boolean().describe('Whether the email is published'), + position: z.number().nullable().describe('Zero-based email position'), + delayValue: z.number().describe('Delay value'), + delayUnit: z.string().describe('Delay unit'), + sendDays: z.array(z.string()).nullable().describe('Allowed send days'), + content: z.string().nullable().optional().describe('Email HTML content') +}); + +export let manageSequenceEmails = SlateTool.create(spec, { + name: 'Manage Sequence Emails', + key: 'manage_sequence_emails', + description: `Create, get, update, delete, and list the individual email steps inside a Kit sequence.`, + instructions: [ + 'Create sequence emails as drafts with published=false unless you explicitly intend to send.', + 'Only the first email in a sequence can use delayValue=0 with delayUnit=days.', + 'Use includeContent for list only when the email HTML body is needed.' + ] +}) + .input( + z.object({ + action: z + .enum(['list', 'get', 'create', 'update', 'delete']) + .describe('The operation to perform'), + sequenceId: z.number().describe('Sequence ID'), + emailId: z + .number() + .optional() + .describe('Sequence email ID (required for get, update, delete)'), + subject: z.string().optional().describe('Subject line (required for create)'), + delayValue: z + .number() + .optional() + .describe('Number of days or hours to wait before sending'), + delayUnit: z + .enum(['days', 'hours']) + .optional() + .describe('Delay unit (required for create)'), + previewText: z.string().nullable().optional().describe('Email preview text'), + content: z.string().nullable().optional().describe('HTML email body'), + emailTemplateId: z.number().nullable().optional().describe('Email template ID'), + published: z.boolean().optional().describe('Whether the email is published'), + sendDays: z + .array(daySchema) + .nullable() + .optional() + .describe('Allowed send days for day-based sequence emails'), + position: z.number().nullable().optional().describe('Zero-based email position'), + includeContent: z.boolean().optional().describe('Include content when listing emails'), + perPage: z.number().optional().describe('Number of results per page (max 1000)'), + afterCursor: z.string().optional().describe('Pagination cursor to fetch next page'), + beforeCursor: z.string().optional().describe('Pagination cursor to fetch previous page') + }) + ) + .output( + z.object({ + emails: z.array(sequenceEmailSchema).optional().describe('List of sequence emails'), + email: sequenceEmailSchema.optional().describe('Single sequence email'), + deleted: z.boolean().optional().describe('Whether the email was deleted'), + hasNextPage: z.boolean().optional().describe('Whether more results are available'), + endCursor: z.string().nullable().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let mapEmail = (email: any) => ({ + emailId: email.id, + sequenceId: email.sequence_id, + subject: email.subject, + previewText: email.preview_text, + emailAddress: email.email_address, + emailTemplateId: email.email_template_id, + published: email.published, + position: email.position, + delayValue: email.delay_value, + delayUnit: email.delay_unit, + sendDays: email.send_days, + content: email.content + }); + + let requireEmailId = (action: string) => { + if (!ctx.input.emailId) { + throw kitServiceError(`Sequence email ID is required for ${action}`); + } + + return ctx.input.emailId; + }; + + if (ctx.input.action === 'list') { + let result = await client.listSequenceEmails(ctx.input.sequenceId, { + includeContent: ctx.input.includeContent, + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + let emails = result.data.map(mapEmail); + return { + output: { + emails, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor + }, + message: `Found **${emails.length}** emails in sequence \`${ctx.input.sequenceId}\`.` + }; + } + + if (ctx.input.action === 'get') { + let data = await client.getSequenceEmail(ctx.input.sequenceId, requireEmailId('get')); + return { + output: { email: mapEmail(data.email) }, + message: `Retrieved sequence email **${data.email.subject}**.` + }; + } + + if (ctx.input.action === 'create') { + if (!ctx.input.subject) { + throw kitServiceError('subject is required for create'); + } + if (ctx.input.delayValue === undefined) { + throw kitServiceError('delayValue is required for create'); + } + if (!ctx.input.delayUnit) { + throw kitServiceError('delayUnit is required for create'); + } + + let data = await client.createSequenceEmail(ctx.input.sequenceId, { + subject: ctx.input.subject, + delayValue: ctx.input.delayValue, + delayUnit: ctx.input.delayUnit, + previewText: ctx.input.previewText, + content: ctx.input.content, + emailTemplateId: ctx.input.emailTemplateId, + published: ctx.input.published, + sendDays: ctx.input.sendDays, + position: ctx.input.position + }); + return { + output: { email: mapEmail(data.email) }, + message: `Created sequence email **${data.email.subject}**.` + }; + } + + if (ctx.input.action === 'update') { + let data = await client.updateSequenceEmail( + ctx.input.sequenceId, + requireEmailId('update'), + { + subject: ctx.input.subject, + delayValue: ctx.input.delayValue, + delayUnit: ctx.input.delayUnit, + previewText: ctx.input.previewText, + content: ctx.input.content, + emailTemplateId: ctx.input.emailTemplateId, + published: ctx.input.published, + sendDays: ctx.input.sendDays, + position: ctx.input.position + } + ); + return { + output: { email: mapEmail(data.email) }, + message: `Updated sequence email **${data.email.subject}**.` + }; + } + + if (ctx.input.action === 'delete') { + let emailId = requireEmailId('delete'); + await client.deleteSequenceEmail(ctx.input.sequenceId, emailId); + return { + output: { deleted: true }, + message: `Deleted sequence email \`${emailId}\`.` + }; + } + + throw kitServiceError(`Unknown action: ${ctx.input.action}`); + }) + .build(); diff --git a/integrations/kit/src/tools/manage-sequences.ts b/integrations/kit/src/tools/manage-sequences.ts index a82cfe6ba4..433a5ba29e 100644 --- a/integrations/kit/src/tools/manage-sequences.ts +++ b/integrations/kit/src/tools/manage-sequences.ts @@ -1,101 +1,270 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; +let daySchema = z.enum([ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday' +]); + +let sequenceSchema = z.object({ + sequenceId: z.number().describe('Unique sequence ID'), + name: z.string().describe('Sequence name'), + hold: z.boolean().describe('Whether subscribers stay active after the last email'), + repeat: z.boolean().describe('Whether subscribers can restart the sequence'), + active: z.boolean().optional().describe('Whether the sequence is active'), + createdAt: z.string().describe('When the sequence was created'), + emailAddress: z.string().nullable().optional().describe('Configured sending email'), + emailTemplateId: z.number().nullable().optional().describe('Default email template ID'), + emailCount: z.number().optional().describe('Number of emails in the sequence'), + subscriberCount: z.number().optional().describe('Number of subscribers in the sequence'), + sendDays: z.array(z.string()).optional().describe('Days this sequence can send'), + sendHour: z.number().optional().describe('Hour of day this sequence can send'), + timeZone: z.string().optional().describe('Sequence sending time zone') +}); + +let subscriberSchema = z.object({ + subscriberId: z.number().describe('Subscriber ID'), + emailAddress: z.string().describe('Subscriber email'), + firstName: z.string().nullable().optional().describe('Subscriber first name'), + state: z.string().describe('Subscriber state') +}); + export let manageSequences = SlateTool.create(spec, { name: 'Manage Sequences', key: 'manage_sequences', - description: `List email sequences (drip campaigns) and add subscribers to sequences. Subscribers can be identified by ID or email address.` + description: `Create, get, update, delete, list, and subscribe people to Kit email sequences. Also lists subscribers for a specific sequence.`, + instructions: [ + 'Use action=create to create an empty sequence, then use manage_sequence_emails to add email steps.', + 'Use active=false when creating or updating draft/test sequences to prevent accidental delivery.', + 'Use senderEmailAddress for the sequence sending address; emailAddress is reserved for add_subscriber.' + ] }) .input( z.object({ - action: z.enum(['list', 'add_subscriber']).describe('The operation to perform'), - sequenceId: z.number().optional().describe('Sequence ID (required for add_subscriber)'), - subscriberId: z + action: z + .enum([ + 'list', + 'get', + 'create', + 'update', + 'delete', + 'add_subscriber', + 'list_subscribers' + ]) + .describe('The operation to perform'), + sequenceId: z .number() .optional() - .describe('Subscriber ID to add (for add_subscriber)'), - emailAddress: z + .describe( + 'Sequence ID (required for get, update, delete, add_subscriber, list_subscribers)' + ), + name: z.string().optional().describe('Sequence name (required for create)'), + senderEmailAddress: z .string() .optional() - .describe('Subscriber email to add (for add_subscriber)') - }) - ) - .output( - z.object({ - sequences: z + .describe('Sending email address for create/update'), + emailTemplateId: z + .number() + .optional() + .describe('Default email template ID for create/update'), + sendDays: z.array(daySchema).optional().describe('Days this sequence can send'), + sendHour: z.number().optional().describe('Hour of day, 0 through 23'), + timeZone: z.string().optional().describe('IANA time zone for the sequence'), + active: z.boolean().optional().describe('Whether the sequence is active'), + repeat: z.boolean().optional().describe('Whether subscribers can restart the sequence'), + hold: z + .boolean() + .optional() + .describe('Whether subscribers stay active after the last published email'), + excludeSubscriberSources: z .array( z.object({ - sequenceId: z.number().describe('Unique sequence ID'), - name: z.string().describe('Sequence name'), - hold: z.boolean().describe('Whether the sequence is on hold'), - repeat: z.boolean().describe('Whether the sequence repeats'), - createdAt: z.string().describe('When the sequence was created') + type: z + .enum(['tag', 'sequence', 'form', 'segment']) + .describe('Subscriber source type to exclude'), + ids: z.array(z.number()).describe('Source IDs to exclude') }) ) .optional() - .describe('List of sequences'), - subscriber: z - .object({ - subscriberId: z.number().describe('Subscriber ID'), - emailAddress: z.string().describe('Subscriber email'), - state: z.string().describe('Subscriber state') - }) + .describe('Subscriber source filters to exclude from the sequence'), + subscriberId: z.number().optional().describe('Subscriber ID for add_subscriber'), + emailAddress: z.string().optional().describe('Subscriber email for add_subscriber'), + status: z + .enum(['active', 'inactive', 'bounced', 'complained', 'cancelled', 'all']) .optional() - .describe('Subscriber added to sequence') + .describe('Subscriber status filter for list_subscribers'), + perPage: z.number().optional().describe('Number of results per page (max 1000)'), + afterCursor: z.string().optional().describe('Pagination cursor to fetch next page'), + beforeCursor: z.string().optional().describe('Pagination cursor to fetch previous page') + }) + ) + .output( + z.object({ + sequences: z.array(sequenceSchema).optional().describe('List of sequences'), + sequence: sequenceSchema.optional().describe('Single sequence'), + subscribers: z.array(subscriberSchema).optional().describe('Subscribers in a sequence'), + subscriber: subscriberSchema.optional().describe('Subscriber added to a sequence'), + deleted: z.boolean().optional().describe('Whether the sequence was deleted'), + hasNextPage: z.boolean().optional().describe('Whether more results are available'), + endCursor: z.string().nullable().optional().describe('Cursor for the next page') }) ) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); + let mapSequence = (s: any) => ({ + sequenceId: s.id, + name: s.name, + hold: s.hold, + repeat: s.repeat, + active: s.active, + createdAt: s.created_at, + emailAddress: s.email_address, + emailTemplateId: s.email_template_id, + emailCount: s.email_count, + subscriberCount: s.subscriber_count, + sendDays: s.send_days, + sendHour: s.send_hour, + timeZone: s.time_zone + }); + + let mapSubscriber = (s: any) => ({ + subscriberId: s.id, + emailAddress: s.email_address, + firstName: s.first_name, + state: s.state + }); + + let requireSequenceId = (action: string) => { + if (!ctx.input.sequenceId) { + throw kitServiceError(`Sequence ID is required for ${action}`); + } + + return ctx.input.sequenceId; + }; + if (ctx.input.action === 'list') { - let result = await client.listSequences(); - let sequences = result.data.map(s => ({ - sequenceId: s.id, - name: s.name, - hold: s.hold, - repeat: s.repeat, - createdAt: s.created_at - })); + let result = await client.listSequences({ + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + let sequences = result.data.map(mapSequence); return { - output: { sequences }, + output: { + sequences, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor + }, message: `Found **${sequences.length}** sequences.` }; } + if (ctx.input.action === 'get') { + let data = await client.getSequence(requireSequenceId('get')); + return { + output: { sequence: mapSequence(data.sequence) }, + message: `Retrieved sequence **${data.sequence.name}**.` + }; + } + + if (ctx.input.action === 'create') { + if (!ctx.input.name) { + throw kitServiceError('Sequence name is required for create'); + } + + let data = await client.createSequence({ + name: ctx.input.name, + emailAddress: ctx.input.senderEmailAddress, + emailTemplateId: ctx.input.emailTemplateId, + sendDays: ctx.input.sendDays, + sendHour: ctx.input.sendHour, + timeZone: ctx.input.timeZone, + active: ctx.input.active, + repeat: ctx.input.repeat, + hold: ctx.input.hold, + excludeSubscriberSources: ctx.input.excludeSubscriberSources + }); + + return { + output: { sequence: mapSequence(data.sequence) }, + message: `Created sequence **${data.sequence.name}**.` + }; + } + + if (ctx.input.action === 'update') { + let sequenceId = requireSequenceId('update'); + let data = await client.updateSequence(sequenceId, { + name: ctx.input.name, + emailAddress: ctx.input.senderEmailAddress, + emailTemplateId: ctx.input.emailTemplateId, + sendDays: ctx.input.sendDays, + sendHour: ctx.input.sendHour, + timeZone: ctx.input.timeZone, + active: ctx.input.active, + repeat: ctx.input.repeat, + hold: ctx.input.hold, + excludeSubscriberSources: ctx.input.excludeSubscriberSources + }); + + return { + output: { sequence: mapSequence(data.sequence) }, + message: `Updated sequence **${data.sequence.name}**.` + }; + } + + if (ctx.input.action === 'delete') { + let sequenceId = requireSequenceId('delete'); + await client.deleteSequence(sequenceId); + return { + output: { deleted: true }, + message: `Deleted sequence \`${sequenceId}\`.` + }; + } + if (ctx.input.action === 'add_subscriber') { - if (!ctx.input.sequenceId) throw new Error('Sequence ID is required'); + let sequenceId = requireSequenceId('add_subscriber'); if (!ctx.input.subscriberId && !ctx.input.emailAddress) { - throw new Error('Provide either subscriberId or emailAddress'); + throw kitServiceError('Provide either subscriberId or emailAddress'); } - let data: any; - if (ctx.input.subscriberId) { - data = await client.addSubscriberToSequence( - ctx.input.sequenceId, - ctx.input.subscriberId - ); - } else { - data = await client.addSubscriberToSequenceByEmail( - ctx.input.sequenceId, - ctx.input.emailAddress! - ); - } + let data = ctx.input.subscriberId + ? await client.addSubscriberToSequence(sequenceId, ctx.input.subscriberId) + : await client.addSubscriberToSequenceByEmail(sequenceId, ctx.input.emailAddress!); + + return { + output: { subscriber: mapSubscriber(data.subscriber) }, + message: `Added **${data.subscriber.email_address}** to sequence \`${sequenceId}\`.` + }; + } - let s = data.subscriber; + if (ctx.input.action === 'list_subscribers') { + let sequenceId = requireSequenceId('list_subscribers'); + let result = await client.listSubscribersForSequence(sequenceId, { + status: ctx.input.status, + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + let subscribers = result.data.map(mapSubscriber); return { output: { - subscriber: { - subscriberId: s.id, - emailAddress: s.email_address, - state: s.state - } + subscribers, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor }, - message: `Added **${s.email_address}** to sequence \`${ctx.input.sequenceId}\`.` + message: `Found **${subscribers.length}** subscribers in sequence \`${sequenceId}\`.` }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw kitServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/kit/src/tools/manage-snippets.ts b/integrations/kit/src/tools/manage-snippets.ts new file mode 100644 index 0000000000..a84537e720 --- /dev/null +++ b/integrations/kit/src/tools/manage-snippets.ts @@ -0,0 +1,173 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let snippetSchema = z.object({ + snippetId: z.number().describe('Snippet ID'), + name: z.string().describe('Snippet name'), + snippetType: z.string().describe('Snippet type'), + archived: z.boolean().describe('Whether the snippet is archived'), + key: z.string().describe('Liquid key used as {{ snippet.key }}'), + createdAt: z.string().describe('Creation timestamp'), + updatedAt: z.string().describe('Update timestamp'), + content: z.string().nullable().optional().describe('Inline snippet content'), + documentHtml: z.string().nullable().optional().describe('Block snippet HTML content') +}); + +export let manageSnippets = SlateTool.create(spec, { + name: 'Manage Snippets', + key: 'manage_snippets', + description: `Create, get, update, archive, restore, and list reusable Kit snippets for Liquid-powered broadcast and sequence email content.`, + instructions: [ + 'Use snippetType=inline with content for plain Liquid text snippets.', + 'Use snippetType=block with documentHtml for rich HTML block snippets.', + 'Kit does not expose hard delete for snippets; update archived=true to archive.' + ] +}) + .input( + z.object({ + action: z + .enum(['list', 'get', 'create', 'update', 'archive', 'restore']) + .describe('The operation to perform'), + snippetId: z + .number() + .optional() + .describe('Snippet ID (required for get, update, archive, restore)'), + name: z.string().optional().describe('Snippet name (required for create)'), + snippetType: z + .enum(['inline', 'block']) + .optional() + .describe('Snippet type for create/update'), + content: z.string().optional().describe('Inline Liquid-enabled text content'), + documentHtml: z.string().optional().describe('Block HTML content'), + archived: z.boolean().optional().describe('Filter list results by archive state'), + includeContent: z.boolean().optional().describe('Include content when listing snippets'), + perPage: z.number().optional().describe('Number of results per page (max 1000)'), + afterCursor: z.string().optional().describe('Pagination cursor to fetch next page'), + beforeCursor: z.string().optional().describe('Pagination cursor to fetch previous page') + }) + ) + .output( + z.object({ + snippets: z.array(snippetSchema).optional().describe('List of snippets'), + snippet: snippetSchema.optional().describe('Single snippet'), + hasNextPage: z.boolean().optional().describe('Whether more results are available'), + endCursor: z.string().nullable().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let mapSnippet = (snippet: any) => ({ + snippetId: snippet.id, + name: snippet.name, + snippetType: snippet.snippet_type, + archived: snippet.archived, + key: snippet.key, + createdAt: snippet.created_at, + updatedAt: snippet.updated_at, + content: snippet.content, + documentHtml: snippet.document?.value_html + }); + + let requireSnippetId = (action: string) => { + if (!ctx.input.snippetId) { + throw kitServiceError(`Snippet ID is required for ${action}`); + } + + return ctx.input.snippetId; + }; + + let validateSnippetBody = () => { + if (ctx.input.snippetType === 'inline' && !ctx.input.content) { + throw kitServiceError('content is required when snippetType is inline'); + } + + if (ctx.input.snippetType === 'block' && !ctx.input.documentHtml) { + throw kitServiceError('documentHtml is required when snippetType is block'); + } + }; + + if (ctx.input.action === 'list') { + let result = await client.listSnippets({ + snippetType: ctx.input.snippetType, + archived: ctx.input.archived, + includeContent: ctx.input.includeContent, + perPage: ctx.input.perPage, + after: ctx.input.afterCursor, + before: ctx.input.beforeCursor + }); + let snippets = result.data.map(mapSnippet); + return { + output: { + snippets, + hasNextPage: result.pagination.has_next_page, + endCursor: result.pagination.end_cursor + }, + message: `Found **${snippets.length}** snippets.` + }; + } + + if (ctx.input.action === 'get') { + let data = await client.getSnippet(requireSnippetId('get')); + return { + output: { snippet: mapSnippet(data.snippet) }, + message: `Retrieved snippet **${data.snippet.name}**.` + }; + } + + if (ctx.input.action === 'create') { + if (!ctx.input.name) { + throw kitServiceError('Snippet name is required for create'); + } + if (!ctx.input.snippetType) { + throw kitServiceError('snippetType is required for create'); + } + validateSnippetBody(); + + let data = await client.createSnippet({ + name: ctx.input.name, + snippetType: ctx.input.snippetType, + content: ctx.input.content, + documentHtml: ctx.input.documentHtml + }); + return { + output: { snippet: mapSnippet(data.snippet) }, + message: `Created snippet **${data.snippet.name}**.` + }; + } + + if (ctx.input.action === 'update') { + let snippetId = requireSnippetId('update'); + if (ctx.input.snippetType) { + validateSnippetBody(); + } + + let data = await client.updateSnippet(snippetId, { + name: ctx.input.name, + snippetType: ctx.input.snippetType, + content: ctx.input.content, + documentHtml: ctx.input.documentHtml + }); + return { + output: { snippet: mapSnippet(data.snippet) }, + message: `Updated snippet **${data.snippet.name}**.` + }; + } + + if (ctx.input.action === 'archive' || ctx.input.action === 'restore') { + let snippetId = requireSnippetId(ctx.input.action); + let data = await client.updateSnippet(snippetId, { + archived: ctx.input.action === 'archive' + }); + return { + output: { snippet: mapSnippet(data.snippet) }, + message: `${ctx.input.action === 'archive' ? 'Archived' : 'Restored'} snippet **${data.snippet.name}**.` + }; + } + + throw kitServiceError(`Unknown action: ${ctx.input.action}`); + }) + .build(); diff --git a/integrations/kit/src/tools/manage-tags.ts b/integrations/kit/src/tools/manage-tags.ts index b1209a381a..4c0f425257 100644 --- a/integrations/kit/src/tools/manage-tags.ts +++ b/integrations/kit/src/tools/manage-tags.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageTags = SlateTool.create(spec, { @@ -60,7 +61,9 @@ export let manageTags = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('Tag name is required for create'); + if (!ctx.input.name) { + throw kitServiceError('Tag name is required for create'); + } let data = await client.createTag(ctx.input.name); return { output: { @@ -75,8 +78,12 @@ export let manageTags = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.tagId) throw new Error('Tag ID is required for update'); - if (!ctx.input.name) throw new Error('Tag name is required for update'); + if (!ctx.input.tagId) { + throw kitServiceError('Tag ID is required for update'); + } + if (!ctx.input.name) { + throw kitServiceError('Tag name is required for update'); + } let data = await client.updateTag(ctx.input.tagId, ctx.input.name); return { output: { @@ -91,7 +98,9 @@ export let manageTags = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.tagId) throw new Error('Tag ID is required for delete'); + if (!ctx.input.tagId) { + throw kitServiceError('Tag ID is required for delete'); + } await client.deleteTag(ctx.input.tagId); return { output: { deleted: true }, @@ -99,6 +108,6 @@ export let manageTags = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw kitServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/kit/src/tools/tag-subscriber.ts b/integrations/kit/src/tools/tag-subscriber.ts index 1e5e01b832..7b9dec01cb 100644 --- a/integrations/kit/src/tools/tag-subscriber.ts +++ b/integrations/kit/src/tools/tag-subscriber.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { kitServiceError } from '../lib/errors'; import { spec } from '../spec'; export let tagSubscriber = SlateTool.create(spec, { @@ -31,7 +32,7 @@ export let tagSubscriber = SlateTool.create(spec, { let client = new Client({ token: ctx.auth.token }); if (!ctx.input.subscriberId && !ctx.input.emailAddress) { - throw new Error('Provide either subscriberId or emailAddress'); + throw kitServiceError('Provide either subscriberId or emailAddress'); } if (ctx.input.action === 'add') { @@ -58,6 +59,6 @@ export let tagSubscriber = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw kitServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/kit/vitest.config.ts b/integrations/kit/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/kit/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/klaviyo/README.md b/integrations/klaviyo/README.md index e52d58cae5..329e67f062 100644 --- a/integrations/klaviyo/README.md +++ b/integrations/klaviyo/README.md @@ -8,6 +8,10 @@ Manage email, SMS, and push notification marketing campaigns for eCommerce. Crea Create a new customer profile or update an existing one in Klaviyo. When a profileId is provided, the existing profile will be updated. Otherwise a new profile is created. Supports setting email, phone, name, location, and custom properties. +### Get Account + +Retrieve the Klaviyo account associated with the current credentials, including account identity, contact information, timezone, currency, and public API key when available. Use this to verify the connected account before making changes. + ### Get Events Retrieve events (actions tracked for profiles) from Klaviyo. Filter by metric, profile, timestamp, or other attributes. Events include email opens, clicks, purchases, and any custom events. @@ -16,6 +20,10 @@ Retrieve events (actions tracked for profiles) from Klaviyo. Filter by metric, p Retrieve automation flows from Klaviyo. Flows are automated messaging workflows triggered by events, list membership, or dates. Can fetch a specific flow by ID or list all flows. Optionally include flow actions. +### Get Forms + +Retrieve Klaviyo signup forms. Can fetch a single form by ID or list forms with filtering, sorting, sparse fields, and pagination. Use this to audit active forms, find form IDs, and verify list-building entry points. + ### Get List or Segment Profiles Retrieve profiles belonging to a specific list or segment in Klaviyo. Supports filtering and pagination. Use this to see who is in a particular audience. @@ -44,6 +52,10 @@ Create, retrieve, update, or delete product catalog items in Klaviyo. Catalog it Create and retrieve coupons in Klaviyo, and bulk-create coupon codes. Coupons are used in campaigns and flows to offer discounts. +### Manage Images + +List, retrieve, upload from URL or data URI, and update Klaviyo images used in templates and campaigns. Use upload to import a hosted image or data URI into Klaviyo's image library. + ### Manage Lists Create, update, delete, or retrieve lists in Klaviyo. Also supports adding and removing profiles from lists. Lists are static collections of profiles used for campaign targeting. @@ -64,6 +76,10 @@ Create, retrieve, update, delete, clone, or render email templates in Klaviyo. T Query aggregate analytics data for a specific metric in Klaviyo. Returns computed measurements like count, sum, or unique values over a time period. Useful for building reports on email opens, revenue, clicks, conversions, and other performance metrics. +### Query Reports + +Query Klaviyo Reporting API values and time series for campaigns, flows, forms, and segments. Use this for performance reporting that is scoped to Klaviyo marketing assets rather than raw metric aggregates. + ### Request Profile Deletion Submit a data privacy deletion request for a profile in Klaviyo. Used for GDPR right-to-erasure and similar privacy compliance. The profile and all associated data will be permanently deleted. diff --git a/integrations/klaviyo/package.json b/integrations/klaviyo/package.json index 3ba3f08ed1..d463038ca8 100644 --- a/integrations/klaviyo/package.json +++ b/integrations/klaviyo/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/klaviyo/src/auth.ts b/integrations/klaviyo/src/auth.ts index 7091ce512e..b6594c17b5 100644 --- a/integrations/klaviyo/src/auth.ts +++ b/integrations/klaviyo/src/auth.ts @@ -1,10 +1,19 @@ -import { createAxios, SlateAuth } from 'slates'; +import { + createApiServiceError, + createAxios, + normalizeOAuthTokenResponse, + SlateAuth +} from 'slates'; import { z } from 'zod'; +import { klaviyoApiError } from './lib/errors'; + +const KLAVIYO_API_REVISION = '2026-04-15'; export let auth = SlateAuth.create() .output( z.object({ token: z.string(), + authType: z.enum(['oauth', 'private_api_key']).optional(), refreshToken: z.string().optional(), expiresAt: z.string().optional() }) @@ -92,11 +101,6 @@ export let auth = SlateAuth.create() description: 'Read forms and form versions', scope: 'forms:read' }, - { - title: 'Forms Write', - description: 'Create and delete forms', - scope: 'forms:write' - }, { title: 'Images Read', description: 'Read uploaded images', @@ -223,29 +227,36 @@ export let auth = SlateAuth.create() client_secret: ctx.clientSecret }); - let response = await axios.post('https://a.klaviyo.com/oauth/token', body.toString(), { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - }); + let response = await axios + .post('https://a.klaviyo.com/oauth/token', body.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + .catch(error => { + throw klaviyoApiError(error, 'OAuth token exchange'); + }); - let data = response.data; - let expiresAt = data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined; + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Klaviyo', + operation: 'token exchange' + }); return { output: { - token: data.access_token, - refreshToken: data.refresh_token ?? undefined, - expiresAt + token: token.token, + authType: 'oauth' as const, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt } }; }, handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - return { output: ctx.output }; + throw createApiServiceError( + 'Klaviyo OAuth token refresh requires a stored refresh token. Reconnect the account.' + ); } let axios = createAxios(); @@ -257,22 +268,28 @@ export let auth = SlateAuth.create() client_secret: ctx.clientSecret }); - let response = await axios.post('https://a.klaviyo.com/oauth/token', body.toString(), { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - }); + let response = await axios + .post('https://a.klaviyo.com/oauth/token', body.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + .catch(error => { + throw klaviyoApiError(error, 'OAuth token refresh'); + }); - let data = response.data; - let expiresAt = data.expires_in - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined; + let token = normalizeOAuthTokenResponse(response.data, { + providerLabel: 'Klaviyo', + operation: 'token refresh', + previousRefreshToken: ctx.output.refreshToken + }); return { output: { - token: data.access_token, - refreshToken: data.refresh_token ?? ctx.output.refreshToken, - expiresAt + token: token.token, + authType: 'oauth' as const, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt } }; }, @@ -282,12 +299,14 @@ export let auth = SlateAuth.create() baseURL: 'https://a.klaviyo.com/api', headers: { Authorization: `Bearer ${ctx.output.token}`, - revision: '2025-01-15', + revision: KLAVIYO_API_REVISION, Accept: 'application/vnd.api+json' } }); - let response = await axios.get('/accounts/'); + let response = await axios.get('/accounts/').catch(error => { + throw klaviyoApiError(error, 'account profile lookup'); + }); let account = response.data?.data?.[0]; return { @@ -311,7 +330,8 @@ export let auth = SlateAuth.create() getOutput: async ctx => { return { output: { - token: ctx.input.token + token: ctx.input.token, + authType: 'private_api_key' as const } }; }, @@ -321,12 +341,14 @@ export let auth = SlateAuth.create() baseURL: 'https://a.klaviyo.com/api', headers: { Authorization: `Klaviyo-API-Key ${ctx.output.token}`, - revision: '2025-01-15', + revision: KLAVIYO_API_REVISION, Accept: 'application/vnd.api+json' } }); - let response = await axios.get('/accounts/'); + let response = await axios.get('/accounts/').catch(error => { + throw klaviyoApiError(error, 'account profile lookup'); + }); let account = response.data?.data?.[0]; return { diff --git a/integrations/klaviyo/src/config.ts b/integrations/klaviyo/src/config.ts index 7248a79a3b..5fc8b99870 100644 --- a/integrations/klaviyo/src/config.ts +++ b/integrations/klaviyo/src/config.ts @@ -5,7 +5,7 @@ export let config = SlateConfig.create( z.object({ revision: z .string() - .default('2025-01-15') - .describe('Klaviyo API revision date (e.g., 2025-01-15). Controls API version behavior.') + .default('2026-04-15') + .describe('Klaviyo API revision date (e.g., 2026-04-15). Controls API version behavior.') }) ); diff --git a/integrations/klaviyo/src/index.ts b/integrations/klaviyo/src/index.ts index a0ecc2c321..8c8d7a3fd3 100644 --- a/integrations/klaviyo/src/index.ts +++ b/integrations/klaviyo/src/index.ts @@ -2,8 +2,10 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { createUpdateProfile, + getAccount, getEvents, getFlows, + getForms, getListSegmentProfiles, getMetrics, getProfiles, @@ -11,11 +13,13 @@ import { manageCampaigns, manageCatalogItems, manageCoupons, + manageImages, manageLists, manageSubscriptions, manageTags, manageTemplates, queryMetricAggregates, + queryReports, requestProfileDeletion, trackEvent, updateFlowStatus @@ -25,6 +29,7 @@ import { newEvents, newProfiles, webhookEvents } from './triggers'; export let provider = Slate.create({ spec, tools: [ + getAccount, getProfiles, createUpdateProfile, manageSubscriptions, @@ -38,7 +43,10 @@ export let provider = Slate.create({ getEvents, getMetrics, queryMetricAggregates, + queryReports, manageCatalogItems, + getForms, + manageImages, manageTemplates, manageTags, manageCoupons, diff --git a/integrations/klaviyo/src/lib/client.ts b/integrations/klaviyo/src/lib/client.ts index 111708ef3e..7e793d3f33 100644 --- a/integrations/klaviyo/src/lib/client.ts +++ b/integrations/klaviyo/src/lib/client.ts @@ -1,4 +1,7 @@ import { createAxios } from 'slates'; +import { klaviyoApiError } from './errors'; + +const DEFAULT_KLAVIYO_API_REVISION = '2026-04-15'; export interface KlaviyoClientConfig { token: string; @@ -42,11 +45,16 @@ export class KlaviyoClient { baseURL: 'https://a.klaviyo.com/api', headers: { Authorization: authHeader, - revision: config.revision ?? '2025-01-15', + revision: config.revision ?? DEFAULT_KLAVIYO_API_REVISION, Accept: 'application/vnd.api+json', 'Content-Type': 'application/vnd.api+json' } }); + + this.axios.interceptors.response.use( + response => response, + error => Promise.reject(klaviyoApiError(error)) + ); } // --- Profiles --- @@ -939,34 +947,81 @@ export class KlaviyoClient { // --- Reporting --- - async queryCampaignValues(body: Record): Promise { - let response = await this.axios.post('/campaign-values-reports/', { - data: { - type: 'campaign-values-report', - attributes: body - } - }); + private async queryReport( + path: string, + type: string, + attributes: Record, + params?: { fields?: string[]; pageCursor?: string } + ): Promise { + let query: Record = {}; + if (params?.fields?.length) query[`fields[${type}]`] = params.fields.join(','); + if (params?.pageCursor) query.page_cursor = params.pageCursor; + + let response = await this.axios.post( + path, + { + data: { + type, + attributes + } + }, + { params: query } + ); return response.data; } - async queryFlowValues(body: Record): Promise { - let response = await this.axios.post('/flow-values-reports/', { - data: { - type: 'flow-values-report', - attributes: body - } - }); - return response.data; + async queryCampaignValues( + body: Record, + params?: { fields?: string[]; pageCursor?: string } + ): Promise { + return this.queryReport( + '/campaign-values-reports/', + 'campaign-values-report', + body, + params + ); } - async queryFlowSeries(body: Record): Promise { - let response = await this.axios.post('/flow-series-reports/', { - data: { - type: 'flow-series-report', - attributes: body - } - }); - return response.data; + async queryFlowValues( + body: Record, + params?: { fields?: string[]; pageCursor?: string } + ): Promise { + return this.queryReport('/flow-values-reports/', 'flow-values-report', body, params); + } + + async queryFlowSeries( + body: Record, + params?: { fields?: string[]; pageCursor?: string } + ): Promise { + return this.queryReport('/flow-series-reports/', 'flow-series-report', body, params); + } + + async queryFormValues( + body: Record, + params?: { fields?: string[] } + ): Promise { + return this.queryReport('/form-values-reports/', 'form-values-report', body, params); + } + + async queryFormSeries( + body: Record, + params?: { fields?: string[] } + ): Promise { + return this.queryReport('/form-series-reports/', 'form-series-report', body, params); + } + + async querySegmentValues( + body: Record, + params?: { fields?: string[] } + ): Promise { + return this.queryReport('/segment-values-reports/', 'segment-values-report', body, params); + } + + async querySegmentSeries( + body: Record, + params?: { fields?: string[] } + ): Promise { + return this.queryReport('/segment-series-reports/', 'segment-series-report', body, params); } // --- Data Privacy --- @@ -1000,8 +1055,11 @@ export class KlaviyoClient { // --- Accounts --- - async getAccounts(): Promise { - let response = await this.axios.get('/accounts/'); + async getAccounts(params?: { fields?: string[] }): Promise { + let query: Record = {}; + if (params?.fields?.length) query['fields[account]'] = params.fields.join(','); + + let response = await this.axios.get('/accounts/', { params: query }); return response.data; } @@ -1050,9 +1108,14 @@ export class KlaviyoClient { return response.data; } + async getImage(imageId: string): Promise { + let response = await this.axios.get(`/images/${imageId}/`); + return response.data; + } + async uploadImage(attributes: { name?: string; - import_url: string; + import_from_url: string; hidden?: boolean; }): Promise { let response = await this.axios.post('/images/', { @@ -1063,4 +1126,21 @@ export class KlaviyoClient { }); return response.data; } + + async updateImage( + imageId: string, + attributes: { + name?: string; + hidden?: boolean; + } + ): Promise { + let response = await this.axios.patch(`/images/${imageId}/`, { + data: { + type: 'image', + id: imageId, + attributes + } + }); + return response.data; + } } diff --git a/integrations/klaviyo/src/lib/errors.ts b/integrations/klaviyo/src/lib/errors.ts new file mode 100644 index 0000000000..12ac4d4bc9 --- /dev/null +++ b/integrations/klaviyo/src/lib/errors.ts @@ -0,0 +1,92 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.detail); + addDetail(details, value.title); + addDetail(details, value.message); + addDetail(details, value.code); + addDetail(details, value.status); + collectDetails(value.errors, details); +}; + +let extractKlaviyoMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + if (isRecord(response?.data)) { + collectDetails(response.data.errors, details); + collectDetails(response.data.error, details); + collectDetails(response.data.message, details); + } else { + collectDetails(response?.data, details); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let klaviyoServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let klaviyoApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = klaviyoServiceError( + `Klaviyo API ${operation} failed: ${statusLabelFor(response)}${extractKlaviyoMessage(error)}` + ); + serviceError.data.reason = 'klaviyo_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/klaviyo/src/lib/helpers.ts b/integrations/klaviyo/src/lib/helpers.ts index 212a6d0836..21868c698f 100644 --- a/integrations/klaviyo/src/lib/helpers.ts +++ b/integrations/klaviyo/src/lib/helpers.ts @@ -1,12 +1,23 @@ import { KlaviyoClient } from './client'; export let createClient = (ctx: { - auth: { token: string }; + auth: { + token: string; + authType?: 'oauth' | 'private_api_key'; + refreshToken?: string; + expiresAt?: string; + }; config: { revision?: string }; }): KlaviyoClient => { + let isOAuth = + ctx.auth.authType === 'oauth' || + (ctx.auth.authType === undefined && + (ctx.auth.refreshToken !== undefined || ctx.auth.expiresAt !== undefined)); + return new KlaviyoClient({ token: ctx.auth.token, - revision: ctx.config.revision + revision: ctx.config.revision, + isOAuth }); }; diff --git a/integrations/klaviyo/src/tools.schema.test.ts b/integrations/klaviyo/src/tools.schema.test.ts new file mode 100644 index 0000000000..0de68c6d01 --- /dev/null +++ b/integrations/klaviyo/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Klaviyo tool input schemas', provider.actions); diff --git a/integrations/klaviyo/src/tools/get-account.ts b/integrations/klaviyo/src/tools/get-account.ts new file mode 100644 index 0000000000..e2a36cd3a3 --- /dev/null +++ b/integrations/klaviyo/src/tools/get-account.ts @@ -0,0 +1,64 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getAccount = SlateTool.create(spec, { + name: 'Get Account', + key: 'get_account', + description: `Retrieve the Klaviyo account associated with the current credentials, including account identity, contact information, timezone, currency, and public API key when available. +Use this to verify the connected account before making changes.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + fields: z + .array(z.string()) + .optional() + .describe('Optional Klaviyo account fields to return, e.g. ["contact_information"].') + }) + ) + .output( + z.object({ + accounts: z + .array( + z.object({ + accountId: z.string().describe('Klaviyo account ID'), + name: z.string().optional().describe('Account or organization name'), + publicApiKey: z.string().optional().describe('Public API key / site ID'), + timezone: z.string().optional().describe('Account timezone'), + currency: z.string().optional().describe('Account currency'), + contactInformation: z.any().optional().describe('Account contact information') + }) + ) + .describe('Accounts returned by Klaviyo') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let result = await client.getAccounts({ fields: ctx.input.fields }); + + let accounts = result.data.map(account => ({ + accountId: account.id ?? '', + name: + account.attributes?.contact_information?.organization_name ?? + account.attributes?.organization_name ?? + undefined, + publicApiKey: + account.attributes?.public_api_key ?? + account.attributes?.public_api_key_id ?? + undefined, + timezone: account.attributes?.timezone ?? undefined, + currency: account.attributes?.currency ?? undefined, + contactInformation: account.attributes?.contact_information ?? undefined + })); + + return { + output: { accounts }, + message: `Retrieved **${accounts.length}** Klaviyo account${accounts.length === 1 ? '' : 's'}` + }; + }) + .build(); diff --git a/integrations/klaviyo/src/tools/get-forms.ts b/integrations/klaviyo/src/tools/get-forms.ts new file mode 100644 index 0000000000..bb19179851 --- /dev/null +++ b/integrations/klaviyo/src/tools/get-forms.ts @@ -0,0 +1,107 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient, extractPaginationCursor } from '../lib/helpers'; +import { spec } from '../spec'; + +export let getForms = SlateTool.create(spec, { + name: 'Get Forms', + key: 'get_forms', + description: `Retrieve Klaviyo signup forms. Can fetch a single form by ID or list forms with filtering, sorting, sparse fields, and pagination. +Use this to audit active forms, find form IDs, and verify list-building entry points.`, + instructions: [ + 'Filter syntax examples: `equals(status,"live")`, `contains(name,"Popup")`, `greater-than(updated_at,2026-01-01T00:00:00Z)`.', + 'Supported sorts include `created_at`, `-created_at`, `updated_at`, and `-updated_at`.' + ], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + formId: z + .string() + .optional() + .describe('Specific form ID to retrieve. Omit to list forms.'), + fields: z + .array(z.string()) + .optional() + .describe('Optional Klaviyo form fields to return.'), + filter: z.string().optional().describe('Klaviyo filter string for listing forms.'), + sort: z.string().optional().describe('Sort field for listing forms.'), + pageCursor: z + .string() + .optional() + .describe('Pagination cursor from a previous response.'), + pageSize: z.number().optional().describe('Number of results per page (max 100).') + }) + ) + .output( + z.object({ + forms: z + .array( + z.object({ + formId: z.string().describe('Form ID'), + name: z.string().optional().describe('Form name'), + status: z.string().optional().describe('Form status'), + type: z.string().optional().describe('Form type'), + abTest: z.boolean().optional().describe('Whether the form has an A/B test'), + createdAt: z.string().optional().describe('Creation timestamp'), + updatedAt: z.string().optional().describe('Last updated timestamp') + }) + ) + .describe('Forms returned by Klaviyo'), + nextCursor: z.string().optional().describe('Cursor for fetching the next page'), + hasMore: z.boolean().describe('Whether more results are available') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + + if (ctx.input.formId) { + let result = await client.getForm(ctx.input.formId); + let form = Array.isArray(result.data) ? result.data[0] : result.data; + return { + output: { + forms: [ + { + formId: form?.id ?? '', + name: form?.attributes?.name ?? undefined, + status: form?.attributes?.status ?? undefined, + type: form?.attributes?.form_type ?? form?.attributes?.type ?? undefined, + abTest: form?.attributes?.ab_test ?? undefined, + createdAt: + form?.attributes?.created_at ?? form?.attributes?.created ?? undefined, + updatedAt: form?.attributes?.updated_at ?? form?.attributes?.updated ?? undefined + } + ], + hasMore: false + }, + message: `Retrieved form **${form?.attributes?.name ?? ctx.input.formId}**` + }; + } + + let result = await client.getForms({ + fields: ctx.input.fields, + filter: ctx.input.filter, + sort: ctx.input.sort, + pageCursor: ctx.input.pageCursor, + pageSize: ctx.input.pageSize + }); + let forms = result.data.map(form => ({ + formId: form.id ?? '', + name: form.attributes?.name ?? undefined, + status: form.attributes?.status ?? undefined, + type: form.attributes?.form_type ?? form.attributes?.type ?? undefined, + abTest: form.attributes?.ab_test ?? undefined, + createdAt: form.attributes?.created_at ?? form.attributes?.created ?? undefined, + updatedAt: form.attributes?.updated_at ?? form.attributes?.updated ?? undefined + })); + let nextCursor = extractPaginationCursor(result.links); + + return { + output: { forms, nextCursor, hasMore: !!nextCursor }, + message: `Retrieved **${forms.length}** forms${nextCursor ? ' — more results available' : ''}` + }; + }) + .build(); diff --git a/integrations/klaviyo/src/tools/get-list-segment-profiles.ts b/integrations/klaviyo/src/tools/get-list-segment-profiles.ts index d8f6029eb9..c448ea7d02 100644 --- a/integrations/klaviyo/src/tools/get-list-segment-profiles.ts +++ b/integrations/klaviyo/src/tools/get-list-segment-profiles.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient, extractPaginationCursor } from '../lib/helpers'; import { spec } from '../spec'; @@ -49,7 +50,7 @@ Use this to see who is in a particular audience.`, let client = createClient(ctx); if (!ctx.input.listId && !ctx.input.segmentId) { - throw new Error('Either listId or segmentId must be provided'); + throw klaviyoServiceError('Either listId or segmentId must be provided'); } let result: any; diff --git a/integrations/klaviyo/src/tools/index.ts b/integrations/klaviyo/src/tools/index.ts index 4af1f37352..0144a6cfac 100644 --- a/integrations/klaviyo/src/tools/index.ts +++ b/integrations/klaviyo/src/tools/index.ts @@ -1,6 +1,8 @@ export * from './create-update-profile'; +export * from './get-account'; export * from './get-events'; export * from './get-flows'; +export * from './get-forms'; export * from './get-list-segment-profiles'; export * from './get-metrics'; export * from './get-profiles'; @@ -8,11 +10,13 @@ export * from './get-segments'; export * from './manage-campaigns'; export * from './manage-catalog-items'; export * from './manage-coupons'; +export * from './manage-images'; export * from './manage-lists'; export * from './manage-subscriptions'; export * from './manage-tags'; export * from './manage-templates'; export * from './query-metric-aggregates'; +export * from './query-reports'; export * from './request-profile-deletion'; export * from './track-event'; export * from './update-flow-status'; diff --git a/integrations/klaviyo/src/tools/manage-campaigns.ts b/integrations/klaviyo/src/tools/manage-campaigns.ts index f6ee5a4cbb..8bbc7d4855 100644 --- a/integrations/klaviyo/src/tools/manage-campaigns.ts +++ b/integrations/klaviyo/src/tools/manage-campaigns.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient, extractPaginationCursor } from '../lib/helpers'; import { spec } from '../spec'; @@ -115,7 +116,7 @@ Campaigns target lists and/or segments with marketing messages. Use this to mana } if (action === 'get') { - if (!campaignId) throw new Error('campaignId is required'); + if (!campaignId) throw klaviyoServiceError('campaignId is required'); let result = await client.getCampaign(campaignId); let c = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -141,7 +142,7 @@ Campaigns target lists and/or segments with marketing messages. Use this to mana } if (action === 'create') { - if (!name) throw new Error('name is required for create'); + if (!name) throw klaviyoServiceError('name is required for create'); let attributes: Record = { name }; if (channel) attributes.channel = channel; if (audiences) { @@ -163,7 +164,7 @@ Campaigns target lists and/or segments with marketing messages. Use this to mana } if (action === 'update') { - if (!campaignId) throw new Error('campaignId is required'); + if (!campaignId) throw klaviyoServiceError('campaignId is required'); let attributes: Record = {}; if (name) attributes.name = name; if (audiences) { @@ -182,7 +183,7 @@ Campaigns target lists and/or segments with marketing messages. Use this to mana } if (action === 'delete') { - if (!campaignId) throw new Error('campaignId is required'); + if (!campaignId) throw klaviyoServiceError('campaignId is required'); await client.deleteCampaign(campaignId); return { output: { campaignId, success: true }, @@ -191,7 +192,7 @@ Campaigns target lists and/or segments with marketing messages. Use this to mana } if (action === 'send') { - if (!campaignId) throw new Error('campaignId is required'); + if (!campaignId) throw klaviyoServiceError('campaignId is required'); await client.sendCampaign(campaignId); return { output: { campaignId, success: true }, @@ -199,6 +200,6 @@ Campaigns target lists and/or segments with marketing messages. Use this to mana }; } - throw new Error(`Unknown action: ${action}`); + throw klaviyoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/klaviyo/src/tools/manage-catalog-items.ts b/integrations/klaviyo/src/tools/manage-catalog-items.ts index 7b3542b4e8..e849f6fa9c 100644 --- a/integrations/klaviyo/src/tools/manage-catalog-items.ts +++ b/integrations/klaviyo/src/tools/manage-catalog-items.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient, extractPaginationCursor } from '../lib/helpers'; import { spec } from '../spec'; @@ -121,7 +122,7 @@ Also supports listing item variants and browsing categories.`, } if (action === 'get') { - if (!itemId) throw new Error('itemId is required'); + if (!itemId) throw klaviyoServiceError('itemId is required'); let result = await client.getCatalogItem(itemId); let i = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -169,7 +170,7 @@ Also supports listing item variants and browsing categories.`, } if (action === 'update') { - if (!itemId) throw new Error('itemId is required'); + if (!itemId) throw klaviyoServiceError('itemId is required'); let attributes: Record = {}; if (ctx.input.title) attributes.title = ctx.input.title; if (ctx.input.description) attributes.description = ctx.input.description; @@ -186,7 +187,7 @@ Also supports listing item variants and browsing categories.`, } if (action === 'delete') { - if (!itemId) throw new Error('itemId is required'); + if (!itemId) throw klaviyoServiceError('itemId is required'); await client.deleteCatalogItem(itemId); return { output: { success: true }, @@ -195,7 +196,7 @@ Also supports listing item variants and browsing categories.`, } if (action === 'list_variants') { - if (!itemId) throw new Error('itemId is required'); + if (!itemId) throw klaviyoServiceError('itemId is required'); let result = await client.getCatalogVariants(itemId, { pageCursor, pageSize }); let variants = result.data.map(v => ({ variantId: v.id ?? '', @@ -224,6 +225,6 @@ Also supports listing item variants and browsing categories.`, }; } - throw new Error(`Unknown action: ${action}`); + throw klaviyoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/klaviyo/src/tools/manage-coupons.ts b/integrations/klaviyo/src/tools/manage-coupons.ts index cde59b455f..f8267950d8 100644 --- a/integrations/klaviyo/src/tools/manage-coupons.ts +++ b/integrations/klaviyo/src/tools/manage-coupons.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient, extractPaginationCursor } from '../lib/helpers'; import { spec } from '../spec'; @@ -68,7 +69,7 @@ export let manageCoupons = SlateTool.create(spec, { } if (action === 'create') { - if (!externalId) throw new Error('externalId is required'); + if (!externalId) throw klaviyoServiceError('externalId is required'); let result = await client.createCoupon({ external_id: externalId, description }); let c = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -78,8 +79,8 @@ export let manageCoupons = SlateTool.create(spec, { } if (action === 'create_codes') { - if (!couponId) throw new Error('couponId is required'); - if (!codes?.length) throw new Error('codes are required'); + if (!couponId) throw klaviyoServiceError('couponId is required'); + if (!codes?.length) throw klaviyoServiceError('codes are required'); await client.createCouponCodes(couponId, codes); return { output: { couponId, success: true }, @@ -87,6 +88,6 @@ export let manageCoupons = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw klaviyoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/klaviyo/src/tools/manage-images.ts b/integrations/klaviyo/src/tools/manage-images.ts new file mode 100644 index 0000000000..adfde80879 --- /dev/null +++ b/integrations/klaviyo/src/tools/manage-images.ts @@ -0,0 +1,142 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; +import { createClient, extractPaginationCursor } from '../lib/helpers'; +import { spec } from '../spec'; + +let formatImage = (image: { id?: string; attributes?: Record }) => ({ + imageId: image.id ?? '', + name: image.attributes?.name ?? undefined, + imageUrl: + image.attributes?.image_url ?? + image.attributes?.image_full_url ?? + image.attributes?.url ?? + undefined, + format: image.attributes?.format ?? undefined, + size: image.attributes?.size ?? undefined, + hidden: image.attributes?.hidden ?? undefined, + updatedAt: image.attributes?.updated_at ?? image.attributes?.updated ?? undefined +}); + +export let manageImages = SlateTool.create(spec, { + name: 'Manage Images', + key: 'manage_images', + description: `List, retrieve, upload from URL or data URI, and update Klaviyo images used in templates and campaigns. +Use upload to import a hosted image or data URI into Klaviyo's image library.`, + instructions: [ + 'Use action "upload" with importUrl set to an HTTPS image URL or data URI.', + 'Use action "update" to rename an image or toggle whether it is hidden in the Klaviyo library.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + action: z.enum(['list', 'get', 'upload', 'update']).describe('Action to perform'), + imageId: z.string().optional().describe('Image ID (required for get and update).'), + name: z.string().optional().describe('Image name (for upload or update).'), + importUrl: z + .string() + .optional() + .describe('HTTPS URL or data URI to import (required for upload).'), + hidden: z.boolean().optional().describe('Whether the image should be hidden.'), + filter: z.string().optional().describe('Klaviyo filter string for listing images.'), + sort: z.string().optional().describe('Sort field for listing images.'), + pageCursor: z + .string() + .optional() + .describe('Pagination cursor from a previous response.'), + pageSize: z.number().optional().describe('Number of results per page (max 100).') + }) + ) + .output( + z.object({ + images: z + .array( + z.object({ + imageId: z.string().describe('Image ID'), + name: z.string().optional().describe('Image name'), + imageUrl: z.string().optional().describe('Klaviyo-hosted image URL'), + format: z.string().optional().describe('Image format'), + size: z.number().optional().describe('Image size in bytes'), + hidden: z.boolean().optional().describe('Whether the image is hidden'), + updatedAt: z.string().optional().describe('Last updated timestamp') + }) + ) + .optional() + .describe('Images returned by Klaviyo'), + imageId: z.string().optional().describe('ID of the targeted image'), + success: z.boolean().describe('Whether the operation succeeded'), + nextCursor: z.string().optional().describe('Cursor for fetching the next page'), + hasMore: z.boolean().optional().describe('Whether more results are available') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let { action, imageId, name, importUrl, hidden, filter, sort, pageCursor, pageSize } = + ctx.input; + + if (action === 'list') { + let result = await client.getImages({ filter, sort, pageCursor, pageSize }); + let images = result.data.map(formatImage); + let nextCursor = extractPaginationCursor(result.links); + return { + output: { images, success: true, nextCursor, hasMore: !!nextCursor }, + message: `Retrieved **${images.length}** images${nextCursor ? ' — more results available' : ''}` + }; + } + + if (action === 'get') { + if (!imageId) throw klaviyoServiceError('imageId is required for get'); + let result = await client.getImage(imageId); + let image = Array.isArray(result.data) ? result.data[0] : result.data; + return { + output: { + images: [formatImage(image ?? {})], + imageId: image?.id ?? imageId, + success: true + }, + message: `Retrieved image **${image?.attributes?.name ?? imageId}**` + }; + } + + if (action === 'upload') { + if (!importUrl) throw klaviyoServiceError('importUrl is required for upload'); + let result = await client.uploadImage({ + import_from_url: importUrl, + name, + hidden + }); + let image = Array.isArray(result.data) ? result.data[0] : result.data; + return { + output: { + images: [formatImage(image ?? {})], + imageId: image?.id, + success: true + }, + message: `Uploaded image **${image?.attributes?.name ?? name ?? image?.id ?? 'image'}**` + }; + } + + if (action === 'update') { + if (!imageId) throw klaviyoServiceError('imageId is required for update'); + if (name === undefined && hidden === undefined) { + throw klaviyoServiceError('Provide name or hidden to update an image'); + } + let result = await client.updateImage(imageId, { name, hidden }); + let image = Array.isArray(result.data) ? result.data[0] : result.data; + return { + output: { + images: [formatImage(image ?? {})], + imageId: image?.id ?? imageId, + success: true + }, + message: `Updated image **${imageId}**` + }; + } + + throw klaviyoServiceError(`Unknown action: ${action}`); + }) + .build(); diff --git a/integrations/klaviyo/src/tools/manage-lists.ts b/integrations/klaviyo/src/tools/manage-lists.ts index f377f274bd..4cd9e0aa03 100644 --- a/integrations/klaviyo/src/tools/manage-lists.ts +++ b/integrations/klaviyo/src/tools/manage-lists.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient, extractPaginationCursor } from '../lib/helpers'; import { spec } from '../spec'; @@ -77,7 +78,7 @@ Lists are static collections of profiles used for campaign targeting.`, } if (action === 'get') { - if (!listId) throw new Error('listId is required'); + if (!listId) throw klaviyoServiceError('listId is required'); let result = await client.getList(listId); let l = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -98,7 +99,7 @@ Lists are static collections of profiles used for campaign targeting.`, } if (action === 'create') { - if (!name) throw new Error('name is required for create'); + if (!name) throw klaviyoServiceError('name is required for create'); let result = await client.createList(name); let l = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -108,8 +109,8 @@ Lists are static collections of profiles used for campaign targeting.`, } if (action === 'update') { - if (!listId) throw new Error('listId is required'); - if (!name) throw new Error('name is required for update'); + if (!listId) throw klaviyoServiceError('listId is required'); + if (!name) throw klaviyoServiceError('name is required for update'); let result = await client.updateList(listId, name); let l = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -119,7 +120,7 @@ Lists are static collections of profiles used for campaign targeting.`, } if (action === 'delete') { - if (!listId) throw new Error('listId is required'); + if (!listId) throw klaviyoServiceError('listId is required'); await client.deleteList(listId); return { output: { listId, success: true }, @@ -128,8 +129,8 @@ Lists are static collections of profiles used for campaign targeting.`, } if (action === 'add_profiles') { - if (!listId) throw new Error('listId is required'); - if (!profileIds?.length) throw new Error('profileIds are required'); + if (!listId) throw klaviyoServiceError('listId is required'); + if (!profileIds?.length) throw klaviyoServiceError('profileIds are required'); await client.addProfilesToList(listId, profileIds); return { output: { listId, success: true }, @@ -138,8 +139,8 @@ Lists are static collections of profiles used for campaign targeting.`, } if (action === 'remove_profiles') { - if (!listId) throw new Error('listId is required'); - if (!profileIds?.length) throw new Error('profileIds are required'); + if (!listId) throw klaviyoServiceError('listId is required'); + if (!profileIds?.length) throw klaviyoServiceError('profileIds are required'); await client.removeProfilesFromList(listId, profileIds); return { output: { listId, success: true }, @@ -147,6 +148,6 @@ Lists are static collections of profiles used for campaign targeting.`, }; } - throw new Error(`Unknown action: ${action}`); + throw klaviyoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/klaviyo/src/tools/manage-subscriptions.ts b/integrations/klaviyo/src/tools/manage-subscriptions.ts index 8a8895bcbe..3f5db611c5 100644 --- a/integrations/klaviyo/src/tools/manage-subscriptions.ts +++ b/integrations/klaviyo/src/tools/manage-subscriptions.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -52,7 +53,7 @@ Use this tool to manage consent and marketing opt-in/opt-out.`, let { action, listId, profiles, channels } = ctx.input; if ((action === 'subscribe' || action === 'unsubscribe') && !listId) { - throw new Error('listId is required for subscribe/unsubscribe actions'); + throw klaviyoServiceError('listId is required for subscribe/unsubscribe actions'); } if (action === 'subscribe') { @@ -83,11 +84,13 @@ Use this tool to manage consent and marketing opt-in/opt-out.`, ); } else if (action === 'suppress') { let ids = profiles.map(p => p.profileId).filter((id): id is string => !!id); - if (ids.length === 0) throw new Error('Profile IDs are required for suppression'); + if (ids.length === 0) + throw klaviyoServiceError('Profile IDs are required for suppression'); await client.suppressProfiles(ids); } else if (action === 'unsuppress') { let ids = profiles.map(p => p.profileId).filter((id): id is string => !!id); - if (ids.length === 0) throw new Error('Profile IDs are required for unsuppression'); + if (ids.length === 0) + throw klaviyoServiceError('Profile IDs are required for unsuppression'); await client.unsuppressProfiles(ids); } diff --git a/integrations/klaviyo/src/tools/manage-tags.ts b/integrations/klaviyo/src/tools/manage-tags.ts index 18c2193cfc..aceed2d684 100644 --- a/integrations/klaviyo/src/tools/manage-tags.ts +++ b/integrations/klaviyo/src/tools/manage-tags.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient, extractPaginationCursor } from '../lib/helpers'; import { spec } from '../spec'; @@ -88,8 +89,8 @@ export let manageTags = SlateTool.create(spec, { } if (action === 'create_tag') { - if (!name) throw new Error('name is required'); - if (!tagGroupId) throw new Error('tagGroupId is required for creating a tag'); + if (!name) throw klaviyoServiceError('name is required'); + if (!tagGroupId) throw klaviyoServiceError('tagGroupId is required for creating a tag'); let result = await client.createTag(name, tagGroupId); let t = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -99,8 +100,8 @@ export let manageTags = SlateTool.create(spec, { } if (action === 'update_tag') { - if (!tagId) throw new Error('tagId is required'); - if (!name) throw new Error('name is required'); + if (!tagId) throw klaviyoServiceError('tagId is required'); + if (!name) throw klaviyoServiceError('name is required'); let result = await client.updateTag(tagId, name); let t = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -110,7 +111,7 @@ export let manageTags = SlateTool.create(spec, { } if (action === 'delete_tag') { - if (!tagId) throw new Error('tagId is required'); + if (!tagId) throw klaviyoServiceError('tagId is required'); await client.deleteTag(tagId); return { output: { tagId, success: true }, @@ -133,7 +134,7 @@ export let manageTags = SlateTool.create(spec, { } if (action === 'create_tag_group') { - if (!name) throw new Error('name is required'); + if (!name) throw klaviyoServiceError('name is required'); let result = await client.createTagGroup(name, exclusive); let g = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -142,6 +143,6 @@ export let manageTags = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw klaviyoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/klaviyo/src/tools/manage-templates.ts b/integrations/klaviyo/src/tools/manage-templates.ts index 3e586016c9..723a480356 100644 --- a/integrations/klaviyo/src/tools/manage-templates.ts +++ b/integrations/klaviyo/src/tools/manage-templates.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient, extractPaginationCursor } from '../lib/helpers'; import { spec } from '../spec'; @@ -93,7 +94,7 @@ Templates are used in campaigns and flows for composing email content. Supports } if (action === 'get') { - if (!templateId) throw new Error('templateId is required'); + if (!templateId) throw klaviyoServiceError('templateId is required'); let result = await client.getTemplate(templateId); let t = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -116,7 +117,7 @@ Templates are used in campaigns and flows for composing email content. Supports } if (action === 'create') { - if (!name) throw new Error('name is required for create'); + if (!name) throw klaviyoServiceError('name is required for create'); let result = await client.createTemplate({ name, html: html ?? '', @@ -130,7 +131,7 @@ Templates are used in campaigns and flows for composing email content. Supports } if (action === 'update') { - if (!templateId) throw new Error('templateId is required'); + if (!templateId) throw klaviyoServiceError('templateId is required'); let attributes: Record = {}; if (name) attributes.name = name; if (html) attributes.html = html; @@ -142,7 +143,7 @@ Templates are used in campaigns and flows for composing email content. Supports } if (action === 'delete') { - if (!templateId) throw new Error('templateId is required'); + if (!templateId) throw klaviyoServiceError('templateId is required'); await client.deleteTemplate(templateId); return { output: { templateId, success: true }, @@ -151,8 +152,8 @@ Templates are used in campaigns and flows for composing email content. Supports } if (action === 'clone') { - if (!templateId) throw new Error('templateId is required'); - if (!name) throw new Error('name is required for clone'); + if (!templateId) throw klaviyoServiceError('templateId is required'); + if (!name) throw klaviyoServiceError('name is required for clone'); let result = await client.cloneTemplate(templateId, name); let t = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -162,7 +163,7 @@ Templates are used in campaigns and flows for composing email content. Supports } if (action === 'render') { - if (!templateId) throw new Error('templateId is required'); + if (!templateId) throw klaviyoServiceError('templateId is required'); let result = await client.renderTemplate(templateId, renderContext); let rendered = Array.isArray(result.data) ? result.data[0] : result.data; return { @@ -176,6 +177,6 @@ Templates are used in campaigns and flows for composing email content. Supports }; } - throw new Error(`Unknown action: ${action}`); + throw klaviyoServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/klaviyo/src/tools/query-reports.ts b/integrations/klaviyo/src/tools/query-reports.ts new file mode 100644 index 0000000000..4f4adf72e4 --- /dev/null +++ b/integrations/klaviyo/src/tools/query-reports.ts @@ -0,0 +1,189 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; +import { createClient, extractPaginationCursor } from '../lib/helpers'; +import { spec } from '../spec'; + +let timeframeKeys = [ + 'today', + 'yesterday', + 'last_7_days', + 'last_30_days', + 'last_90_days', + 'last_12_months', + 'last_365_days', + 'last_3_months', + 'last_week', + 'last_month', + 'last_year', + 'this_week', + 'this_month', + 'this_year' +] as const; + +let seriesReports = ['flow_series', 'form_series', 'segment_series']; +let conversionMetricReports = ['campaign_values', 'flow_values', 'flow_series']; + +let reportLabels: Record = { + campaign_values: 'campaign values', + flow_values: 'flow values', + flow_series: 'flow series', + form_values: 'form values', + form_series: 'form series', + segment_values: 'segment values', + segment_series: 'segment series' +}; + +let buildTimeframe = (input: { + timeframeKey?: (typeof timeframeKeys)[number]; + timeframeStart?: string; + timeframeEnd?: string; +}) => { + if (input.timeframeKey) { + return { key: input.timeframeKey }; + } + + if (input.timeframeStart && input.timeframeEnd) { + return { start: input.timeframeStart, end: input.timeframeEnd }; + } + + throw klaviyoServiceError('Provide timeframeKey or both timeframeStart and timeframeEnd.'); +}; + +export let queryReports = SlateTool.create(spec, { + name: 'Query Reports', + key: 'query_reports', + description: `Query Klaviyo Reporting API values and time series for campaigns, flows, forms, and segments. +Use this for performance reporting that is scoped to Klaviyo marketing assets rather than raw metric aggregates.`, + instructions: [ + 'Use *_values reports for total values over a timeframe.', + 'Use *_series reports when you need hourly, daily, weekly, or monthly buckets.', + 'Campaign and flow reports require conversionMetricId for conversion statistics.', + 'Use filter with Klaviyo reporting syntax, e.g. `equals(form_id,"FORM_ID")` or `contains-any(send_channel,["email"])`.' + ], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + report: z + .enum([ + 'campaign_values', + 'flow_values', + 'flow_series', + 'form_values', + 'form_series', + 'segment_values', + 'segment_series' + ]) + .describe('Reporting endpoint to query.'), + statistics: z + .array(z.string()) + .describe('Statistics to return, e.g. ["opens", "open_rate"] or ["viewed_form"].'), + timeframeKey: z + .enum(timeframeKeys) + .optional() + .describe('Preset reporting timeframe. Omit when using custom start/end.'), + timeframeStart: z + .string() + .optional() + .describe('Custom reporting timeframe start, ISO 8601. Requires timeframeEnd.'), + timeframeEnd: z + .string() + .optional() + .describe('Custom reporting timeframe end, ISO 8601. Requires timeframeStart.'), + interval: z + .enum(['hourly', 'daily', 'weekly', 'monthly']) + .optional() + .describe('Required for *_series reports.'), + conversionMetricId: z + .string() + .optional() + .describe('Conversion metric ID required by campaign and flow reports.'), + groupBy: z.array(z.string()).optional().describe('Reporting group_by fields.'), + filter: z.string().optional().describe('Klaviyo Reporting API filter string.'), + fields: z + .array(z.string()) + .optional() + .describe('Sparse report fields such as ["results"] or ["results","date_times"].'), + pageCursor: z + .string() + .optional() + .describe('Pagination cursor for campaign and flow report endpoints.') + }) + ) + .output( + z.object({ + report: z.any().describe('Raw Klaviyo report response'), + results: z.any().optional().describe('Report result rows'), + dateTimes: z + .array(z.string()) + .optional() + .describe('Series timestamps returned by *_series reports'), + nextCursor: z.string().optional().describe('Cursor for the next page'), + hasMore: z.boolean().optional().describe('Whether more report rows are available') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let input = ctx.input; + + if (seriesReports.includes(input.report) && !input.interval) { + throw klaviyoServiceError('interval is required for *_series reports.'); + } + + if (conversionMetricReports.includes(input.report) && !input.conversionMetricId) { + throw klaviyoServiceError( + 'conversionMetricId is required for campaign and flow reports.' + ); + } + + let attributes: Record = { + statistics: input.statistics, + timeframe: buildTimeframe(input) + }; + + if (input.interval) attributes.interval = input.interval; + if (input.conversionMetricId) attributes.conversion_metric_id = input.conversionMetricId; + if (input.groupBy?.length) attributes.group_by = input.groupBy; + if (input.filter) attributes.filter = input.filter; + + let params = { + fields: input.fields, + pageCursor: input.pageCursor + }; + + let report: any; + if (input.report === 'campaign_values') { + report = await client.queryCampaignValues(attributes, params); + } else if (input.report === 'flow_values') { + report = await client.queryFlowValues(attributes, params); + } else if (input.report === 'flow_series') { + report = await client.queryFlowSeries(attributes, params); + } else if (input.report === 'form_values') { + report = await client.queryFormValues(attributes, { fields: input.fields }); + } else if (input.report === 'form_series') { + report = await client.queryFormSeries(attributes, { fields: input.fields }); + } else if (input.report === 'segment_values') { + report = await client.querySegmentValues(attributes, { fields: input.fields }); + } else { + report = await client.querySegmentSeries(attributes, { fields: input.fields }); + } + + let data = Array.isArray(report.data) ? report.data[0] : report.data; + let nextCursor = extractPaginationCursor(report.links); + + return { + output: { + report, + results: data?.attributes?.results, + dateTimes: data?.attributes?.date_times, + nextCursor, + hasMore: !!nextCursor + }, + message: `Queried Klaviyo ${reportLabels[input.report]} report` + }; + }) + .build(); diff --git a/integrations/klaviyo/src/tools/request-profile-deletion.ts b/integrations/klaviyo/src/tools/request-profile-deletion.ts index 88fa232f00..eeb2d2f17f 100644 --- a/integrations/klaviyo/src/tools/request-profile-deletion.ts +++ b/integrations/klaviyo/src/tools/request-profile-deletion.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -33,7 +34,7 @@ The profile and all associated data will be permanently deleted.`, let client = createClient(ctx); if (!ctx.input.profileId && !ctx.input.email && !ctx.input.phoneNumber) { - throw new Error( + throw klaviyoServiceError( 'At least one identifier (profileId, email, or phoneNumber) is required' ); } diff --git a/integrations/klaviyo/src/tools/track-event.ts b/integrations/klaviyo/src/tools/track-event.ts index e20dc71e49..a520d83c6c 100644 --- a/integrations/klaviyo/src/tools/track-event.ts +++ b/integrations/klaviyo/src/tools/track-event.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { klaviyoServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -45,6 +46,12 @@ Common use cases: tracking purchases, form submissions, quiz completions, passwo .handleInvocation(async ctx => { let client = createClient(ctx); + if (!ctx.input.profileId && !ctx.input.email && !ctx.input.phoneNumber) { + throw klaviyoServiceError( + 'Provide profileId, email, or phoneNumber so Klaviyo can associate the event with a profile.' + ); + } + let profileData: Record = { type: 'profile' }; if (ctx.input.profileId) { profileData.id = ctx.input.profileId; diff --git a/integrations/klaviyo/src/triggers/new-events.ts b/integrations/klaviyo/src/triggers/new-events.ts index 8a1d0002a7..064886a5dc 100644 --- a/integrations/klaviyo/src/triggers/new-events.ts +++ b/integrations/klaviyo/src/triggers/new-events.ts @@ -1,6 +1,6 @@ import { SlateDefaultPollingIntervalSeconds, SlateTrigger } from 'slates'; import { z } from 'zod'; -import { KlaviyoClient } from '../lib/client'; +import { createClient } from '../lib/helpers'; import { spec } from '../spec'; export let newEvents = SlateTrigger.create(spec, { @@ -37,10 +37,7 @@ export let newEvents = SlateTrigger.create(spec, { }, pollEvents: async ctx => { - let client = new KlaviyoClient({ - token: ctx.auth.token, - revision: ctx.config.revision - }); + let client = createClient(ctx); let state = ctx.state as { lastTimestamp?: string; lastSeenIds?: string[] } | undefined; let lastTimestamp = state?.lastTimestamp; diff --git a/integrations/klaviyo/src/triggers/new-profiles.ts b/integrations/klaviyo/src/triggers/new-profiles.ts index c3cabf4f06..6f6e1964f1 100644 --- a/integrations/klaviyo/src/triggers/new-profiles.ts +++ b/integrations/klaviyo/src/triggers/new-profiles.ts @@ -1,6 +1,6 @@ import { SlateDefaultPollingIntervalSeconds, SlateTrigger } from 'slates'; import { z } from 'zod'; -import { KlaviyoClient } from '../lib/client'; +import { createClient } from '../lib/helpers'; import { spec } from '../spec'; export let newProfiles = SlateTrigger.create(spec, { @@ -39,10 +39,7 @@ export let newProfiles = SlateTrigger.create(spec, { }, pollEvents: async ctx => { - let client = new KlaviyoClient({ - token: ctx.auth.token, - revision: ctx.config.revision - }); + let client = createClient(ctx); let state = ctx.state as { lastTimestamp?: string; lastSeenIds?: string[] } | undefined; let lastTimestamp = state?.lastTimestamp; @@ -87,10 +84,7 @@ export let newProfiles = SlateTrigger.create(spec, { }, handleEvent: async ctx => { - let client = new KlaviyoClient({ - token: ctx.auth.token, - revision: ctx.config.revision - }); + let client = createClient(ctx); // Fetch full profile data for enriched output let profile: any = null; diff --git a/integrations/klaviyo/src/triggers/webhook-events.ts b/integrations/klaviyo/src/triggers/webhook-events.ts index 1e918b4548..1ad0169421 100644 --- a/integrations/klaviyo/src/triggers/webhook-events.ts +++ b/integrations/klaviyo/src/triggers/webhook-events.ts @@ -1,6 +1,6 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; -import { KlaviyoClient } from '../lib/client'; +import { createClient } from '../lib/helpers'; import { spec } from '../spec'; let generateSecretKey = (): string => { @@ -57,10 +57,7 @@ export let webhookEvents = SlateTrigger.create(spec, { ) .webhook({ autoRegisterWebhook: async ctx => { - let client = new KlaviyoClient({ - token: ctx.auth.token, - revision: ctx.config.revision - }); + let client = createClient(ctx); let secretKey = generateSecretKey(); @@ -105,10 +102,7 @@ export let webhookEvents = SlateTrigger.create(spec, { }, autoUnregisterWebhook: async ctx => { - let client = new KlaviyoClient({ - token: ctx.auth.token, - revision: ctx.config.revision - }); + let client = createClient(ctx); let details = ctx.input.registrationDetails as { webhookId?: string }; if (details?.webhookId) { diff --git a/integrations/klaviyo/vitest.config.ts b/integrations/klaviyo/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/klaviyo/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/kubernetes/README.md b/integrations/kubernetes/README.md index 535a6185d6..d09cbf6c2d 100644 --- a/integrations/kubernetes/README.md +++ b/integrations/kubernetes/README.md @@ -1,12 +1,12 @@ # Kubernetes -Manage and orchestrate containerized applications on Kubernetes clusters. Create, update, scale, and delete workloads including Pods, Deployments, StatefulSets, DaemonSets, Jobs, and CronJobs. Configure service discovery and load balancing through Services, Ingress, and Endpoints. Manage configuration and storage with ConfigMaps, Secrets, PersistentVolumes, and StorageClasses. Control access with Namespaces, RBAC roles and bindings, ServiceAccounts, and NetworkPolicies. Administer cluster infrastructure including Nodes, ResourceQuotas, LimitRanges, and PriorityClasses. Autoscale workloads with HorizontalPodAutoscaler and VerticalPodAutoscaler. Define and manage Custom Resource Definitions (CRDs) to extend cluster functionality. Watch resources in real time for create, modify, and delete events. Configure admission webhooks to validate or mutate resources before persistence. +Manage and orchestrate containerized applications on Kubernetes clusters. Create, update, scale, and delete workloads including Pods, Deployments, StatefulSets, DaemonSets, ReplicaSets, Jobs, and CronJobs. Configure service discovery and load balancing through Services, Ingress, Endpoints, and EndpointSlices. Manage configuration and storage with ConfigMaps, Secrets, PersistentVolumes, PersistentVolumeClaims, and StorageClasses. Control access with Namespaces, RBAC roles and bindings, ServiceAccounts, NetworkPolicies, and PodDisruptionBudgets. Administer cluster infrastructure including Nodes, ResourceQuotas, LimitRanges, PriorityClasses, APIService registrations, and certificate signing requests. Autoscale workloads with HorizontalPodAutoscaler. Define and manage Custom Resource Definitions (CRDs) to extend cluster functionality. Poll stable Kubernetes Events for operational changes. Configure admission webhooks and admission policies to validate or mutate resources before persistence. ## Tools ### Apply Resource -Apply a Kubernetes resource manifest (similar to \ +Apply a Kubernetes resource manifest (similar to `kubectl apply`). Creates the resource if it does not exist, or updates it if it does. ### Cluster Info @@ -35,10 +35,12 @@ Create, update, or get the status of a HorizontalPodAutoscaler (HPA). HPAs autom ### Manage ConfigMap or Secret Create or update Kubernetes ConfigMaps and Secrets. Supports setting key-value data directly, or providing a full manifest. For secrets, values should be provided as plain text — they will be base64-encoded automatically. +Updates can remove specific data keys with `deleteKeys`. ### Manage Deployment Create, update, scale, or restart a Kubernetes Deployment. Combine multiple operations in one call — for example, update the image and scale replicas simultaneously. Also supports StatefulSets and DaemonSets for similar workload management. +Scale operations are available for Deployments, StatefulSets, and ReplicaSets. DaemonSets can be updated or restarted, but do not expose a scale subresource. ### Manage Job diff --git a/integrations/kubernetes/docs/SPEC.md b/integrations/kubernetes/docs/SPEC.md index 8e697a9342..5ff63c8544 100644 --- a/integrations/kubernetes/docs/SPEC.md +++ b/integrations/kubernetes/docs/SPEC.md @@ -91,7 +91,6 @@ Manage the cluster infrastructure itself: Scale workloads dynamically based on metrics: - **HorizontalPodAutoscaler** — adjusts the number of pod replicas based on CPU, memory, or custom metrics -- **VerticalPodAutoscaler** (if installed) — adjusts resource requests/limits for containers ### Custom Resource Definitions (CRDs) diff --git a/integrations/kubernetes/package.json b/integrations/kubernetes/package.json index 91475f7bfd..609581136c 100644 --- a/integrations/kubernetes/package.json +++ b/integrations/kubernetes/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/kubernetes/src/auth.ts b/integrations/kubernetes/src/auth.ts index 7457ad90a3..b66da8fee6 100644 --- a/integrations/kubernetes/src/auth.ts +++ b/integrations/kubernetes/src/auth.ts @@ -1,5 +1,6 @@ import { SlateAuth } from 'slates'; import { z } from 'zod'; +import { kubernetesServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -110,6 +111,9 @@ let parseKubeconfig = ( break; } } + if (!currentContext) { + throw kubernetesServiceError('Kubeconfig must include current-context.'); + } // Find the context entry to get cluster and user names let contextCluster = ''; @@ -140,6 +144,11 @@ let parseKubeconfig = ( if (inTargetContext && contextCluster && contextUser) break; if (inContexts && line.match(/^\s*- name:/) && inTargetContext) break; } + if (!contextCluster || !contextUser) { + throw kubernetesServiceError( + `Kubeconfig current-context "${currentContext}" must reference a cluster and user.` + ); + } // Find user credentials let inUsers = false; @@ -160,11 +169,14 @@ let parseKubeconfig = ( if (inTargetUser) { if (line.includes('client-certificate-data:')) { let b64 = line.split('client-certificate-data:')[1] || ''; - result.clientCertificate = atob(b64.trim()); + result.clientCertificate = decodeKubeconfigBase64( + b64.trim(), + 'client-certificate-data' + ); } if (line.includes('client-key-data:')) { let b64 = line.split('client-key-data:')[1] || ''; - result.clientKey = atob(b64.trim()); + result.clientKey = decodeKubeconfigBase64(b64.trim(), 'client-key-data'); } if (line.includes('token:') && !line.includes('token-file')) { let part = line.split('token:')[1] || ''; @@ -193,7 +205,7 @@ let parseKubeconfig = ( } if (inTargetCluster && line.includes('certificate-authority-data:')) { let b64 = line.split('certificate-authority-data:')[1] || ''; - result.caCertificate = atob(b64.trim()); + result.caCertificate = decodeKubeconfigBase64(b64.trim(), 'certificate-authority-data'); } if ( inClusters && @@ -204,5 +216,23 @@ let parseKubeconfig = ( break; } + if (!result.token && (!result.clientCertificate || !result.clientKey)) { + throw kubernetesServiceError( + `Kubeconfig user "${contextUser}" must include a bearer token or client certificate and key data.` + ); + } + return result; }; + +let decodeKubeconfigBase64 = (value: string, fieldName: string) => { + try { + return atob(value); + } catch (error) { + let serviceError = kubernetesServiceError(`Kubeconfig ${fieldName} is not valid base64.`); + if (error instanceof Error) { + serviceError.setParent(error); + } + throw serviceError; + } +}; diff --git a/integrations/kubernetes/src/lib/client.ts b/integrations/kubernetes/src/lib/client.ts index cc01ff21d3..b0354464cc 100644 --- a/integrations/kubernetes/src/lib/client.ts +++ b/integrations/kubernetes/src/lib/client.ts @@ -1,4 +1,6 @@ +import { Agent as HttpsAgent } from 'node:https'; import { createAxios } from 'slates'; +import { kubernetesApiError, kubernetesServiceError } from './errors'; export interface KubeClientConfig { clusterUrl: string; @@ -66,12 +68,27 @@ export interface KubeEvent { [key: string]: any; }; reason?: string; + note?: string; message?: string; type?: string; + eventTime?: string; firstTimestamp?: string; lastTimestamp?: string; + deprecatedFirstTimestamp?: string; + deprecatedLastTimestamp?: string; + deprecatedCount?: number; + deprecatedSource?: Record; count?: number; source?: Record; + reportingController?: string; + reportingInstance?: string; + regarding?: { + kind?: string; + namespace?: string; + name?: string; + uid?: string; + [key: string]: any; + }; [key: string]: any; } @@ -80,13 +97,14 @@ export let resourceApiPaths: Record Object.keys(resourceApiPaths).join(', '); + +let unknownResourceTypeError = (resourceType: string) => + kubernetesServiceError( + `Unknown resource type: ${resourceType}. Supported types: ${supportedResourceTypes()}` + ); + +let createHttpsAgent = (config: KubeClientConfig) => { + let hasTlsOptions = + !!config.clientCertificate || + !!config.clientKey || + !!config.caCertificate || + config.skipTlsVerify === true; + if (!hasTlsOptions) return undefined; + + if ( + (config.clientCertificate && !config.clientKey) || + (!config.clientCertificate && config.clientKey) + ) { + throw kubernetesServiceError( + 'Kubernetes client certificate authentication requires both clientCertificate and clientKey.' + ); + } + + return new HttpsAgent({ + cert: config.clientCertificate, + key: config.clientKey, + ca: config.caCertificate, + rejectUnauthorized: config.skipTlsVerify === true ? false : undefined + }); +}; + export class KubeClient { private clusterUrl: string; private token: string; @@ -139,10 +211,20 @@ export class KubeClient { this.axios = createAxios({ baseURL: this.clusterUrl, - headers + headers, + httpsAgent: createHttpsAgent(config) }); } + private async request(operation: string, run: () => Promise<{ data: T }>): Promise { + try { + let response = await run(); + return response.data; + } catch (error) { + throw kubernetesApiError(error, operation); + } + } + private buildResourcePath( resourceType: string, namespace?: string, @@ -151,9 +233,7 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error( - `Unknown resource type: ${resourceType}. Supported types: ${Object.keys(resourceApiPaths).join(', ')}` - ); + throw unknownResourceTypeError(resourceType); } let path = info.apiBase; @@ -178,9 +258,7 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error( - `Unknown resource type: ${resourceType}. Supported types: ${Object.keys(resourceApiPaths).join(', ')}` - ); + throw unknownResourceTypeError(resourceType); } let ns = info.namespaced ? this.resolveNamespace(options?.namespace) : undefined; @@ -192,8 +270,7 @@ export class KubeClient { if (options?.limit) params.limit = String(options.limit); if (options?.continueToken) params.continue = options.continueToken; - let response = await this.axios.get(path, { params }); - return response.data; + return await this.request(`list ${rt}`, () => this.axios.get(path, { params })); } async getResource( @@ -204,16 +281,13 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error( - `Unknown resource type: ${resourceType}. Supported types: ${Object.keys(resourceApiPaths).join(', ')}` - ); + throw unknownResourceTypeError(resourceType); } let ns = info.namespaced ? this.resolveNamespace(namespace) : undefined; let path = this.buildResourcePath(rt, ns, resourceName); - let response = await this.axios.get(path); - return response.data; + return await this.request(`get ${rt}/${resourceName}`, () => this.axios.get(path)); } async createResource( @@ -224,16 +298,13 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error( - `Unknown resource type: ${resourceType}. Supported types: ${Object.keys(resourceApiPaths).join(', ')}` - ); + throw unknownResourceTypeError(resourceType); } let ns = info.namespaced ? this.resolveNamespace(namespace) : undefined; let path = this.buildResourcePath(rt, ns); - let response = await this.axios.post(path, body); - return response.data; + return await this.request(`create ${rt}`, () => this.axios.post(path, body)); } async updateResource( @@ -245,16 +316,15 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error( - `Unknown resource type: ${resourceType}. Supported types: ${Object.keys(resourceApiPaths).join(', ')}` - ); + throw unknownResourceTypeError(resourceType); } let ns = info.namespaced ? this.resolveNamespace(namespace) : undefined; let path = this.buildResourcePath(rt, ns, resourceName); - let response = await this.axios.put(path, body); - return response.data; + return await this.request(`update ${rt}/${resourceName}`, () => + this.axios.put(path, body) + ); } async patchResource( @@ -267,9 +337,7 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error( - `Unknown resource type: ${resourceType}. Supported types: ${Object.keys(resourceApiPaths).join(', ')}` - ); + throw unknownResourceTypeError(resourceType); } let ns = info.namespaced ? this.resolveNamespace(namespace) : undefined; @@ -281,10 +349,11 @@ export class KubeClient { json: 'application/json-patch+json' }; - let response = await this.axios.patch(path, patch, { - headers: { 'Content-Type': contentTypeMap[patchType] } - }); - return response.data; + return await this.request(`patch ${rt}/${resourceName}`, () => + this.axios.patch(path, patch, { + headers: { 'Content-Type': contentTypeMap[patchType] } + }) + ); } async deleteResource( @@ -296,9 +365,7 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error( - `Unknown resource type: ${resourceType}. Supported types: ${Object.keys(resourceApiPaths).join(', ')}` - ); + throw unknownResourceTypeError(resourceType); } let ns = info.namespaced ? this.resolveNamespace(namespace) : undefined; @@ -309,8 +376,9 @@ export class KubeClient { body = { propagationPolicy }; } - let response = await this.axios.delete(path, { data: body }); - return response.data; + return await this.request(`delete ${rt}/${resourceName}`, () => + this.axios.delete(path, { data: body }) + ); } async getResourceScale( @@ -321,14 +389,13 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error(`Unknown resource type: ${resourceType}`); + throw unknownResourceTypeError(resourceType); } let ns = info.namespaced ? this.resolveNamespace(namespace) : undefined; let path = `${this.buildResourcePath(rt, ns, resourceName)}/scale`; - let response = await this.axios.get(path); - return response.data; + return await this.request(`get ${rt}/${resourceName} scale`, () => this.axios.get(path)); } async setResourceScale( @@ -340,22 +407,23 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error(`Unknown resource type: ${resourceType}`); + throw unknownResourceTypeError(resourceType); } let ns = info.namespaced ? this.resolveNamespace(namespace) : undefined; let path = `${this.buildResourcePath(rt, ns, resourceName)}/scale`; - let response = await this.axios.patch( - path, - { - spec: { replicas } - }, - { - headers: { 'Content-Type': 'application/merge-patch+json' } - } + return await this.request(`set ${rt}/${resourceName} scale`, () => + this.axios.patch( + path, + { + spec: { replicas } + }, + { + headers: { 'Content-Type': 'application/merge-patch+json' } + } + ) ); - return response.data; } async getResourceLogs( @@ -377,11 +445,12 @@ export class KubeClient { if (options?.sinceSeconds) params.sinceSeconds = String(options.sinceSeconds); if (options?.previous) params.previous = 'true'; - let response = await this.axios.get(path, { - params, - headers: { Accept: 'text/plain' } - }); - return response.data; + return await this.request(`get pod ${podName} logs`, () => + this.axios.get(path, { + params, + headers: { Accept: 'text/plain' } + }) + ); } async restartDeployment(deploymentName: string, namespace?: string): Promise { @@ -389,24 +458,25 @@ export class KubeClient { let path = this.buildResourcePath('deployments', ns, deploymentName); let now = new Date().toISOString(); - let response = await this.axios.patch( - path, - { - spec: { - template: { - metadata: { - annotations: { - 'kubectl.kubernetes.io/restartedAt': now + return await this.request(`restart deployment ${deploymentName}`, () => + this.axios.patch( + path, + { + spec: { + template: { + metadata: { + annotations: { + 'kubectl.kubernetes.io/restartedAt': now + } } } } + }, + { + headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } - }, - { - headers: { 'Content-Type': 'application/strategic-merge-patch+json' } - } + ) ); - return response.data; } async listEvents( @@ -417,14 +487,15 @@ export class KubeClient { } ): Promise { let ns = this.resolveNamespace(namespace); - let path = `/api/v1/namespaces/${ns}/events`; + let path = this.buildResourcePath('events', ns); let params: Record = {}; if (options?.fieldSelector) params.fieldSelector = options.fieldSelector; if (options?.limit) params.limit = String(options.limit); - let response = await this.axios.get(path, { params }); - return response.data; + return await this.request(`list events in namespace ${ns}`, () => + this.axios.get(path, { params }) + ); } async listClusterEvents(options?: { @@ -432,15 +503,14 @@ export class KubeClient { limit?: number; labelSelector?: string; }): Promise { - let path = `/api/v1/events`; + let path = this.buildResourcePath('events'); let params: Record = {}; if (options?.fieldSelector) params.fieldSelector = options.fieldSelector; if (options?.limit) params.limit = String(options.limit); if (options?.labelSelector) params.labelSelector = options.labelSelector; - let response = await this.axios.get(path, { params }); - return response.data; + return await this.request('list cluster events', () => this.axios.get(path, { params })); } async getClusterInfo(): Promise<{ @@ -448,14 +518,14 @@ export class KubeClient { nodeCount: number; nodes: any[]; }> { - let [versionResp, nodesResp] = await Promise.all([ - this.axios.get('/version'), - this.axios.get('/api/v1/nodes') + let [version, nodesResp] = await Promise.all([ + this.request('get server version', () => this.axios.get('/version')), + this.request('list nodes', () => this.axios.get('/api/v1/nodes')) ]); - let nodes = nodesResp.data.items || []; + let nodes = nodesResp.items || []; return { - version: versionResp.data, + version, nodeCount: nodes.length, nodes: nodes.map((n: any) => ({ name: n.metadata?.name, @@ -473,8 +543,7 @@ export class KubeClient { } async getNamespaces(): Promise { - let response = await this.axios.get('/api/v1/namespaces'); - return response.data; + return await this.request('list namespaces', () => this.axios.get('/api/v1/namespaces')); } async applyResource(body: Record, namespace?: string): Promise { @@ -483,7 +552,7 @@ export class KubeClient { let name = body.metadata?.name; let info = resourceApiPaths[resourceType]; if (!info) { - throw new Error(`Unknown resource kind: ${kind}`); + throw kubernetesServiceError(`Unknown resource kind: ${kind}`); } let ns = info.namespaced @@ -512,20 +581,20 @@ export class KubeClient { let rt = resourceType.toLowerCase(); let info = resourceApiPaths[rt]; if (!info) { - throw new Error(`Unknown resource type: ${resourceType}`); + throw unknownResourceTypeError(resourceType); } let ns = info.namespaced ? this.resolveNamespace(namespace) : undefined; let path = `${this.buildResourcePath(rt, ns, resourceName)}/status`; - let response = await this.axios.get(path); - return response.data; + return await this.request(`get ${rt}/${resourceName} status`, () => this.axios.get(path)); } } export let kindToResourceType = (kind: string): string => { let map: Record = { Pod: 'pods', + PodTemplate: 'podtemplates', Service: 'services', ConfigMap: 'configmaps', Secret: 'secrets', @@ -545,8 +614,10 @@ export let kindToResourceType = (kind: string): string => { ReplicaSet: 'replicasets', Job: 'jobs', CronJob: 'cronjobs', + CertificateSigningRequest: 'certificatesigningrequests', Ingress: 'ingresses', NetworkPolicy: 'networkpolicies', + PodDisruptionBudget: 'poddisruptionbudgets', Role: 'roles', ClusterRole: 'clusterroles', RoleBinding: 'rolebindings', @@ -557,7 +628,12 @@ export let kindToResourceType = (kind: string): string => { EndpointSlice: 'endpointslices', PriorityClass: 'priorityclasses', Lease: 'leases', + APIService: 'apiservices', + MutatingAdmissionPolicy: 'mutatingadmissionpolicies', + MutatingAdmissionPolicyBinding: 'mutatingadmissionpolicybindings', MutatingWebhookConfiguration: 'mutatingwebhookconfigurations', + ValidatingAdmissionPolicy: 'validatingadmissionpolicies', + ValidatingAdmissionPolicyBinding: 'validatingadmissionpolicybindings', ValidatingWebhookConfiguration: 'validatingwebhookconfigurations' }; return map[kind] || `${kind.toLowerCase()}s`; diff --git a/integrations/kubernetes/src/lib/errors.ts b/integrations/kubernetes/src/lib/errors.ts new file mode 100644 index 0000000000..d4ee5cfd71 --- /dev/null +++ b/integrations/kubernetes/src/lib/errors.ts @@ -0,0 +1,76 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let message = value.trim(); + if (message && !messages.includes(message)) { + messages.push(message); + } +}; + +let extractKubernetesMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let messages: string[] = []; + + if (isRecord(data)) { + pushMessage(messages, data.message); + pushMessage(messages, data.reason); + pushMessage(messages, data.status); + + let details = isRecord(data.details) ? data.details : undefined; + pushMessage(messages, details?.name); + pushMessage(messages, details?.kind); + } else { + pushMessage(messages, data); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let kubernetesServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let kubernetesApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = kubernetesServiceError( + `Kubernetes API ${operation} failed: ${statusLabel}${extractKubernetesMessage(error)}` + ); + + serviceError.data.reason = 'kubernetes_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/kubernetes/src/tools/apply-resource.ts b/integrations/kubernetes/src/tools/apply-resource.ts index 76fb3111fd..78daa1c2c7 100644 --- a/integrations/kubernetes/src/tools/apply-resource.ts +++ b/integrations/kubernetes/src/tools/apply-resource.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createKubeClient } from '../lib/client'; +import { kubernetesServiceError } from '../lib/errors'; import { spec } from '../spec'; export let applyResource = SlateTool.create(spec, { @@ -47,7 +48,9 @@ Accepts a full resource manifest as a JSON object. The kind, apiVersion, and met let manifest = ctx.input.manifest; if (!manifest.kind || !manifest.apiVersion || !manifest.metadata?.name) { - throw new Error('Manifest must include apiVersion, kind, and metadata.name'); + throw kubernetesServiceError( + 'Manifest must include apiVersion, kind, and metadata.name' + ); } // Try to get the existing resource to determine if this is create or update diff --git a/integrations/kubernetes/src/tools/manage-autoscaler.ts b/integrations/kubernetes/src/tools/manage-autoscaler.ts index ce7bd8ae66..4092751e3b 100644 --- a/integrations/kubernetes/src/tools/manage-autoscaler.ts +++ b/integrations/kubernetes/src/tools/manage-autoscaler.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createKubeClient } from '../lib/client'; +import { kubernetesServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageAutoscaler = SlateTool.create(spec, { @@ -78,8 +79,10 @@ export let manageAutoscaler = SlateTool.create(spec, { result = await client.applyResource(ctx.input.manifest, namespace); } } else if (action === 'create') { - if (!ctx.input.targetRef || !ctx.input.maxReplicas) { - throw new Error('targetRef and maxReplicas are required for creating an HPA'); + if (!ctx.input.targetRef || ctx.input.maxReplicas === undefined) { + throw kubernetesServiceError( + 'targetRef and maxReplicas are required for creating an HPA' + ); } let metrics: any[] = []; diff --git a/integrations/kubernetes/src/tools/manage-config-storage.ts b/integrations/kubernetes/src/tools/manage-config-storage.ts index 323881e195..96f5cda6f4 100644 --- a/integrations/kubernetes/src/tools/manage-config-storage.ts +++ b/integrations/kubernetes/src/tools/manage-config-storage.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createKubeClient } from '../lib/client'; +import { kubernetesServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageConfigStorage = SlateTool.create(spec, { @@ -11,7 +12,7 @@ For secrets, values should be provided as plain text — they will be base64-enc instructions: [ 'When updating, only provided keys are changed; existing keys not in the update are preserved.', 'For secrets, provide plaintext values — they are automatically base64-encoded.', - 'To delete a specific key, set its value to an empty string in the entries.' + 'To delete specific keys during an update, list them in deleteKeys.' ], tags: { destructive: false @@ -29,6 +30,10 @@ For secrets, values should be provided as plain text — they will be base64-enc .record(z.string(), z.string()) .optional() .describe('Key-value data to set. For secrets, provide plain text values.'), + deleteKeys: z + .array(z.string()) + .optional() + .describe('Data keys to remove during update. Ignored for create actions.'), secretType: z .string() .optional() @@ -87,15 +92,24 @@ For secrets, values should be provided as plain text — they will be base64-enc } else { // update via merge patch let patch: any = {}; + let deleteKeys = ctx.input.deleteKeys || []; if (resourceKind === 'configmaps') { + if (ctx.input.entries || deleteKeys.length > 0) { + patch.data = { ...(ctx.input.entries || {}) }; + for (let key of deleteKeys) { + patch.data[key] = null; + } + } + } else if (ctx.input.entries || deleteKeys.length > 0) { + let encodedData: Record = {}; if (ctx.input.entries) { - patch.data = ctx.input.entries; + for (let [key, value] of Object.entries(ctx.input.entries)) { + encodedData[key] = btoa(value as string); + } } - } else if (ctx.input.entries) { - let encodedData: Record = {}; - for (let [key, value] of Object.entries(ctx.input.entries)) { - encodedData[key] = btoa(value as string); + for (let key of deleteKeys) { + encodedData[key] = null; } patch.data = encodedData; } @@ -108,6 +122,12 @@ For secrets, values should be provided as plain text — they will be base64-enc patch.metadata.annotations = ctx.input.annotations; } + if (Object.keys(patch).length === 0) { + throw kubernetesServiceError( + 'Provide entries, deleteKeys, labels, or annotations when updating a ConfigMap or Secret.' + ); + } + result = await client.patchResource( resourceKind, resourceName, diff --git a/integrations/kubernetes/src/tools/manage-deployment.ts b/integrations/kubernetes/src/tools/manage-deployment.ts index 99eaf64af0..797eff892e 100644 --- a/integrations/kubernetes/src/tools/manage-deployment.ts +++ b/integrations/kubernetes/src/tools/manage-deployment.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createKubeClient } from '../lib/client'; +import { kubernetesServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageDeployment = SlateTool.create(spec, { @@ -23,7 +24,7 @@ Also supports StatefulSets and DaemonSets for similar workload management.`, .enum(['create', 'update', 'scale', 'restart']) .describe('Action to perform on the deployment'), workloadType: z - .enum(['deployments', 'statefulsets', 'daemonsets']) + .enum(['deployments', 'statefulsets', 'daemonsets', 'replicasets']) .default('deployments') .describe('Type of workload to manage'), deploymentName: z.string().describe('Name of the deployment/workload'), @@ -34,6 +35,12 @@ Also supports StatefulSets and DaemonSets for similar workload management.`, .describe('Desired number of replicas (for scale action or create/update)'), containerName: z.string().optional().describe('Name of the container to update'), image: z.string().optional().describe('New container image (e.g. "nginx:1.25")'), + serviceName: z + .string() + .optional() + .describe( + 'Headless service name for StatefulSet creation. Defaults to deploymentName when omitted.' + ), labels: z .record(z.string(), z.string()) .optional() @@ -80,21 +87,26 @@ Also supports StatefulSets and DaemonSets for similar workload management.`, if (ctx.input.manifest) { result = await client.createResource(workloadType, ctx.input.manifest, namespace); } else { + if (!ctx.input.image) { + throw kubernetesServiceError( + 'image is required when creating a workload without a full manifest.' + ); + } + let kindMap: Record = { + deployments: 'Deployment', + statefulsets: 'StatefulSet', + daemonsets: 'DaemonSet', + replicasets: 'ReplicaSet' + }; let body: any = { - apiVersion: workloadType === 'deployments' ? 'apps/v1' : 'apps/v1', - kind: - workloadType === 'deployments' - ? 'Deployment' - : workloadType === 'statefulsets' - ? 'StatefulSet' - : 'DaemonSet', + apiVersion: 'apps/v1', + kind: kindMap[workloadType], metadata: { name: deploymentName, labels: ctx.input.labels, annotations: ctx.input.annotations }, spec: { - replicas: ctx.input.replicas ?? 1, selector: { matchLabels: ctx.input.labels || { app: deploymentName } }, @@ -115,11 +127,22 @@ Also supports StatefulSets and DaemonSets for similar workload management.`, } } }; + if (workloadType !== 'daemonsets') { + body.spec.replicas = ctx.input.replicas ?? 1; + } + if (workloadType === 'statefulsets') { + body.spec.serviceName = ctx.input.serviceName || deploymentName; + } result = await client.createResource(workloadType, body, namespace); } } else if (action === 'scale') { + if (workloadType === 'daemonsets') { + throw kubernetesServiceError( + 'DaemonSets do not expose the Kubernetes scale subresource.' + ); + } if (ctx.input.replicas === undefined) { - throw new Error('replicas is required for scale action'); + throw kubernetesServiceError('replicas is required for scale action'); } let _scaleResult = await client.setResourceScale( workloadType, @@ -129,6 +152,11 @@ Also supports StatefulSets and DaemonSets for similar workload management.`, ); result = await client.getResource(workloadType, deploymentName, namespace); } else if (action === 'restart') { + if (workloadType === 'replicasets') { + throw kubernetesServiceError( + 'Restart is supported for deployments, statefulsets, and daemonsets.' + ); + } if (workloadType === 'deployments') { result = await client.restartDeployment(deploymentName, namespace); } else { @@ -154,6 +182,11 @@ Also supports StatefulSets and DaemonSets for similar workload management.`, } else { // update let patch: any = {}; + if (ctx.input.image && !ctx.input.containerName) { + throw kubernetesServiceError( + 'containerName is required when updating a container image.' + ); + } if (ctx.input.image && ctx.input.containerName) { patch.spec = { template: { @@ -181,6 +214,12 @@ Also supports StatefulSets and DaemonSets for similar workload management.`, patch.metadata.annotations = ctx.input.annotations; } + if (Object.keys(patch).length === 0) { + throw kubernetesServiceError( + 'Provide image, replicas, labels, or annotations when updating a workload.' + ); + } + result = await client.patchResource( workloadType, deploymentName, diff --git a/integrations/kubernetes/src/tools/manage-job.ts b/integrations/kubernetes/src/tools/manage-job.ts index 689425a9e1..2cc571759c 100644 --- a/integrations/kubernetes/src/tools/manage-job.ts +++ b/integrations/kubernetes/src/tools/manage-job.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createKubeClient } from '../lib/client'; +import { kubernetesServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageJob = SlateTool.create(spec, { @@ -75,12 +76,12 @@ export let manageJob = SlateTool.create(spec, { result = await client.createResource(resourceKind, ctx.input.manifest, namespace); } else { if (!ctx.input.image) { - throw new Error('image is required for job creation'); + throw kubernetesServiceError('image is required for job creation'); } if (resourceKind === 'cronjobs') { if (!ctx.input.schedule) { - throw new Error('schedule is required for CronJob creation'); + throw kubernetesServiceError('schedule is required for CronJob creation'); } let body = { apiVersion: 'batch/v1', diff --git a/integrations/kubernetes/src/tools/manage-namespace.ts b/integrations/kubernetes/src/tools/manage-namespace.ts index a30f1b9f00..f90feffa7c 100644 --- a/integrations/kubernetes/src/tools/manage-namespace.ts +++ b/integrations/kubernetes/src/tools/manage-namespace.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createKubeClient } from '../lib/client'; +import { kubernetesServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageNamespace = SlateTool.create(spec, { @@ -62,7 +63,7 @@ export let manageNamespace = SlateTool.create(spec, { } if (!namespaceName) { - throw new Error('namespaceName is required for create/update actions'); + throw kubernetesServiceError('namespaceName is required for create/update actions'); } if (action === 'create') { @@ -101,6 +102,10 @@ export let manageNamespace = SlateTool.create(spec, { patch.metadata.annotations = ctx.input.annotations; } + if (Object.keys(patch).length === 0) { + throw kubernetesServiceError('Provide labels or annotations when updating a namespace.'); + } + let result = await client.patchResource( 'namespaces', namespaceName, diff --git a/integrations/kubernetes/src/tools/manage-rbac.ts b/integrations/kubernetes/src/tools/manage-rbac.ts index b708fe468d..0a0feccf31 100644 --- a/integrations/kubernetes/src/tools/manage-rbac.ts +++ b/integrations/kubernetes/src/tools/manage-rbac.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createKubeClient } from '../lib/client'; +import { kubernetesServiceError } from '../lib/errors'; import { spec } from '../spec'; let ruleSchema = z.object({ @@ -114,7 +115,7 @@ Use this to define access control policies for users, groups, and service accoun clusterrolebindings: 'ClusterRoleBinding' }; if (!ctx.input.roleRef) { - throw new Error('roleRef is required for creating/updating bindings'); + throw kubernetesServiceError('roleRef is required for creating/updating bindings'); } let body: any = { apiVersion: 'rbac.authorization.k8s.io/v1', diff --git a/integrations/kubernetes/src/tools/manage-service.ts b/integrations/kubernetes/src/tools/manage-service.ts index 8929efa551..bea7ab4f84 100644 --- a/integrations/kubernetes/src/tools/manage-service.ts +++ b/integrations/kubernetes/src/tools/manage-service.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createKubeClient } from '../lib/client'; +import { kubernetesServiceError } from '../lib/errors'; import { spec } from '../spec'; let portSchema = z.object({ @@ -40,6 +41,12 @@ Also manages Ingress resources for HTTP(S) routing.`, .enum(['ClusterIP', 'NodePort', 'LoadBalancer', 'ExternalName']) .optional() .describe('Service type (only for services)'), + externalName: z + .string() + .optional() + .describe( + 'DNS name for ExternalName services. Required when serviceType is ExternalName.' + ), selector: z .record(z.string(), z.string()) .optional() @@ -87,6 +94,22 @@ Also manages Ingress resources for HTTP(S) routing.`, } } else if (resourceKind === 'services') { if (action === 'create') { + if (ctx.input.serviceType === 'ExternalName' && !ctx.input.externalName) { + throw kubernetesServiceError( + 'externalName is required when creating an ExternalName service.' + ); + } + if (ctx.input.serviceType !== 'ExternalName' && !ctx.input.ports?.length) { + throw kubernetesServiceError( + 'ports is required when creating a Kubernetes service.' + ); + } + if (ctx.input.serviceType !== 'ExternalName' && ctx.input.externalName) { + throw kubernetesServiceError( + 'externalName can only be used with ExternalName services.' + ); + } + let body: any = { apiVersion: 'v1', kind: 'Service', @@ -97,7 +120,9 @@ Also manages Ingress resources for HTTP(S) routing.`, }, spec: { type: ctx.input.serviceType || 'ClusterIP', - selector: ctx.input.selector, + selector: + ctx.input.serviceType === 'ExternalName' ? undefined : ctx.input.selector, + externalName: ctx.input.externalName, ports: ctx.input.ports?.map(p => ({ name: p.portName, port: p.port, @@ -111,6 +136,17 @@ Also manages Ingress resources for HTTP(S) routing.`, } else { let patch: any = { spec: {} }; if (ctx.input.serviceType) patch.spec.type = ctx.input.serviceType; + if (ctx.input.serviceType === 'ExternalName' && !ctx.input.externalName) { + throw kubernetesServiceError( + 'externalName is required when changing a service to ExternalName.' + ); + } + if (ctx.input.externalName && ctx.input.serviceType !== 'ExternalName') { + throw kubernetesServiceError( + 'externalName can only be used when serviceType is ExternalName.' + ); + } + if (ctx.input.externalName) patch.spec.externalName = ctx.input.externalName; if (ctx.input.selector) patch.spec.selector = ctx.input.selector; if (ctx.input.ports) { patch.spec.ports = ctx.input.ports.map(p => ({ @@ -126,6 +162,11 @@ Also manages Ingress resources for HTTP(S) routing.`, patch.metadata = patch.metadata || {}; patch.metadata.annotations = ctx.input.annotations; } + if (Object.keys(patch.spec).length === 0 && !patch.metadata) { + throw kubernetesServiceError( + 'Provide serviceType, externalName, selector, ports, labels, or annotations when updating a service.' + ); + } result = await client.patchResource( 'services', serviceName, @@ -135,7 +176,9 @@ Also manages Ingress resources for HTTP(S) routing.`, ); } } else { - throw new Error('For Ingress creation/update, please provide a full manifest.'); + throw kubernetesServiceError( + 'For Ingress creation/update, please provide a full manifest.' + ); } let ports = result.spec?.ports?.map((p: any) => ({ diff --git a/integrations/kubernetes/src/triggers/resource-events.ts b/integrations/kubernetes/src/triggers/resource-events.ts index ced7a7c502..be30192e7b 100644 --- a/integrations/kubernetes/src/triggers/resource-events.ts +++ b/integrations/kubernetes/src/triggers/resource-events.ts @@ -86,20 +86,38 @@ export let resourceEvents = SlateTrigger.create(spec, { }; } - let inputs = newEvents.map(event => ({ - eventUid: event.metadata?.uid || '', - eventType: event.type || 'Normal', - reason: event.reason || '', - message: event.message || '', - involvedObjectKind: event.involvedObject?.kind, - involvedObjectName: event.involvedObject?.name, - involvedObjectNamespace: event.involvedObject?.namespace, - resourceVersion: event.metadata?.resourceVersion, - firstTimestamp: event.firstTimestamp, - lastTimestamp: event.lastTimestamp, - count: event.count, - sourceComponent: event.source?.component - })); + let inputs = newEvents.map(event => { + let involvedObject = event.regarding || event.involvedObject; + let series = event.series as + | { + count?: number; + lastObservedTime?: string; + } + | undefined; + + return { + eventUid: event.metadata?.uid || '', + eventType: event.type || 'Normal', + reason: event.reason || '', + message: event.note || event.message || '', + involvedObjectKind: involvedObject?.kind, + involvedObjectName: involvedObject?.name, + involvedObjectNamespace: involvedObject?.namespace, + resourceVersion: event.metadata?.resourceVersion, + firstTimestamp: + event.deprecatedFirstTimestamp || event.firstTimestamp || event.eventTime, + lastTimestamp: + event.deprecatedLastTimestamp || + event.lastTimestamp || + series?.lastObservedTime || + event.eventTime, + count: event.deprecatedCount ?? event.count ?? series?.count, + sourceComponent: + event.reportingController || + event.deprecatedSource?.component || + event.source?.component + }; + }); let updatedUids = [ ...seenUids, diff --git a/integrations/langbase/README.md b/integrations/langbase/README.md index decd0720b0..349a2664ca 100644 --- a/integrations/langbase/README.md +++ b/integrations/langbase/README.md @@ -1,6 +1,6 @@ # Langbase -Build and run AI agents using a unified LLM API across 100+ models. Create and manage pipes (AI agents) for text generation and chat with streaming support. Store and search documents using managed RAG memory with vector embeddings, chunking, and semantic similarity search. Manage conversation threads with full history and context. Orchestrate multi-step agentic workflows with retries and parallel execution. Parse and extract text from documents (PDFs, CSVs, etc.). Generate vector embeddings for text chunks. Generate AI images via multiple providers. Perform web search and web crawling through integrated tools. Supports tool calling, structured outputs, JSON mode, and vision capabilities. +Build and run AI agents using a unified LLM API across 600+ models. Create and manage pipes (AI agents) for text generation and chat. Store, upload, list, delete, and search documents using managed RAG memory with vector embeddings, chunking, and semantic similarity search. Manage conversation threads with full history and context. Parse and extract text from documents (PDFs, CSVs, etc.). Generate vector embeddings for text chunks. Generate AI images via multiple providers. Perform web search and web crawling through integrated tools. Supports tool calling, structured outputs, JSON mode, and vision-capable model inputs where supported by Langbase. ## License diff --git a/integrations/langbase/package.json b/integrations/langbase/package.json index 1c36644f0e..6049606880 100644 --- a/integrations/langbase/package.json +++ b/integrations/langbase/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/langbase/slate.json b/integrations/langbase/slate.json index 5585e28c0c..8f38663a68 100644 --- a/integrations/langbase/slate.json +++ b/integrations/langbase/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/langbase", - "description": "Build and run AI agents using a unified LLM API across 100+ models. Create and manage pipes (AI agents) for text generation and chat with streaming support. Store and search documents using managed RAG memory with vector embeddings, chunking, and semantic similarity search. Manage conversation threads with full history and context. Orchestrate multi-step agentic workflows with retries and parallel execution. Parse and extract text from documents (PDFs, CSVs, etc.). Generate vector embeddings for text chunks. Generate AI images via multiple providers. Perform web search and web crawling through integrated tools. Supports tool calling, structured outputs, JSON mode, and vision capabilities.", + "description": "Build and run AI agents using a unified LLM API across 600+ models. Create and manage pipes (AI agents) for text generation and chat. Store, upload, list, delete, and search documents using managed RAG memory with vector embeddings, chunking, and semantic similarity search. Manage conversation threads with full history and context. Parse and extract text from documents (PDFs, CSVs, etc.). Generate vector embeddings for text chunks. Generate AI images via multiple providers. Perform web search and web crawling through integrated tools. Supports tool calling, structured outputs, JSON mode, and vision-capable model inputs where supported by Langbase.", "categories": [ "apis-and-http-requests", "document-processing", @@ -12,7 +12,6 @@ "upload and parse documents", "generate vector embeddings", "manage conversation threads", - "orchestrate multi-step workflows", "generate AI images", "perform web search", "chunk and split text", diff --git a/integrations/langbase/src/index.ts b/integrations/langbase/src/index.ts index f17265d2ac..42d240bdae 100644 --- a/integrations/langbase/src/index.ts +++ b/integrations/langbase/src/index.ts @@ -16,18 +16,18 @@ import { listMemories, listMessages, listPipes, + parseDocument, retrieveMemory, retryDocumentEmbeddings, runAgent, runPipe, updatePipe, updateThread, + uploadDocument, webCrawl, webSearch } from './tools'; -import { inboundWebhook } from './triggers/inbound-webhook'; - export let provider = Slate.create({ spec, tools: [ @@ -39,6 +39,7 @@ export let provider = Slate.create({ listMemories, deleteMemory, retrieveMemory, + uploadDocument, listDocuments, deleteDocument, retryDocumentEmbeddings, @@ -48,6 +49,7 @@ export let provider = Slate.create({ deleteThread, appendMessages, listMessages, + parseDocument, runAgent, chunkText, generateEmbeddings, @@ -55,5 +57,5 @@ export let provider = Slate.create({ webSearch, webCrawl ], - triggers: [inboundWebhook] + triggers: [] }); diff --git a/integrations/langbase/src/lib/client.ts b/integrations/langbase/src/lib/client.ts index 24b5185ecf..290d5599c3 100644 --- a/integrations/langbase/src/lib/client.ts +++ b/integrations/langbase/src/lib/client.ts @@ -1,41 +1,75 @@ +import { Buffer } from 'node:buffer'; import { createAxios } from 'slates'; +import { langbaseApiError, langbaseServiceError } from './errors'; let http = createAxios({ baseURL: 'https://api.langbase.com/v1' }); +type DocumentUploadParams = { + memoryName: string; + documentName: string; + contentType: string; + contentBase64?: string; + contentText?: string; + meta?: Record; +}; + +let asBlobPart = (content: Buffer | string) => + typeof content === 'string' ? content : new Uint8Array(content); + export class Client { constructor(private token: string) {} - private headers(extra?: Record) { + private authHeaders(extra?: Record) { return { - 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}`, ...extra }; } + private headers(extra?: Record) { + return { + 'Content-Type': 'application/json', + ...this.authHeaders(extra) + }; + } + + private async request(operation: string, config: Record): Promise { + try { + let res = await http.request(config); + return res.data as T; + } catch (error) { + throw langbaseApiError(error, operation); + } + } + // ─── Pipes ────────────────────────────────────────────── async createPipe(body: Record) { - let res = await http.post('/pipes', body, { + return await this.request('create pipe', { + method: 'post', + url: '/pipes', + data: body, headers: this.headers() }); - return res.data; } async listPipes() { - let res = await http.get('/pipes', { + return await this.request('list pipes', { + method: 'get', + url: '/pipes', headers: this.headers() }); - return res.data; } async updatePipe(pipeName: string, body: Record) { - let res = await http.post(`/pipes/${encodeURIComponent(pipeName)}`, body, { + return await this.request('update pipe', { + method: 'post', + url: `/pipes/${encodeURIComponent(pipeName)}`, + data: body, headers: this.headers() }); - return res.data; } async runPipe( @@ -48,17 +82,23 @@ export class Client { } let authToken = options?.pipeApiKey ?? this.token; - let res = await http.post( - '/pipes/run', - { ...body, stream: false }, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authToken}`, - ...extra + let res: any; + try { + res = await http.post( + '/pipes/run', + { ...body, stream: false }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + ...extra + } } - } - ); + ); + } catch (error) { + throw langbaseApiError(error, 'run pipe'); + } + return { ...res.data, threadId: res.headers?.['lb-thread-id'] ?? undefined @@ -68,118 +108,176 @@ export class Client { // ─── Memory ───────────────────────────────────────────── async createMemory(body: Record) { - let res = await http.post('/memory', body, { + return await this.request('create memory', { + method: 'post', + url: '/memory', + data: body, headers: this.headers() }); - return res.data; } async listMemories() { - let res = await http.get('/memory', { + return await this.request('list memories', { + method: 'get', + url: '/memory', headers: this.headers() }); - return res.data; } async deleteMemory(memoryName: string) { - let res = await http.delete(`/memory/${encodeURIComponent(memoryName)}`, { + return await this.request('delete memory', { + method: 'delete', + url: `/memory/${encodeURIComponent(memoryName)}`, headers: this.headers() }); - return res.data; } async retrieveMemory(body: Record) { - let res = await http.post('/memory/retrieve', body, { + return await this.request('retrieve memory', { + method: 'post', + url: '/memory/retrieve', + data: body, headers: this.headers() }); - return res.data; } async getDocumentSignedUrl(body: Record) { - let res = await http.post('/memory/documents', body, { + return await this.request('get document signed URL', { + method: 'post', + url: '/memory/documents', + data: body, headers: this.headers() }); - return res.data; + } + + async uploadDocument(params: DocumentUploadParams) { + let signed = await this.getDocumentSignedUrl({ + memoryName: params.memoryName, + documentName: params.documentName, + ...(params.meta ? { meta: params.meta } : {}) + }); + + if (typeof signed.signedUrl !== 'string' || signed.signedUrl.length === 0) { + throw langbaseServiceError('Langbase did not return a signed document upload URL.'); + } + + let content = + params.contentBase64 !== undefined + ? Buffer.from(params.contentBase64, 'base64') + : (params.contentText ?? ''); + + let response: Response; + try { + response = await fetch(signed.signedUrl, { + method: 'PUT', + headers: { + 'Content-Type': params.contentType + }, + body: new Blob([asBlobPart(content)], { type: params.contentType }) + }); + } catch (error) { + throw langbaseApiError(error, 'upload document content'); + } + + if (!response.ok) { + throw langbaseServiceError( + `Langbase document upload failed: HTTP ${response.status} ${response.statusText}` + ); + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText + }; } async listDocuments(memoryName: string) { - let res = await http.get(`/memory/${encodeURIComponent(memoryName)}/documents`, { + return await this.request('list documents', { + method: 'get', + url: `/memory/${encodeURIComponent(memoryName)}/documents`, headers: this.headers() }); - return res.data; } async deleteDocument(memoryName: string, documentName: string) { - let res = await http.delete( - `/memory/${encodeURIComponent(memoryName)}/documents/${encodeURIComponent(documentName)}`, - { headers: this.headers() } - ); - return res.data; + return await this.request('delete document', { + method: 'delete', + url: `/memory/${encodeURIComponent(memoryName)}/documents/${encodeURIComponent(documentName)}`, + headers: this.headers() + }); } async retryDocumentEmbeddings(memoryName: string, documentName: string) { - let res = await http.get( - `/memory/${encodeURIComponent(memoryName)}/documents/${encodeURIComponent(documentName)}/embeddings/retry`, - { headers: this.headers() } - ); - return res.data; + return await this.request('retry document embeddings', { + method: 'get', + url: `/memory/${encodeURIComponent(memoryName)}/documents/${encodeURIComponent(documentName)}/embeddings/retry`, + headers: this.headers() + }); } // ─── Threads ──────────────────────────────────────────── async createThread(body?: Record) { - let res = await http.post('/threads', body ?? {}, { + return await this.request('create thread', { + method: 'post', + url: '/threads', + data: body ?? {}, headers: this.headers() }); - return res.data; } async getThread(threadId: string) { - let res = await http.get(`/threads/${encodeURIComponent(threadId)}`, { + return await this.request('get thread', { + method: 'get', + url: `/threads/${encodeURIComponent(threadId)}`, headers: this.headers() }); - return res.data; } async updateThread(threadId: string, body: Record) { - let res = await http.post(`/threads/${encodeURIComponent(threadId)}`, body, { + return await this.request('update thread', { + method: 'post', + url: `/threads/${encodeURIComponent(threadId)}`, + data: body, headers: this.headers() }); - return res.data; } async deleteThread(threadId: string) { - let res = await http.delete(`/threads/${encodeURIComponent(threadId)}`, { + return await this.request('delete thread', { + method: 'delete', + url: `/threads/${encodeURIComponent(threadId)}`, headers: this.headers() }); - return res.data; } - async appendMessages(threadId: string, body: Record) { - let res = await http.post(`/threads/${encodeURIComponent(threadId)}/messages`, body, { + async appendMessages(threadId: string, messages: Record[]) { + return await this.request('append messages', { + method: 'post', + url: `/threads/${encodeURIComponent(threadId)}/messages`, + data: messages, headers: this.headers() }); - return res.data; } async listMessages(threadId: string) { - let res = await http.get(`/threads/${encodeURIComponent(threadId)}/messages`, { + return await this.request('list messages', { + method: 'get', + url: `/threads/${encodeURIComponent(threadId)}/messages`, headers: this.headers() }); - return res.data; } // ─── Agent ────────────────────────────────────────────── async runAgent(body: Record, llmKey: string) { - let res = await http.post( - '/agent/run', - { ...body, stream: false }, - { - headers: this.headers({ 'LB-LLM-Key': llmKey }) - } - ); - return res.data; + return await this.request('run agent', { + method: 'post', + url: '/agent/run', + data: { ...body, stream: false }, + headers: this.headers({ 'LB-LLM-Key': llmKey }) + }); } // ─── Parser ───────────────────────────────────────────── @@ -189,76 +287,73 @@ export class Client { contentType: string, documentContent: Buffer | string ) { - let boundary = `----SlatesBoundary${Date.now()}`; - let parts: string[] = []; - - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="documentName"\r\n\r\n${documentName}` - ); - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="contentType"\r\n\r\n${contentType}` + let form = new FormData(); + form.append('documentName', documentName); + form.append('contentType', contentType); + form.append( + 'document', + new Blob([asBlobPart(documentContent)], { type: contentType }), + documentName ); - let fileContent = - typeof documentContent === 'string' - ? documentContent - : documentContent.toString('base64'); - parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${documentName}"\r\nContent-Type: ${contentType}\r\n\r\n${fileContent}` - ); - parts.push(`--${boundary}--`); - - let body = parts.join('\r\n'); - - let res = await http.post('/parser', body, { - headers: { - Authorization: `Bearer ${this.token}`, - 'Content-Type': `multipart/form-data; boundary=${boundary}` - } + return await this.request('parse document', { + method: 'post', + url: '/parser', + data: form, + headers: this.authHeaders() }); - return res.data; } // ─── Chunker ──────────────────────────────────────────── async chunkText(body: Record) { - let res = await http.post('/chunker', body, { + return await this.request('chunk text', { + method: 'post', + url: '/chunker', + data: body, headers: this.headers() }); - return res.data; } // ─── Embed ────────────────────────────────────────────── async generateEmbeddings(body: Record) { - let res = await http.post('/embed', body, { + return await this.request('generate embeddings', { + method: 'post', + url: '/embed', + data: body, headers: this.headers() }); - return res.data; } // ─── Images ───────────────────────────────────────────── async generateImages(body: Record, llmKey: string) { - let res = await http.post('/images', body, { + return await this.request('generate images', { + method: 'post', + url: '/images', + data: body, headers: this.headers({ 'LB-LLM-Key': llmKey }) }); - return res.data; } // ─── Tools ────────────────────────────────────────────── async webSearch(body: Record, searchKey: string) { - let res = await http.post('/tools/web-search', body, { + return await this.request('web search', { + method: 'post', + url: '/tools/web-search', + data: body, headers: this.headers({ 'LB-WEB-SEARCH-KEY': searchKey }) }); - return res.data; } async webCrawl(body: Record, crawlKey: string) { - let res = await http.post('/tools/crawl', body, { + return await this.request('web crawl', { + method: 'post', + url: '/tools/crawl', + data: body, headers: this.headers({ 'LB-CRAWL-KEY': crawlKey }) }); - return res.data; } } diff --git a/integrations/langbase/src/lib/errors.ts b/integrations/langbase/src/lib/errors.ts new file mode 100644 index 0000000000..9d4d24509d --- /dev/null +++ b/integrations/langbase/src/lib/errors.ts @@ -0,0 +1,96 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (!isRecord(value)) { + addMessage(messages, value); + return; + } + + for (let key of ['message', 'error', 'detail', 'title', 'code']) { + addMessage(messages, value[key]); + } + + if (Array.isArray(value.errors)) { + for (let item of value.errors) { + collectMessages(item, messages); + } + } +}; + +let getErrorResponse = (error: unknown) => + isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + +let getErrorStatus = (error: unknown) => { + let response = getErrorResponse(error); + return response?.status ?? (isRecord(error) ? error.status : undefined); +}; + +let getErrorStatusText = (error: unknown) => { + let response = getErrorResponse(error); + return response?.statusText; +}; + +let extractMessage = (error: unknown) => { + let messages: string[] = []; + let response = getErrorResponse(error); + + collectMessages(response?.data, messages); + + if (isRecord(error)) { + collectMessages(error.data, messages); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let langbaseServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let langbaseApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let status = getErrorStatus(error); + let statusText = getErrorStatusText(error); + let statusLabel = + status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : ''; + let serviceError = langbaseServiceError( + `Langbase API ${operation} failed: ${statusLabel}${extractMessage(error)}` + ); + + serviceError.data.reason = 'langbase_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/langbase/src/tools.schema.test.ts b/integrations/langbase/src/tools.schema.test.ts new file mode 100644 index 0000000000..bf155f0a8c --- /dev/null +++ b/integrations/langbase/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Langbase tool input schemas', provider.actions); diff --git a/integrations/langbase/src/tools/create-pipe.ts b/integrations/langbase/src/tools/create-pipe.ts index 3f0717ebb5..54fc7a2813 100644 --- a/integrations/langbase/src/tools/create-pipe.ts +++ b/integrations/langbase/src/tools/create-pipe.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; +import { functionToolSchema, mapFunctionTools, mapMemoryNames } from './shared'; let messageSchema = z.object({ role: z.enum(['system', 'user', 'assistant']).describe('Role of the message sender'), @@ -45,9 +46,29 @@ export let createPipe = SlateTool.create(spec, { .optional() .describe('System prompts and few-shot examples'), variables: z + .record(z.string(), z.string()) + .optional() + .describe('Key-value pairs for variable substitution in pipe prompts'), + tools: z + .array(functionToolSchema) + .optional() + .describe('Function tools the model may call from this pipe'), + toolChoice: z + .enum(['auto', 'required']) + .optional() + .describe('Controls whether the model may or must call tools'), + parallelToolCalls: z + .boolean() + .optional() + .describe('Whether the model may call multiple tools in parallel'), + memoryNames: z .array(z.string()) .optional() - .describe('Variable names used in the pipe prompts'), + .describe('Names of Langbase memories attached to this pipe'), + responseFormat: z + .enum(['text', 'json_object']) + .optional() + .describe('Response format for the pipe output'), upsert: z.boolean().optional().describe('If true, update the pipe if it already exists') }) ) @@ -86,6 +107,14 @@ export let createPipe = SlateTool.create(spec, { if (ctx.input.stop !== undefined) body.stop = ctx.input.stop; if (ctx.input.messages !== undefined) body.messages = ctx.input.messages; if (ctx.input.variables !== undefined) body.variables = ctx.input.variables; + if (ctx.input.tools !== undefined) body.tools = mapFunctionTools(ctx.input.tools); + if (ctx.input.toolChoice !== undefined) body.tool_choice = ctx.input.toolChoice; + if (ctx.input.parallelToolCalls !== undefined) + body.parallel_tool_calls = ctx.input.parallelToolCalls; + if (ctx.input.memoryNames !== undefined) + body.memory = mapMemoryNames(ctx.input.memoryNames); + if (ctx.input.responseFormat !== undefined) + body.response_format = { type: ctx.input.responseFormat }; if (ctx.input.upsert !== undefined) body.upsert = ctx.input.upsert; let result = await client.createPipe(body); diff --git a/integrations/langbase/src/tools/generate-embeddings.ts b/integrations/langbase/src/tools/generate-embeddings.ts index 125c1f2664..0f50ec579c 100644 --- a/integrations/langbase/src/tools/generate-embeddings.ts +++ b/integrations/langbase/src/tools/generate-embeddings.ts @@ -24,6 +24,7 @@ export let generateEmbeddings = SlateTool.create(spec, { embeddingModel: z .enum([ 'openai:text-embedding-3-large', + 'cohere:embed-v4.0', 'cohere:embed-multilingual-v3.0', 'cohere:embed-multilingual-light-v3.0', 'google:text-embedding-004' diff --git a/integrations/langbase/src/tools/generate-images.ts b/integrations/langbase/src/tools/generate-images.ts index 0de156645f..4aa93a2951 100644 --- a/integrations/langbase/src/tools/generate-images.ts +++ b/integrations/langbase/src/tools/generate-images.ts @@ -28,6 +28,10 @@ export let generateImages = SlateTool.create(spec, { llmProviderKey: z.string().describe('API key for the image generation provider'), width: z.number().optional().describe('Width of the generated image in pixels'), height: z.number().optional().describe('Height of the generated image in pixels'), + size: z + .string() + .optional() + .describe('Provider size string, e.g. "1024x1024", for models that support it'), n: z.number().optional().describe('Number of images to generate'), steps: z.number().optional().describe('Number of generation steps (provider-dependent)'), imageUrl: z.string().optional().describe('Base image URL for image-to-image generation') @@ -50,6 +54,7 @@ export let generateImages = SlateTool.create(spec, { if (ctx.input.width !== undefined) body.width = ctx.input.width; if (ctx.input.height !== undefined) body.height = ctx.input.height; + if (ctx.input.size !== undefined) body.size = ctx.input.size; if (ctx.input.n !== undefined) body.n = ctx.input.n; if (ctx.input.steps !== undefined) body.steps = ctx.input.steps; if (ctx.input.imageUrl !== undefined) body.image_url = ctx.input.imageUrl; @@ -58,7 +63,7 @@ export let generateImages = SlateTool.create(spec, { let images = (result.choices ?? []).flatMap((c: any) => (c.message?.images ?? []).map((img: any) => ({ - url: img.url ?? '' + url: img.image_url?.url ?? img.url ?? '' })) ); diff --git a/integrations/langbase/src/tools/index.ts b/integrations/langbase/src/tools/index.ts index b37752b21a..dc494f9ad6 100644 --- a/integrations/langbase/src/tools/index.ts +++ b/integrations/langbase/src/tools/index.ts @@ -8,9 +8,11 @@ export * from './list-memories'; export * from './list-pipes'; export * from './manage-documents'; export * from './manage-threads'; +export * from './parse-document'; export * from './retrieve-memory'; export * from './run-agent'; export * from './run-pipe'; export * from './update-pipe'; +export * from './upload-document'; export * from './web-crawl'; export * from './web-search'; diff --git a/integrations/langbase/src/tools/manage-threads.ts b/integrations/langbase/src/tools/manage-threads.ts index 3d23907fca..f6cd73c088 100644 --- a/integrations/langbase/src/tools/manage-threads.ts +++ b/integrations/langbase/src/tools/manage-threads.ts @@ -213,16 +213,14 @@ export let appendMessages = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new Client(ctx.auth.token); - let body = { - messages: ctx.input.messages.map(m => ({ - role: m.role, - content: m.content, - ...(m.name ? { name: m.name } : {}), - ...(m.toolCallId ? { tool_call_id: m.toolCallId } : {}) - })) - }; + let messages = ctx.input.messages.map(m => ({ + role: m.role, + content: m.content, + ...(m.name ? { name: m.name } : {}), + ...(m.toolCallId ? { tool_call_id: m.toolCallId } : {}) + })); - let result = await client.appendMessages(ctx.input.threadId, body); + let result = await client.appendMessages(ctx.input.threadId, messages); let msgs = (Array.isArray(result) ? result : []).map((m: any) => ({ messageId: m.id ?? '', threadId: m.thread_id ?? ctx.input.threadId, diff --git a/integrations/langbase/src/tools/parse-document.ts b/integrations/langbase/src/tools/parse-document.ts new file mode 100644 index 0000000000..24771fdc3c --- /dev/null +++ b/integrations/langbase/src/tools/parse-document.ts @@ -0,0 +1,56 @@ +import { createTextAttachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; +import { parseContentFromInput } from './shared'; + +export let parseDocument = SlateTool.create(spec, { + name: 'Parse Document', + key: 'parse_document', + description: `Extract text content from a document using Langbase Parser. Useful before chunking text, embedding content, or loading a document into memory.`, + constraints: [ + 'Maximum file size is 10 MB.', + 'Provide exactly one of contentBase64 or contentText.' + ], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + documentName: z + .string() + .describe('Document name including extension, e.g. "notes.md" or "report.pdf"'), + contentType: z.string().describe('MIME type for the document'), + contentBase64: z.string().optional().describe('Base64-encoded document content'), + contentText: z.string().optional().describe('UTF-8 text document content') + }) + ) + .output( + z.object({ + documentName: z.string().describe('Parsed document name'), + contentLength: z.number().describe('Extracted content length in characters'), + attachmentCount: z.number().describe('Number of Slate attachments returned') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth.token); + let result = await client.parseDocument( + ctx.input.documentName, + ctx.input.contentType, + parseContentFromInput(ctx.input) + ); + let content = result.content ?? ''; + + return { + output: { + documentName: result.documentName ?? ctx.input.documentName, + contentLength: content.length, + attachmentCount: content.length > 0 ? 1 : 0 + }, + attachments: content.length > 0 ? [createTextAttachment(content, 'text/plain')] : [], + message: `Parsed **${result.documentName ?? ctx.input.documentName}** (${content.length} characters).` + }; + }) + .build(); diff --git a/integrations/langbase/src/tools/run-agent.ts b/integrations/langbase/src/tools/run-agent.ts index 0b3f47885c..3c97c75acb 100644 --- a/integrations/langbase/src/tools/run-agent.ts +++ b/integrations/langbase/src/tools/run-agent.ts @@ -1,11 +1,16 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { langbaseServiceError } from '../lib/errors'; import { spec } from '../spec'; +import { functionToolSchema, mapFunctionTools, requireExactlyOneDefined } from './shared'; let inputMessageSchema = z.object({ role: z.enum(['user', 'assistant', 'system', 'tool']).describe('Role of the message sender'), - content: z.string().describe('Content of the message'), + content: z + .string() + .nullable() + .describe('Content of the message. Tool messages may use null.'), name: z.string().optional().describe('Name identifier'), toolCallId: z.string().optional().describe('Tool call ID for tool response messages') }); @@ -28,19 +33,44 @@ export let runAgent = SlateTool.create(spec, { model: z .string() .describe('LLM model in provider:model format, e.g. "openai:gpt-4o-mini"'), - input: z - .union([z.string(), z.array(inputMessageSchema)]) - .describe('Input text or array of messages'), + inputText: z + .string() + .optional() + .describe('Plain text input. Provide exactly one of inputText or messages.'), + messages: z + .array(inputMessageSchema) + .optional() + .describe('Message array input. Provide exactly one of inputText or messages.'), llmProviderKey: z .string() .describe('API key for the LLM provider (e.g. OpenAI API key)'), instructions: z.string().optional().describe('System-level instructions for the agent'), + tools: z + .array(functionToolSchema) + .optional() + .describe('Function tools the model may call'), + toolChoice: z + .enum(['auto', 'required']) + .optional() + .describe('Controls whether the model may or must call tools'), + toolChoiceFunctionName: z + .string() + .optional() + .describe('Force the model to call this specific function tool by name'), + parallelToolCalls: z + .boolean() + .optional() + .describe('Whether the model may call multiple tools in parallel'), temperature: z.number().optional().describe('Temperature for response randomness (0-2)'), topP: z.number().optional().describe('Top-p sampling parameter (0-1)'), maxTokens: z.number().optional().describe('Maximum number of tokens to generate'), presencePenalty: z.number().optional().describe('Presence penalty (-2 to 2)'), frequencyPenalty: z.number().optional().describe('Frequency penalty (-2 to 2)'), - stop: z.array(z.string()).optional().describe('Stop sequences') + stop: z.array(z.string()).optional().describe('Stop sequences'), + customModelParams: z + .record(z.string(), z.any()) + .optional() + .describe('Additional model-specific parameters passed through to the provider') }) ) .output( @@ -59,10 +89,17 @@ export let runAgent = SlateTool.create(spec, { model: ctx.input.model }; - if (typeof ctx.input.input === 'string') { - body.input = ctx.input.input; + let inputSource = requireExactlyOneDefined( + ctx.input, + 'inputText', + 'messages', + 'Provide exactly one of inputText or messages.' + ); + + if (inputSource === 'inputText') { + body.input = ctx.input.inputText; } else { - body.input = ctx.input.input.map(m => ({ + body.input = (ctx.input.messages ?? []).map(m => ({ role: m.role, content: m.content, ...(m.name ? { name: m.name } : {}), @@ -71,6 +108,25 @@ export let runAgent = SlateTool.create(spec, { } if (ctx.input.instructions !== undefined) body.instructions = ctx.input.instructions; + if (ctx.input.tools !== undefined) body.tools = mapFunctionTools(ctx.input.tools); + if (ctx.input.toolChoiceFunctionName !== undefined) { + let matchingTool = ctx.input.tools?.some( + tool => tool.name === ctx.input.toolChoiceFunctionName + ); + if (!matchingTool) { + throw langbaseServiceError( + 'toolChoiceFunctionName must match one of the provided tools.' + ); + } + body.tool_choice = { + type: 'function', + function: { name: ctx.input.toolChoiceFunctionName } + }; + } else if (ctx.input.toolChoice !== undefined) { + body.tool_choice = ctx.input.toolChoice; + } + if (ctx.input.parallelToolCalls !== undefined) + body.parallel_tool_calls = ctx.input.parallelToolCalls; if (ctx.input.temperature !== undefined) body.temperature = ctx.input.temperature; if (ctx.input.topP !== undefined) body.top_p = ctx.input.topP; if (ctx.input.maxTokens !== undefined) body.max_tokens = ctx.input.maxTokens; @@ -79,6 +135,8 @@ export let runAgent = SlateTool.create(spec, { if (ctx.input.frequencyPenalty !== undefined) body.frequency_penalty = ctx.input.frequencyPenalty; if (ctx.input.stop !== undefined) body.stop = ctx.input.stop; + if (ctx.input.customModelParams !== undefined) + body.customModelParams = ctx.input.customModelParams; let result = await client.runAgent(body, ctx.input.llmProviderKey); diff --git a/integrations/langbase/src/tools/run-pipe.ts b/integrations/langbase/src/tools/run-pipe.ts index e9e7356167..0628be3f34 100644 --- a/integrations/langbase/src/tools/run-pipe.ts +++ b/integrations/langbase/src/tools/run-pipe.ts @@ -2,10 +2,14 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; +import { functionToolSchema, mapFunctionTools, mapMemoryNames } from './shared'; let messageSchema = z.object({ role: z.enum(['user', 'assistant', 'system', 'tool']).describe('Role of the message sender'), - content: z.string().describe('Content of the message'), + content: z + .string() + .nullable() + .describe('Content of the message. Tool messages may use null.'), name: z.string().optional().describe('Name identifier'), toolCallId: z.string().optional().describe('Tool call ID for tool response messages') }); @@ -41,7 +45,15 @@ export let runPipe = SlateTool.create(spec, { llmProviderKey: z .string() .optional() - .describe('LLM provider API key to override the default one configured in the pipe') + .describe('LLM provider API key to override the default one configured in the pipe'), + memoryNames: z + .array(z.string()) + .optional() + .describe('Memory names to use at runtime instead of the pipe default memories'), + tools: z + .array(functionToolSchema) + .optional() + .describe('Function tools the model may call during this run') }) ) .output( @@ -68,6 +80,8 @@ export let runPipe = SlateTool.create(spec, { if (ctx.input.threadId) body.threadId = ctx.input.threadId; if (ctx.input.variables) body.variables = ctx.input.variables; + if (ctx.input.memoryNames) body.memory = mapMemoryNames(ctx.input.memoryNames); + if (ctx.input.tools) body.tools = mapFunctionTools(ctx.input.tools); let result = await client.runPipe(body, { pipeApiKey: ctx.input.pipeApiKey, diff --git a/integrations/langbase/src/tools/shared.ts b/integrations/langbase/src/tools/shared.ts new file mode 100644 index 0000000000..4a8b907f77 --- /dev/null +++ b/integrations/langbase/src/tools/shared.ts @@ -0,0 +1,80 @@ +import { Buffer } from 'node:buffer'; +import { createApiServiceError } from 'slates'; +import { z } from 'zod'; + +export let functionToolSchema = z.object({ + name: z.string().describe('Function name exposed to the model'), + description: z.string().optional().describe('Function description exposed to the model'), + parameters: z + .record(z.string(), z.any()) + .optional() + .describe('JSON Schema parameters for the function') +}); + +export let mapFunctionTools = (tools: z.infer[] | undefined) => + tools?.map(tool => ({ + type: 'function', + function: { + name: tool.name, + ...(tool.description !== undefined ? { description: tool.description } : {}), + ...(tool.parameters !== undefined ? { parameters: tool.parameters } : {}) + } + })); + +export let mapMemoryNames = (memoryNames: string[] | undefined) => + memoryNames?.map(name => ({ name })); + +export let requireExactlyOneDefined = ( + input: Record, + firstField: string, + secondField: string, + message: string +) => { + let hasFirst = input[firstField] !== undefined; + let hasSecond = input[secondField] !== undefined; + + if (hasFirst === hasSecond) { + throw createApiServiceError(message); + } + + return hasFirst ? firstField : secondField; +}; + +type DocumentContentInput = { + contentBase64?: string; + contentText?: string; +}; + +export let parseContentFromInput = (input: DocumentContentInput) => { + let source = requireExactlyOneDefined( + input, + 'contentBase64', + 'contentText', + 'Provide exactly one of contentBase64 or contentText.' + ); + + if (source === 'contentBase64') { + return Buffer.from(input.contentBase64!, 'base64'); + } + + return input.contentText ?? ''; +}; + +export let documentContentFromInput = (input: DocumentContentInput) => { + let source = requireExactlyOneDefined( + input, + 'contentBase64', + 'contentText', + 'Provide exactly one of contentBase64 or contentText.' + ); + + if (source === 'contentBase64') { + return { + contentBase64: Buffer.from(input.contentBase64!, 'base64').toString('base64') + }; + } + + return { + contentText: input.contentText ?? '' + }; +}; diff --git a/integrations/langbase/src/tools/update-pipe.ts b/integrations/langbase/src/tools/update-pipe.ts index dfa6f823eb..35320cf5cf 100644 --- a/integrations/langbase/src/tools/update-pipe.ts +++ b/integrations/langbase/src/tools/update-pipe.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; +import { functionToolSchema, mapFunctionTools, mapMemoryNames } from './shared'; let messageSchema = z.object({ role: z.enum(['system', 'user', 'assistant']).describe('Role of the message sender'), @@ -42,9 +43,29 @@ export let updatePipe = SlateTool.create(spec, { .optional() .describe('System prompts and few-shot examples'), variables: z + .record(z.string(), z.string()) + .optional() + .describe('Key-value pairs for variable substitution in pipe prompts'), + tools: z + .array(functionToolSchema) + .optional() + .describe('Function tools the model may call from this pipe'), + toolChoice: z + .enum(['auto', 'required']) + .optional() + .describe('Controls whether the model may or must call tools'), + parallelToolCalls: z + .boolean() + .optional() + .describe('Whether the model may call multiple tools in parallel'), + memoryNames: z .array(z.string()) .optional() - .describe('Variable names used in the pipe prompts') + .describe('Names of Langbase memories attached to this pipe'), + responseFormat: z + .enum(['text', 'json_object']) + .optional() + .describe('Response format for the pipe output') }) ) .output( @@ -78,6 +99,14 @@ export let updatePipe = SlateTool.create(spec, { if (ctx.input.stop !== undefined) body.stop = ctx.input.stop; if (ctx.input.messages !== undefined) body.messages = ctx.input.messages; if (ctx.input.variables !== undefined) body.variables = ctx.input.variables; + if (ctx.input.tools !== undefined) body.tools = mapFunctionTools(ctx.input.tools); + if (ctx.input.toolChoice !== undefined) body.tool_choice = ctx.input.toolChoice; + if (ctx.input.parallelToolCalls !== undefined) + body.parallel_tool_calls = ctx.input.parallelToolCalls; + if (ctx.input.memoryNames !== undefined) + body.memory = mapMemoryNames(ctx.input.memoryNames); + if (ctx.input.responseFormat !== undefined) + body.response_format = { type: ctx.input.responseFormat }; let result = await client.updatePipe(ctx.input.pipeName, body); diff --git a/integrations/langbase/src/tools/upload-document.ts b/integrations/langbase/src/tools/upload-document.ts new file mode 100644 index 0000000000..6896e10314 --- /dev/null +++ b/integrations/langbase/src/tools/upload-document.ts @@ -0,0 +1,66 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; +import { documentContentFromInput } from './shared'; + +export let uploadDocument = SlateTool.create(spec, { + name: 'Upload Document', + key: 'upload_document', + description: `Upload or replace a document in a Langbase memory. Langbase returns a signed upload URL and the tool uploads the content to that URL, making the document available for memory processing and retrieval.`, + constraints: [ + 'Provide exactly one of contentBase64 or contentText.', + 'Supported MIME types include text/plain, text/markdown, text/csv, application/pdf, and spreadsheet formats.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + memoryName: z.string().describe('Name of the memory to upload the document to'), + documentName: z + .string() + .describe('Document name including extension, e.g. "notes.md" or "report.pdf"'), + contentType: z.string().describe('MIME type for the uploaded document'), + contentBase64: z.string().optional().describe('Base64-encoded document content'), + contentText: z.string().optional().describe('UTF-8 text document content'), + meta: z + .record(z.string(), z.string()) + .optional() + .describe('String metadata for the document, maximum 10 key-value pairs') + }) + ) + .output( + z.object({ + memoryName: z.string().describe('Memory the document was uploaded to'), + documentName: z.string().describe('Uploaded document name'), + ok: z.boolean().describe('Whether the signed URL upload succeeded'), + status: z.number().describe('Signed URL upload HTTP status'), + statusText: z.string().describe('Signed URL upload HTTP status text') + }) + ) + .handleInvocation(async ctx => { + let client = new Client(ctx.auth.token); + let content = documentContentFromInput(ctx.input); + let result = await client.uploadDocument({ + memoryName: ctx.input.memoryName, + documentName: ctx.input.documentName, + contentType: ctx.input.contentType, + meta: ctx.input.meta, + ...content + }); + + return { + output: { + memoryName: ctx.input.memoryName, + documentName: ctx.input.documentName, + ok: result.ok, + status: result.status, + statusText: result.statusText + }, + message: `Uploaded document **${ctx.input.documentName}** to memory **${ctx.input.memoryName}**.` + }; + }) + .build(); diff --git a/integrations/langbase/src/triggers/inbound-webhook.ts b/integrations/langbase/src/triggers/inbound-webhook.ts deleted file mode 100644 index a4ba5368cc..0000000000 --- a/integrations/langbase/src/triggers/inbound-webhook.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { SlateTrigger } from 'slates'; -import { z } from 'zod'; -import { spec } from '../spec'; - -/** - * Generic inbound webhook for providers without a tailored webhook trigger yet. - * POST JSON is parsed into `payload` (non-objects are wrapped as { _value }). - * Refine in the workflow mapper or replace with a provider-specific trigger. - */ -export let inboundWebhook = SlateTrigger.create(spec, { - name: 'Inbound Webhook', - key: 'inbound_webhook', - description: - 'Receives HTTP POST at the Slates webhook URL. Parses JSON into payload (or stores raw body if not JSON). Configure your provider to POST here when supported.' -}) - .input( - z.object({ - payload: z - .record(z.string(), z.any()) - .describe('Parsed JSON object from the request body'), - rawBody: z.string().optional().describe('Raw body when JSON parsing failed'), - contentType: z.string().optional().describe('Content-Type header') - }) - ) - .output( - z.object({ - payload: z.record(z.string(), z.any()), - rawBody: z.string().optional() - }) - ) - .webhook({ - handleRequest: async ctx => { - let contentType = ctx.request.headers.get('content-type') ?? ''; - let text = await ctx.request.text(); - if (!text?.trim()) { - return { - inputs: [{ payload: {}, contentType }] - }; - } - try { - let parsed = JSON.parse(text); - let payload = - parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) - ? parsed - : { _value: parsed }; - return { - inputs: [{ payload, contentType }] - }; - } catch { - return { - inputs: [{ payload: {}, rawBody: text, contentType }] - }; - } - }, - - handleEvent: async ctx => { - return { - type: 'webhook.inbound', - id: `inbound-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - output: { - payload: ctx.input.payload, - rawBody: ctx.input.rawBody - } - }; - } - }) - .build(); diff --git a/integrations/langbase/src/triggers/index.ts b/integrations/langbase/src/triggers/index.ts deleted file mode 100644 index 39885238b2..0000000000 --- a/integrations/langbase/src/triggers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Langbase does not support webhooks, event subscriptions, or any polling mechanism. -// No triggers are available for this provider. -export * from './inbound-webhook'; diff --git a/integrations/langbase/vitest.config.ts b/integrations/langbase/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/langbase/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/lemon-squeezy/README.md b/integrations/lemon-squeezy/README.md index 8149ad4a7b..5ffa96e120 100644 --- a/integrations/lemon-squeezy/README.md +++ b/integrations/lemon-squeezy/README.md @@ -1,6 +1,6 @@ # Lemon Squeezy -Manage digital product sales, subscriptions, and license keys. Create and retrieve stores, products, variants, and customers. Generate custom checkout URLs with pre-filled data, discounts, and custom metadata. Process and refund orders. Create, update, cancel, pause, resume, and upgrade/downgrade subscriptions. Issue and manage discount codes with percentage or fixed-amount rules. Generate, validate, activate, and deactivate software license keys. Manage digital file delivery. Configure webhooks to receive events for orders, subscriptions, payments, license keys, and affiliates. +Manage digital product sales, subscriptions, customers, discounts, webhooks, and license keys. Retrieve stores, products, variants, prices, files, checkouts, webhooks, orders, order items, subscription invoices, subscription items, customers, discounts, discount redemptions, and license keys. Generate custom checkout URLs with pre-filled data, discounts, and custom metadata. Create and archive customers, issue and delete discount codes, refund orders, update license key settings, and create, update, or delete webhooks for Lemon Squeezy event delivery. ## License diff --git a/integrations/lemon-squeezy/docs/SPEC.md b/integrations/lemon-squeezy/docs/SPEC.md index f8cb9790ef..1a6efb27b7 100644 --- a/integrations/lemon-squeezy/docs/SPEC.md +++ b/integrations/lemon-squeezy/docs/SPEC.md @@ -1,10 +1,8 @@ -Let me get the full list of webhook event types.Now I have comprehensive information. Let me compile the specification. - # Slates Specification for Lemon Squeezy ## Overview -Lemon Squeezy is a merchant-of-record platform for selling digital products, software, and subscriptions. It handles payments, tax compliance, subscription billing, license key management, and digital product delivery. The API allows programmatic management of stores, products, orders, customers, subscriptions, discounts, license keys, and checkouts. +Lemon Squeezy is a merchant-of-record platform for selling digital products, software, and subscriptions. It handles payments, tax compliance, subscription billing, license key management, and digital product delivery. The integration exposes high-value API operations for stores, products, variants, prices, files, checkouts, webhooks, orders, order items, customers, subscriptions, subscription invoices, subscription items, discounts, discount redemptions, and license keys. ## Authentication @@ -28,10 +26,14 @@ You can build and test a full API integration with Lemon Squeezy using Test Mode Retrieve information about your Lemon Squeezy stores, including store details and settings. A store is the top-level entity that contains all products, orders, and other resources. -### Product & Variant Management +### Product, Variant, Price & File Management The API covers all data types used in your store such as Products, Customers, Discounts and Files. You can use the API to manage your store as well as set up payments for customers, access prior orders and manage ongoing subscriptions and software license keys. Products can have multiple variants representing different pricing tiers or configurations. +- List products and variants, with product filtering for variants. +- List prices for variants, including package, renewal, setup fee, trial, and tier metadata. +- List files attached to variants, including file names, identifiers, version, size, status, and download URL metadata. + ### Checkout Creation A checkout represents a custom checkout page. Checkouts can be used to customize the checkout experience for a specific variant/product without having to create a new product in the dashboard. @@ -43,7 +45,7 @@ A checkout represents a custom checkout page. Checkouts can be used to customize ### Order Management -Access and manage orders placed in your store. Orders contain details about the customer, pricing (including tax and discounts), currency, and status. You can also issue refunds for orders via the API. +Access and manage orders placed in your store. Orders contain details about the customer, pricing (including tax and discounts), currency, and status. You can also list order items and issue refunds for orders via the API. ### Subscription Management @@ -52,7 +54,7 @@ The API covers essential subscription management tasks. You'll learn how to prog - Change subscription plans (upgrade/downgrade) by updating the variant. - Cancel and resume subscriptions during the grace period. - Pausing a subscription is a great option if you want to keep a subscription active but pause regular payment collection. -- Access subscription invoices for billing history. +- Access subscription invoices for billing history, including status and refund state filters. - Track usage-based billing through subscription items. ### Customer Management @@ -72,8 +74,8 @@ Create and manage discount codes for your store. License keys are a feature in Lemon Squeezy, which lets you control access to your external application via orders and subscriptions. You can turn on license keys at a product and variant level. You have options for length of license and how many activations are allowed for each license. -- Validate, activate, and deactivate license keys through a separate License API (does not require API key authentication). - Retrieve and list license keys and their activation instances. +- Update administrative license key settings such as activation limits, disabled state, and expiration. - If your product is a subscription, the license length is tied to the subscription's lifecycle. When a subscription becomes expired, the related license key's status will automatically become expired. ### File Management diff --git a/integrations/lemon-squeezy/package.json b/integrations/lemon-squeezy/package.json index 18968341e6..87e37a5db8 100644 --- a/integrations/lemon-squeezy/package.json +++ b/integrations/lemon-squeezy/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/lemon-squeezy/src/index.ts b/integrations/lemon-squeezy/src/index.ts index ac056d550a..aa3117cdb4 100644 --- a/integrations/lemon-squeezy/src/index.ts +++ b/integrations/lemon-squeezy/src/index.ts @@ -4,16 +4,27 @@ import { createCheckoutTool, createDiscountTool, getOrderTool, + listCheckoutsTool, listCustomersTool, + listDiscountRedemptionsTool, listDiscountsTool, + listFilesTool, listLicenseKeysTool, + listOrderItemsTool, listOrdersTool, + listPricesTool, listProductsTool, listStoresTool, + listSubscriptionInvoicesTool, + listSubscriptionItemsTool, listSubscriptionsTool, listVariantsTool, + listWebhooksTool, + manageCustomerTool, + manageDiscountTool, manageLicenseKeyTool, manageSubscriptionTool, + manageWebhookTool, refundOrderTool } from './tools'; import { @@ -29,14 +40,25 @@ export let provider = Slate.create({ listStoresTool, listProductsTool, listVariantsTool, + listPricesTool, + listFilesTool, getOrderTool, listOrdersTool, + listOrderItemsTool, refundOrderTool, createCheckoutTool, + listCheckoutsTool, + manageCustomerTool, + listWebhooksTool, + manageWebhookTool, manageSubscriptionTool, listSubscriptionsTool, + listSubscriptionInvoicesTool, + listSubscriptionItemsTool, createDiscountTool, + manageDiscountTool, listDiscountsTool, + listDiscountRedemptionsTool, listCustomersTool, manageLicenseKeyTool, listLicenseKeysTool diff --git a/integrations/lemon-squeezy/src/lib/client.ts b/integrations/lemon-squeezy/src/lib/client.ts index 8f3f48c722..b038a5b3fd 100644 --- a/integrations/lemon-squeezy/src/lib/client.ts +++ b/integrations/lemon-squeezy/src/lib/client.ts @@ -1,4 +1,9 @@ import { createAxios } from 'slates'; +import { lemonSqueezyApiError } from './errors'; + +type JsonApiResponse = { + data: T; +}; export class Client { private http: ReturnType; @@ -14,106 +19,151 @@ export class Client { }); } - // ── Users ── + private async request( + operation: string, + run: () => Promise> + ): Promise { + try { + let response = await run(); + return response.data; + } catch (error) { + throw lemonSqueezyApiError(error, operation); + } + } + + private async requestVoid(operation: string, run: () => Promise) { + try { + await run(); + } catch (error) { + throw lemonSqueezyApiError(error, operation); + } + } + + // Users async getUser() { - let response = await this.http.get('/users/me'); - return response.data; + return await this.request('retrieve user', () => this.http.get('/users/me')); } - // ── Stores ── + // Stores async getStore(storeId: string) { - let response = await this.http.get(`/stores/${storeId}`); - return response.data; + return await this.request('retrieve store', () => this.http.get(`/stores/${storeId}`)); } async listStores(params?: ListParams) { - let response = await this.http.get('/stores', { params: buildListParams(params) }); - return response.data; + return await this.request('list stores', () => + this.http.get('/stores', { params: buildListParams(params) }) + ); } - // ── Products ── + // Products async getProduct(productId: string) { - let response = await this.http.get(`/products/${productId}`); - return response.data; + return await this.request('retrieve product', () => + this.http.get(`/products/${productId}`) + ); } async listProducts(params?: ListParams & { storeId?: string }) { let queryParams = buildListParams(params); if (params?.storeId) queryParams['filter[store_id]'] = params.storeId; - let response = await this.http.get('/products', { params: queryParams }); - return response.data; + return await this.request('list products', () => + this.http.get('/products', { params: queryParams }) + ); } - // ── Variants ── + // Variants async getVariant(variantId: string) { - let response = await this.http.get(`/variants/${variantId}`); - return response.data; + return await this.request('retrieve variant', () => + this.http.get(`/variants/${variantId}`) + ); } async listVariants(params?: ListParams & { productId?: string }) { let queryParams = buildListParams(params); if (params?.productId) queryParams['filter[product_id]'] = params.productId; - let response = await this.http.get('/variants', { params: queryParams }); - return response.data; + return await this.request('list variants', () => + this.http.get('/variants', { params: queryParams }) + ); } - // ── Orders ── + // Prices + + async getPrice(priceId: string) { + return await this.request('retrieve price', () => this.http.get(`/prices/${priceId}`)); + } + + async listPrices(params?: ListParams & { variantId?: string }) { + let queryParams = buildListParams(params); + if (params?.variantId) queryParams['filter[variant_id]'] = params.variantId; + return await this.request('list prices', () => + this.http.get('/prices', { params: queryParams }) + ); + } + + // Orders async getOrder(orderId: string) { - let response = await this.http.get(`/orders/${orderId}`); - return response.data; + return await this.request('retrieve order', () => this.http.get(`/orders/${orderId}`)); } async listOrders(params?: ListParams & { storeId?: string; userEmail?: string }) { let queryParams = buildListParams(params); if (params?.storeId) queryParams['filter[store_id]'] = params.storeId; if (params?.userEmail) queryParams['filter[user_email]'] = params.userEmail; - let response = await this.http.get('/orders', { params: queryParams }); - return response.data; + return await this.request('list orders', () => + this.http.get('/orders', { params: queryParams }) + ); } async refundOrder(orderId: string, amount?: number) { - let response = await this.http.post( - `/orders/${orderId}/refund`, - amount - ? { - data: { - type: 'orders', - id: orderId, - attributes: { amount } + return await this.request('refund order', () => + this.http.post( + `/orders/${orderId}/refund`, + amount + ? { + data: { + type: 'orders', + id: orderId, + attributes: { amount } + } } - } - : undefined + : undefined + ) ); - return response.data; } - // ── Order Items ── + // Order Items - async listOrderItems(params?: ListParams & { orderId?: string }) { + async listOrderItems( + params?: ListParams & { orderId?: string; productId?: string; variantId?: string } + ) { let queryParams = buildListParams(params); if (params?.orderId) queryParams['filter[order_id]'] = params.orderId; - let response = await this.http.get('/order-items', { params: queryParams }); - return response.data; + if (params?.productId) queryParams['filter[product_id]'] = params.productId; + if (params?.variantId) queryParams['filter[variant_id]'] = params.variantId; + return await this.request('list order items', () => + this.http.get('/order-items', { params: queryParams }) + ); } - // ── Customers ── + // Customers async getCustomer(customerId: string) { - let response = await this.http.get(`/customers/${customerId}`); - return response.data; + return await this.request('retrieve customer', () => + this.http.get(`/customers/${customerId}`) + ); } async listCustomers(params?: ListParams & { storeId?: string; email?: string }) { let queryParams = buildListParams(params); if (params?.storeId) queryParams['filter[store_id]'] = params.storeId; if (params?.email) queryParams['filter[email]'] = params.email; - let response = await this.http.get('/customers', { params: queryParams }); - return response.data; + return await this.request('list customers', () => + this.http.get('/customers', { params: queryParams }) + ); } async createCustomer( @@ -126,34 +176,37 @@ export class Client { country?: string; } ) { - let response = await this.http.post('/customers', { - data: { - type: 'customers', - attributes, - relationships: { - store: { data: { type: 'stores', id: storeId } } + return await this.request('create customer', () => + this.http.post('/customers', { + data: { + type: 'customers', + attributes, + relationships: { + store: { data: { type: 'stores', id: storeId } } + } } - } - }); - return response.data; + }) + ); } async updateCustomer(customerId: string, attributes: Record) { - let response = await this.http.patch(`/customers/${customerId}`, { - data: { - type: 'customers', - id: customerId, - attributes - } - }); - return response.data; + return await this.request('update customer', () => + this.http.patch(`/customers/${customerId}`, { + data: { + type: 'customers', + id: customerId, + attributes + } + }) + ); } - // ── Subscriptions ── + // Subscriptions async getSubscription(subscriptionId: string) { - let response = await this.http.get(`/subscriptions/${subscriptionId}`); - return response.data; + return await this.request('retrieve subscription', () => + this.http.get(`/subscriptions/${subscriptionId}`) + ); } async listSubscriptions( @@ -171,56 +224,83 @@ export class Client { if (params?.productId) queryParams['filter[product_id]'] = params.productId; if (params?.variantId) queryParams['filter[variant_id]'] = params.variantId; if (params?.status) queryParams['filter[status]'] = params.status; - let response = await this.http.get('/subscriptions', { params: queryParams }); - return response.data; + return await this.request('list subscriptions', () => + this.http.get('/subscriptions', { params: queryParams }) + ); } async updateSubscription(subscriptionId: string, attributes: Record) { - let response = await this.http.patch(`/subscriptions/${subscriptionId}`, { - data: { - type: 'subscriptions', - id: subscriptionId, - attributes - } - }); - return response.data; + return await this.request('update subscription', () => + this.http.patch(`/subscriptions/${subscriptionId}`, { + data: { + type: 'subscriptions', + id: subscriptionId, + attributes + } + }) + ); } async cancelSubscription(subscriptionId: string) { - let response = await this.http.delete(`/subscriptions/${subscriptionId}`); - return response.data; + return await this.request('cancel subscription', () => + this.http.delete(`/subscriptions/${subscriptionId}`) + ); } - // ── Subscription Invoices ── + // Subscription Invoices async listSubscriptionInvoices( - params?: ListParams & { storeId?: string; subscriptionId?: string; status?: string } + params?: ListParams & { + storeId?: string; + subscriptionId?: string; + status?: string; + refunded?: boolean; + } ) { let queryParams = buildListParams(params); if (params?.storeId) queryParams['filter[store_id]'] = params.storeId; if (params?.subscriptionId) queryParams['filter[subscription_id]'] = params.subscriptionId; if (params?.status) queryParams['filter[status]'] = params.status; - let response = await this.http.get('/subscription-invoices', { params: queryParams }); - return response.data; + if (params?.refunded !== undefined) + queryParams['filter[refunded]'] = String(params.refunded); + return await this.request('list subscription invoices', () => + this.http.get('/subscription-invoices', { params: queryParams }) + ); } async getSubscriptionInvoice(invoiceId: string) { - let response = await this.http.get(`/subscription-invoices/${invoiceId}`); - return response.data; + return await this.request('retrieve subscription invoice', () => + this.http.get(`/subscription-invoices/${invoiceId}`) + ); } - // ── Discounts ── + // Subscription Items + + async listSubscriptionItems( + params?: ListParams & { subscriptionId?: string; priceId?: string } + ) { + let queryParams = buildListParams(params); + if (params?.subscriptionId) queryParams['filter[subscription_id]'] = params.subscriptionId; + if (params?.priceId) queryParams['filter[price_id]'] = params.priceId; + return await this.request('list subscription items', () => + this.http.get('/subscription-items', { params: queryParams }) + ); + } + + // Discounts async getDiscount(discountId: string) { - let response = await this.http.get(`/discounts/${discountId}`); - return response.data; + return await this.request('retrieve discount', () => + this.http.get(`/discounts/${discountId}`) + ); } async listDiscounts(params?: ListParams & { storeId?: string }) { let queryParams = buildListParams(params); if (params?.storeId) queryParams['filter[store_id]'] = params.storeId; - let response = await this.http.get('/discounts', { params: queryParams }); - return response.data; + return await this.request('list discounts', () => + this.http.get('/discounts', { params: queryParams }) + ); } async createDiscount( @@ -251,38 +331,52 @@ export class Client { }; } - let response = await this.http.post('/discounts', { - data: { - type: 'discounts', - attributes: { - name: attributes.name, - code: attributes.code, - amount: attributes.amount, - amount_type: attributes.amountType, - is_limited_to_products: attributes.isLimitedToProducts, - is_limited_redemptions: attributes.isLimitedRedemptions, - max_redemptions: attributes.maxRedemptions, - starts_at: attributes.startsAt, - expires_at: attributes.expiresAt, - duration: attributes.duration, - duration_in_months: attributes.durationInMonths, - test_mode: attributes.testMode - }, - relationships - } - }); - return response.data; + return await this.request('create discount', () => + this.http.post('/discounts', { + data: { + type: 'discounts', + attributes: { + name: attributes.name, + code: attributes.code, + amount: attributes.amount, + amount_type: attributes.amountType, + is_limited_to_products: attributes.isLimitedToProducts, + is_limited_redemptions: attributes.isLimitedRedemptions, + max_redemptions: attributes.maxRedemptions, + starts_at: attributes.startsAt, + expires_at: attributes.expiresAt, + duration: attributes.duration, + duration_in_months: attributes.durationInMonths, + test_mode: attributes.testMode + }, + relationships + } + }) + ); } async deleteDiscount(discountId: string) { - await this.http.delete(`/discounts/${discountId}`); + await this.requestVoid('delete discount', () => + this.http.delete(`/discounts/${discountId}`) + ); + } + + // Discount Redemptions + + async listDiscountRedemptions(params?: ListParams & { discountId?: string }) { + let queryParams = buildListParams(params); + if (params?.discountId) queryParams['filter[discount_id]'] = params.discountId; + return await this.request('list discount redemptions', () => + this.http.get('/discount-redemptions', { params: queryParams }) + ); } - // ── License Keys ── + // License Keys async getLicenseKey(licenseKeyId: string) { - let response = await this.http.get(`/license-keys/${licenseKeyId}`); - return response.data; + return await this.request('retrieve license key', () => + this.http.get(`/license-keys/${licenseKeyId}`) + ); } async listLicenseKeys( @@ -292,31 +386,34 @@ export class Client { if (params?.storeId) queryParams['filter[store_id]'] = params.storeId; if (params?.orderId) queryParams['filter[order_id]'] = params.orderId; if (params?.productId) queryParams['filter[product_id]'] = params.productId; - let response = await this.http.get('/license-keys', { params: queryParams }); - return response.data; + return await this.request('list license keys', () => + this.http.get('/license-keys', { params: queryParams }) + ); } async updateLicenseKey(licenseKeyId: string, attributes: Record) { - let response = await this.http.patch(`/license-keys/${licenseKeyId}`, { - data: { - type: 'license-keys', - id: licenseKeyId, - attributes - } - }); - return response.data; + return await this.request('update license key', () => + this.http.patch(`/license-keys/${licenseKeyId}`, { + data: { + type: 'license-keys', + id: licenseKeyId, + attributes + } + }) + ); } - // ── License Key Instances ── + // License Key Instances async listLicenseKeyInstances(params?: ListParams & { licenseKeyId?: string }) { let queryParams = buildListParams(params); if (params?.licenseKeyId) queryParams['filter[license_key_id]'] = params.licenseKeyId; - let response = await this.http.get('/license-key-instances', { params: queryParams }); - return response.data; + return await this.request('list license key instances', () => + this.http.get('/license-key-instances', { params: queryParams }) + ); } - // ── Checkouts ── + // Checkouts async createCheckout( storeId: string, @@ -331,54 +428,64 @@ export class Client { testMode?: boolean; } ) { - let response = await this.http.post('/checkouts', { - data: { - type: 'checkouts', - attributes: { - custom_price: attributes?.customPrice, - product_options: attributes?.productOptions, - checkout_options: attributes?.checkoutOptions, - checkout_data: attributes?.checkoutData, - expires_at: attributes?.expiresAt, - preview: attributes?.preview, - test_mode: attributes?.testMode - }, - relationships: { - store: { data: { type: 'stores', id: storeId } }, - variant: { data: { type: 'variants', id: variantId } } + return await this.request('create checkout', () => + this.http.post('/checkouts', { + data: { + type: 'checkouts', + attributes: { + custom_price: attributes?.customPrice, + product_options: attributes?.productOptions, + checkout_options: attributes?.checkoutOptions, + checkout_data: attributes?.checkoutData, + expires_at: attributes?.expiresAt, + preview: attributes?.preview, + test_mode: attributes?.testMode + }, + relationships: { + store: { data: { type: 'stores', id: storeId } }, + variant: { data: { type: 'variants', id: variantId } } + } } - } - }); - return response.data; + }) + ); } async getCheckout(checkoutId: string) { - let response = await this.http.get(`/checkouts/${checkoutId}`); - return response.data; + return await this.request('retrieve checkout', () => + this.http.get(`/checkouts/${checkoutId}`) + ); } - async listCheckouts(params?: ListParams & { storeId?: string }) { + async listCheckouts(params?: ListParams & { storeId?: string; variantId?: string }) { let queryParams = buildListParams(params); if (params?.storeId) queryParams['filter[store_id]'] = params.storeId; - let response = await this.http.get('/checkouts', { params: queryParams }); - return response.data; + if (params?.variantId) queryParams['filter[variant_id]'] = params.variantId; + return await this.request('list checkouts', () => + this.http.get('/checkouts', { params: queryParams }) + ); } - // ── Files ── + // Files async getFile(fileId: string) { - let response = await this.http.get(`/files/${fileId}`); - return response.data; + return await this.request('retrieve file', () => this.http.get(`/files/${fileId}`)); } async listFiles(params?: ListParams & { variantId?: string }) { let queryParams = buildListParams(params); if (params?.variantId) queryParams['filter[variant_id]'] = params.variantId; - let response = await this.http.get('/files', { params: queryParams }); - return response.data; + return await this.request('list files', () => + this.http.get('/files', { params: queryParams }) + ); } - // ── Webhooks ── + // Webhooks + + async getWebhook(webhookId: string) { + return await this.request('retrieve webhook', () => + this.http.get(`/webhooks/${webhookId}`) + ); + } async createWebhook( storeId: string, @@ -387,60 +494,52 @@ export class Client { secret: string, testMode?: boolean ) { - let response = await this.http.post('/webhooks', { - data: { - type: 'webhooks', - attributes: { - url, - events, - secret, - test_mode: testMode - }, - relationships: { - store: { data: { type: 'stores', id: storeId } } + return await this.request('create webhook', () => + this.http.post('/webhooks', { + data: { + type: 'webhooks', + attributes: { + url, + events, + secret, + test_mode: testMode + }, + relationships: { + store: { data: { type: 'stores', id: storeId } } + } } - } - }); - return response.data; + }) + ); } async updateWebhook( webhookId: string, attributes: { url?: string; events?: string[]; secret?: string } ) { - let response = await this.http.patch(`/webhooks/${webhookId}`, { - data: { - type: 'webhooks', - id: webhookId, - attributes - } - }); - return response.data; + return await this.request('update webhook', () => + this.http.patch(`/webhooks/${webhookId}`, { + data: { + type: 'webhooks', + id: webhookId, + attributes + } + }) + ); } async deleteWebhook(webhookId: string) { - await this.http.delete(`/webhooks/${webhookId}`); + await this.requestVoid('delete webhook', () => this.http.delete(`/webhooks/${webhookId}`)); } async listWebhooks(params?: ListParams & { storeId?: string }) { let queryParams = buildListParams(params); if (params?.storeId) queryParams['filter[store_id]'] = params.storeId; - let response = await this.http.get('/webhooks', { params: queryParams }); - return response.data; - } - - // ── Discount Redemptions ── - - async listDiscountRedemptions(params?: ListParams & { discountId?: string }) { - let queryParams = buildListParams(params); - if (params?.discountId) queryParams['filter[discount_id]'] = params.discountId; - let response = await this.http.get('/discount-redemptions', { params: queryParams }); - return response.data; + return await this.request('list webhooks', () => + this.http.get('/webhooks', { params: queryParams }) + ); } } -// ── Helpers ── - interface ListParams { page?: number; perPage?: number; diff --git a/integrations/lemon-squeezy/src/lib/errors.ts b/integrations/lemon-squeezy/src/lib/errors.ts new file mode 100644 index 0000000000..508c574908 --- /dev/null +++ b/integrations/lemon-squeezy/src/lib/errors.ts @@ -0,0 +1,79 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) details.push(detail); +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) collectDetails(item, details); + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.error); + addDetail(details, value.error_description); + addDetail(details, value.title); + addDetail(details, value.detail); + addDetail(details, value.code); + collectDetails(value.errors, details); +}; + +let extractLemonSqueezyMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) return details.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) return undefined; + + let code = response.data.code ?? response.data.error; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +}; + +export let lemonSqueezyServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let lemonSqueezyApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = lemonSqueezyServiceError( + `Lemon Squeezy API ${operation} failed: ${statusLabelFor(response)}${extractLemonSqueezyMessage(error)}` + ); + serviceError.data.reason = 'lemon_squeezy_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) serviceError.setParent(error); + + return serviceError; +}; diff --git a/integrations/lemon-squeezy/src/spec.ts b/integrations/lemon-squeezy/src/spec.ts index d120771e1f..b49c5a17b2 100644 --- a/integrations/lemon-squeezy/src/spec.ts +++ b/integrations/lemon-squeezy/src/spec.ts @@ -6,7 +6,7 @@ export let spec = SlateSpecification.create({ key: 'lemon-squeezy', name: 'Lemon Squeezy', description: - 'Lemon Squeezy is a merchant-of-record platform for selling digital products, software, and subscriptions. Manage stores, products, orders, customers, subscriptions, discounts, license keys, and checkouts.', + 'Lemon Squeezy is a merchant-of-record platform for selling digital products, software, and subscriptions. Manage stores, products, variants, prices, files, orders, order items, customers, subscriptions, subscription invoices, subscription items, discounts, discount redemptions, license keys, checkouts, and webhooks.', metadata: {}, config, auth diff --git a/integrations/lemon-squeezy/src/tools.schema.test.ts b/integrations/lemon-squeezy/src/tools.schema.test.ts new file mode 100644 index 0000000000..4642713a6c --- /dev/null +++ b/integrations/lemon-squeezy/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Lemon Squeezy tool input schemas', provider.actions); diff --git a/integrations/lemon-squeezy/src/tools/create-checkout.ts b/integrations/lemon-squeezy/src/tools/create-checkout.ts index 81b243c82b..9ca1e95389 100644 --- a/integrations/lemon-squeezy/src/tools/create-checkout.ts +++ b/integrations/lemon-squeezy/src/tools/create-checkout.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { lemonSqueezyServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createCheckoutTool = SlateTool.create(spec, { @@ -62,7 +63,7 @@ export let createCheckoutTool = SlateTool.create(spec, { let storeId = ctx.input.storeId || ctx.config.storeId; if (!storeId) { - throw new Error( + throw lemonSqueezyServiceError( 'Store ID is required. Provide it in the input or configure it in the provider settings.' ); } diff --git a/integrations/lemon-squeezy/src/tools/create-discount.ts b/integrations/lemon-squeezy/src/tools/create-discount.ts index 5d09269867..f6d82da73f 100644 --- a/integrations/lemon-squeezy/src/tools/create-discount.ts +++ b/integrations/lemon-squeezy/src/tools/create-discount.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { lemonSqueezyServiceError } from '../lib/errors'; import { spec } from '../spec'; export let createDiscountTool = SlateTool.create(spec, { @@ -67,7 +68,7 @@ export let createDiscountTool = SlateTool.create(spec, { let storeId = ctx.input.storeId || ctx.config.storeId; if (!storeId) { - throw new Error( + throw lemonSqueezyServiceError( 'Store ID is required. Provide it in the input or configure it in the provider settings.' ); } diff --git a/integrations/lemon-squeezy/src/tools/index.ts b/integrations/lemon-squeezy/src/tools/index.ts index b79b81f873..7842b85f8c 100644 --- a/integrations/lemon-squeezy/src/tools/index.ts +++ b/integrations/lemon-squeezy/src/tools/index.ts @@ -1,14 +1,25 @@ export * from './create-checkout'; export * from './create-discount'; export * from './get-order'; +export * from './list-checkouts'; export * from './list-customers'; +export * from './list-discount-redemptions'; export * from './list-discounts'; +export * from './list-files'; export * from './list-license-keys'; +export * from './list-order-items'; export * from './list-orders'; +export * from './list-prices'; export * from './list-products'; export * from './list-stores'; +export * from './list-subscription-invoices'; +export * from './list-subscription-items'; export * from './list-subscriptions'; export * from './list-variants'; +export * from './list-webhooks'; +export * from './manage-customer'; +export * from './manage-discount'; export * from './manage-license-key'; export * from './manage-subscription'; +export * from './manage-webhook'; export * from './refund-order'; diff --git a/integrations/lemon-squeezy/src/tools/list-checkouts.ts b/integrations/lemon-squeezy/src/tools/list-checkouts.ts new file mode 100644 index 0000000000..bc2d993780 --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/list-checkouts.ts @@ -0,0 +1,72 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listCheckoutsTool = SlateTool.create(spec, { + name: 'List Checkouts', + key: 'list_checkouts', + description: + 'Retrieve custom checkout links created for Lemon Squeezy variants. Filter by store or variant and inspect URL, custom price, expiration, and test mode.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + storeId: z.string().optional().describe('Filter checkouts by store ID'), + variantId: z.string().optional().describe('Filter checkouts by variant ID'), + page: z.number().optional().describe('Page number for pagination'), + perPage: z.number().optional().describe('Number of results per page (max 100)') + }) + ) + .output( + z.object({ + checkouts: z.array( + z.object({ + checkoutId: z.string(), + storeId: z.number(), + variantId: z.number(), + customPrice: z.number().nullable(), + checkoutUrl: z.string(), + expiresAt: z.string().nullable(), + testMode: z.boolean(), + createdAt: z.string(), + updatedAt: z.string() + }) + ), + hasMore: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let response = await client.listCheckouts({ + storeId: ctx.input.storeId, + variantId: ctx.input.variantId, + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let checkouts = (response.data || []).map((checkout: any) => ({ + checkoutId: checkout.id, + storeId: checkout.attributes.store_id, + variantId: checkout.attributes.variant_id, + customPrice: checkout.attributes.custom_price, + checkoutUrl: checkout.attributes.url, + expiresAt: checkout.attributes.expires_at, + testMode: checkout.attributes.test_mode, + createdAt: checkout.attributes.created_at, + updatedAt: checkout.attributes.updated_at + })); + + let hasMore = + !!response.meta?.page?.lastPage && + response.meta?.page?.currentPage < response.meta?.page?.lastPage; + + return { + output: { checkouts, hasMore }, + message: `Found **${checkouts.length}** checkout(s).` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/list-discount-redemptions.ts b/integrations/lemon-squeezy/src/tools/list-discount-redemptions.ts new file mode 100644 index 0000000000..1bc16783b4 --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/list-discount-redemptions.ts @@ -0,0 +1,72 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listDiscountRedemptionsTool = SlateTool.create(spec, { + name: 'List Discount Redemptions', + key: 'list_discount_redemptions', + description: + 'Retrieve discount redemption records from Lemon Squeezy. Use this to inspect where discount codes were redeemed and audit discount usage.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + discountId: z.string().optional().describe('Filter redemptions by discount ID'), + page: z.number().optional().describe('Page number for pagination'), + perPage: z.number().optional().describe('Number of results per page (max 100)') + }) + ) + .output( + z.object({ + discountRedemptions: z.array( + z.object({ + discountRedemptionId: z.string(), + discountId: z.number(), + orderId: z.number(), + discountName: z.string(), + discountCode: z.string(), + discountAmount: z.number(), + discountAmountType: z.string(), + amount: z.number(), + createdAt: z.string(), + updatedAt: z.string() + }) + ), + hasMore: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let response = await client.listDiscountRedemptions({ + discountId: ctx.input.discountId, + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let discountRedemptions = (response.data || []).map((redemption: any) => ({ + discountRedemptionId: redemption.id, + discountId: redemption.attributes.discount_id, + orderId: redemption.attributes.order_id, + discountName: redemption.attributes.discount_name, + discountCode: redemption.attributes.discount_code, + discountAmount: redemption.attributes.discount_amount, + discountAmountType: redemption.attributes.discount_amount_type, + amount: redemption.attributes.amount, + createdAt: redemption.attributes.created_at, + updatedAt: redemption.attributes.updated_at + })); + + let hasMore = + !!response.meta?.page?.lastPage && + response.meta?.page?.currentPage < response.meta?.page?.lastPage; + + return { + output: { discountRedemptions, hasMore }, + message: `Found **${discountRedemptions.length}** discount redemption(s).` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/list-files.ts b/integrations/lemon-squeezy/src/tools/list-files.ts new file mode 100644 index 0000000000..5929d85192 --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/list-files.ts @@ -0,0 +1,80 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listFilesTool = SlateTool.create(spec, { + name: 'List Files', + key: 'list_files', + description: + 'Retrieve digital product files from Lemon Squeezy. Returns file metadata and signed download URLs for customer-delivered files; download URLs expire and are rate-limited by Lemon Squeezy.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + variantId: z.string().optional().describe('Filter files by variant ID'), + page: z.number().optional().describe('Page number for pagination'), + perPage: z.number().optional().describe('Number of results per page (max 100)') + }) + ) + .output( + z.object({ + files: z.array( + z.object({ + fileId: z.string(), + variantId: z.number(), + identifier: z.string(), + name: z.string(), + extension: z.string(), + downloadUrl: z.string(), + size: z.number(), + sizeFormatted: z.string(), + version: z.string().nullable(), + sort: z.number(), + status: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + testMode: z.boolean() + }) + ), + hasMore: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let response = await client.listFiles({ + variantId: ctx.input.variantId, + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let files = (response.data || []).map((file: any) => ({ + fileId: file.id, + variantId: file.attributes.variant_id, + identifier: file.attributes.identifier, + name: file.attributes.name, + extension: file.attributes.extension, + downloadUrl: file.attributes.download_url, + size: file.attributes.size, + sizeFormatted: file.attributes.size_formatted, + version: file.attributes.version, + sort: file.attributes.sort, + status: file.attributes.status, + createdAt: file.attributes.created_at, + updatedAt: file.attributes.updated_at, + testMode: file.attributes.test_mode + })); + + let hasMore = + !!response.meta?.page?.lastPage && + response.meta?.page?.currentPage < response.meta?.page?.lastPage; + + return { + output: { files, hasMore }, + message: `Found **${files.length}** file(s).` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/list-order-items.ts b/integrations/lemon-squeezy/src/tools/list-order-items.ts new file mode 100644 index 0000000000..9cddd4ab65 --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/list-order-items.ts @@ -0,0 +1,76 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listOrderItemsTool = SlateTool.create(spec, { + name: 'List Order Items', + key: 'list_order_items', + description: + 'Retrieve order line items from Lemon Squeezy, including product, variant, price, and quantity details. Filter by order, product, or variant.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + orderId: z.string().optional().describe('Filter order items by order ID'), + productId: z.string().optional().describe('Filter order items by product ID'), + variantId: z.string().optional().describe('Filter order items by variant ID'), + page: z.number().optional().describe('Page number for pagination'), + perPage: z.number().optional().describe('Number of results per page (max 100)') + }) + ) + .output( + z.object({ + orderItems: z.array( + z.object({ + orderItemId: z.string(), + orderId: z.number(), + productId: z.number(), + variantId: z.number(), + productName: z.string(), + variantName: z.string(), + price: z.number(), + quantity: z.number(), + createdAt: z.string(), + updatedAt: z.string() + }) + ), + hasMore: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let response = await client.listOrderItems({ + orderId: ctx.input.orderId, + productId: ctx.input.productId, + variantId: ctx.input.variantId, + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let orderItems = (response.data || []).map((item: any) => ({ + orderItemId: item.id, + orderId: item.attributes.order_id, + productId: item.attributes.product_id, + variantId: item.attributes.variant_id, + productName: item.attributes.product_name, + variantName: item.attributes.variant_name, + price: item.attributes.price, + quantity: item.attributes.quantity, + createdAt: item.attributes.created_at, + updatedAt: item.attributes.updated_at + })); + + let hasMore = + !!response.meta?.page?.lastPage && + response.meta?.page?.currentPage < response.meta?.page?.lastPage; + + return { + output: { orderItems, hasMore }, + message: `Found **${orderItems.length}** order item(s).` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/list-prices.ts b/integrations/lemon-squeezy/src/tools/list-prices.ts new file mode 100644 index 0000000000..ed59c0d7d8 --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/list-prices.ts @@ -0,0 +1,92 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listPricesTool = SlateTool.create(spec, { + name: 'List Prices', + key: 'list_prices', + description: + 'Retrieve Lemon Squeezy price objects. Prices preserve historical pricing for variants and include pricing scheme, renewal interval, usage aggregation, setup fee, tiers, and tax code details.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + variantId: z.string().optional().describe('Filter prices by variant ID'), + page: z.number().optional().describe('Page number for pagination'), + perPage: z.number().optional().describe('Number of results per page (max 100)') + }) + ) + .output( + z.object({ + prices: z.array( + z.object({ + priceId: z.string(), + variantId: z.number(), + category: z.string(), + scheme: z.string(), + usageAggregation: z.string().nullable(), + unitPrice: z.number().nullable(), + unitPriceDecimal: z.string().nullable(), + setupFeeEnabled: z.boolean(), + setupFee: z.number().nullable(), + packageSize: z.number().nullable(), + tiers: z.array(z.record(z.string(), z.unknown())).nullable(), + renewalIntervalUnit: z.string().nullable(), + renewalIntervalQuantity: z.number().nullable(), + trialIntervalUnit: z.string().nullable(), + trialIntervalQuantity: z.number().nullable(), + minPrice: z.number().nullable(), + suggestedPrice: z.number().nullable(), + taxCode: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string() + }) + ), + hasMore: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let response = await client.listPrices({ + variantId: ctx.input.variantId, + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let prices = (response.data || []).map((price: any) => ({ + priceId: price.id, + variantId: price.attributes.variant_id, + category: price.attributes.category, + scheme: price.attributes.scheme, + usageAggregation: price.attributes.usage_aggregation, + unitPrice: price.attributes.unit_price, + unitPriceDecimal: price.attributes.unit_price_decimal, + setupFeeEnabled: price.attributes.setup_fee_enabled, + setupFee: price.attributes.setup_fee, + packageSize: price.attributes.package_size, + tiers: price.attributes.tiers, + renewalIntervalUnit: price.attributes.renewal_interval_unit, + renewalIntervalQuantity: price.attributes.renewal_interval_quantity, + trialIntervalUnit: price.attributes.trial_interval_unit, + trialIntervalQuantity: price.attributes.trial_interval_quantity, + minPrice: price.attributes.min_price, + suggestedPrice: price.attributes.suggested_price, + taxCode: price.attributes.tax_code, + createdAt: price.attributes.created_at, + updatedAt: price.attributes.updated_at + })); + + let hasMore = + !!response.meta?.page?.lastPage && + response.meta?.page?.currentPage < response.meta?.page?.lastPage; + + return { + output: { prices, hasMore }, + message: `Found **${prices.length}** price(s).` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/list-subscription-invoices.ts b/integrations/lemon-squeezy/src/tools/list-subscription-invoices.ts new file mode 100644 index 0000000000..eae41913e6 --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/list-subscription-invoices.ts @@ -0,0 +1,105 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listSubscriptionInvoicesTool = SlateTool.create(spec, { + name: 'List Subscription Invoices', + key: 'list_subscription_invoices', + description: + 'Retrieve subscription invoices from Lemon Squeezy. Filter by store, subscription, status, or refund state to inspect recurring billing history.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + storeId: z.string().optional().describe('Filter invoices by store ID'), + subscriptionId: z.string().optional().describe('Filter invoices by subscription ID'), + status: z + .enum(['pending', 'paid', 'void', 'refunded', 'partial_refund']) + .optional() + .describe('Filter invoices by status'), + refunded: z.boolean().optional().describe('Filter by whether invoices are refunded'), + page: z.number().optional().describe('Page number for pagination'), + perPage: z.number().optional().describe('Number of results per page (max 100)') + }) + ) + .output( + z.object({ + subscriptionInvoices: z.array( + z.object({ + invoiceId: z.string(), + storeId: z.number(), + subscriptionId: z.number(), + customerId: z.number(), + userName: z.string(), + userEmail: z.string(), + billingReason: z.string(), + currency: z.string(), + status: z.string(), + statusFormatted: z.string(), + refunded: z.boolean(), + refundedAt: z.string().nullable(), + subtotal: z.number(), + discountTotal: z.number(), + tax: z.number(), + total: z.number(), + subtotalFormatted: z.string(), + discountTotalFormatted: z.string(), + taxFormatted: z.string(), + totalFormatted: z.string(), + createdAt: z.string(), + updatedAt: z.string() + }) + ), + hasMore: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let response = await client.listSubscriptionInvoices({ + storeId: ctx.input.storeId, + subscriptionId: ctx.input.subscriptionId, + status: ctx.input.status, + refunded: ctx.input.refunded, + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let subscriptionInvoices = (response.data || []).map((invoice: any) => ({ + invoiceId: invoice.id, + storeId: invoice.attributes.store_id, + subscriptionId: invoice.attributes.subscription_id, + customerId: invoice.attributes.customer_id, + userName: invoice.attributes.user_name, + userEmail: invoice.attributes.user_email, + billingReason: invoice.attributes.billing_reason, + currency: invoice.attributes.currency, + status: invoice.attributes.status, + statusFormatted: invoice.attributes.status_formatted, + refunded: invoice.attributes.refunded, + refundedAt: invoice.attributes.refunded_at, + subtotal: invoice.attributes.subtotal, + discountTotal: invoice.attributes.discount_total, + tax: invoice.attributes.tax, + total: invoice.attributes.total, + subtotalFormatted: invoice.attributes.subtotal_formatted, + discountTotalFormatted: invoice.attributes.discount_total_formatted, + taxFormatted: invoice.attributes.tax_formatted, + totalFormatted: invoice.attributes.total_formatted, + createdAt: invoice.attributes.created_at, + updatedAt: invoice.attributes.updated_at + })); + + let hasMore = + !!response.meta?.page?.lastPage && + response.meta?.page?.currentPage < response.meta?.page?.lastPage; + + return { + output: { subscriptionInvoices, hasMore }, + message: `Found **${subscriptionInvoices.length}** subscription invoice(s).` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/list-subscription-items.ts b/integrations/lemon-squeezy/src/tools/list-subscription-items.ts new file mode 100644 index 0000000000..3dfc9d288d --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/list-subscription-items.ts @@ -0,0 +1,68 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listSubscriptionItemsTool = SlateTool.create(spec, { + name: 'List Subscription Items', + key: 'list_subscription_items', + description: + 'Retrieve Lemon Squeezy subscription items that link subscription records to price objects and quantities. Useful for quantity-based and usage-based billing inspection.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + subscriptionId: z.string().optional().describe('Filter items by subscription ID'), + priceId: z.string().optional().describe('Filter items by price ID'), + page: z.number().optional().describe('Page number for pagination'), + perPage: z.number().optional().describe('Number of results per page (max 100)') + }) + ) + .output( + z.object({ + subscriptionItems: z.array( + z.object({ + subscriptionItemId: z.string(), + subscriptionId: z.number(), + priceId: z.number(), + quantity: z.number(), + isUsageBased: z.boolean(), + createdAt: z.string(), + updatedAt: z.string() + }) + ), + hasMore: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let response = await client.listSubscriptionItems({ + subscriptionId: ctx.input.subscriptionId, + priceId: ctx.input.priceId, + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let subscriptionItems = (response.data || []).map((item: any) => ({ + subscriptionItemId: item.id, + subscriptionId: item.attributes.subscription_id, + priceId: item.attributes.price_id, + quantity: item.attributes.quantity, + isUsageBased: item.attributes.is_usage_based, + createdAt: item.attributes.created_at, + updatedAt: item.attributes.updated_at + })); + + let hasMore = + !!response.meta?.page?.lastPage && + response.meta?.page?.currentPage < response.meta?.page?.lastPage; + + return { + output: { subscriptionItems, hasMore }, + message: `Found **${subscriptionItems.length}** subscription item(s).` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/list-webhooks.ts b/integrations/lemon-squeezy/src/tools/list-webhooks.ts new file mode 100644 index 0000000000..a5f9e0f853 --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/list-webhooks.ts @@ -0,0 +1,68 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listWebhooksTool = SlateTool.create(spec, { + name: 'List Webhooks', + key: 'list_webhooks', + description: + 'Retrieve Lemon Squeezy webhooks. Filter by store and inspect endpoint URL, subscribed event types, last delivery time, and test mode.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + storeId: z.string().optional().describe('Filter webhooks by store ID'), + page: z.number().optional().describe('Page number for pagination'), + perPage: z.number().optional().describe('Number of results per page (max 100)') + }) + ) + .output( + z.object({ + webhooks: z.array( + z.object({ + webhookId: z.string(), + storeId: z.number(), + url: z.string(), + events: z.array(z.string()), + lastSentAt: z.string().nullable(), + testMode: z.boolean(), + createdAt: z.string(), + updatedAt: z.string() + }) + ), + hasMore: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + let response = await client.listWebhooks({ + storeId: ctx.input.storeId, + page: ctx.input.page, + perPage: ctx.input.perPage + }); + + let webhooks = (response.data || []).map((webhook: any) => ({ + webhookId: webhook.id, + storeId: webhook.attributes.store_id, + url: webhook.attributes.url, + events: webhook.attributes.events, + lastSentAt: webhook.attributes.last_sent_at, + testMode: webhook.attributes.test_mode, + createdAt: webhook.attributes.created_at, + updatedAt: webhook.attributes.updated_at + })); + + let hasMore = + !!response.meta?.page?.lastPage && + response.meta?.page?.currentPage < response.meta?.page?.lastPage; + + return { + output: { webhooks, hasMore }, + message: `Found **${webhooks.length}** webhook(s).` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/manage-customer.ts b/integrations/lemon-squeezy/src/tools/manage-customer.ts new file mode 100644 index 0000000000..d48bef94ba --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/manage-customer.ts @@ -0,0 +1,162 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { lemonSqueezyServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let customerOutputSchema = z.object({ + customerId: z.string(), + storeId: z.number(), + name: z.string(), + email: z.string(), + status: z.string(), + statusFormatted: z.string(), + city: z.string().nullable(), + region: z.string().nullable(), + country: z.string().nullable(), + countryFormatted: z.string().nullable().optional(), + totalRevenueCurrency: z.number(), + totalRevenueCurrencyFormatted: z.string().optional(), + mrr: z.number(), + mrrFormatted: z.string().optional(), + customerPortalUrl: z.string().nullable().optional(), + testMode: z.boolean(), + createdAt: z.string(), + updatedAt: z.string() +}); + +let formatCustomer = (customer: any) => ({ + customerId: customer.id, + storeId: customer.attributes.store_id, + name: customer.attributes.name, + email: customer.attributes.email, + status: customer.attributes.status, + statusFormatted: customer.attributes.status_formatted, + city: customer.attributes.city, + region: customer.attributes.region, + country: customer.attributes.country, + countryFormatted: customer.attributes.country_formatted, + totalRevenueCurrency: customer.attributes.total_revenue_currency, + totalRevenueCurrencyFormatted: customer.attributes.total_revenue_currency_formatted, + mrr: customer.attributes.mrr, + mrrFormatted: customer.attributes.mrr_formatted, + customerPortalUrl: customer.attributes.urls?.customer_portal ?? null, + testMode: customer.attributes.test_mode, + createdAt: customer.attributes.created_at, + updatedAt: customer.attributes.updated_at +}); + +export let manageCustomerTool = SlateTool.create(spec, { + name: 'Manage Customer', + key: 'manage_customer', + description: + 'Retrieve, create, update, or archive a Lemon Squeezy customer. Use action to choose the operation. Create requires storeId or configured storeId plus name and email; update requires customerId plus fields to change; archive marks the customer status as archived.', + instructions: [ + 'Use action "get" with customerId to retrieve a customer.', + 'Use action "create" with name, email, and storeId or configured storeId.', + 'Use action "update" with customerId and at least one customer field.', + 'Use action "archive" with customerId to set status to archived.' + ] +}) + .input( + z.object({ + action: z.enum(['get', 'create', 'update', 'archive']).describe('The customer action'), + customerId: z + .string() + .optional() + .describe('Customer ID for get, update, or archive actions'), + storeId: z + .string() + .optional() + .describe('Store ID for create. Falls back to the configured store ID if omitted.'), + name: z.string().optional().describe('Customer name for create or update'), + email: z.string().optional().describe('Customer email for create or update'), + city: z.string().optional().describe('Customer city for create or update'), + region: z.string().optional().describe('Customer region for create or update'), + country: z + .string() + .optional() + .describe('ISO 3166-1 alpha-2 country code for create or update') + }) + ) + .output( + z.object({ + customer: customerOutputSchema + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let customer: any; + + if (ctx.input.action === 'get') { + if (!ctx.input.customerId) { + throw lemonSqueezyServiceError('customerId is required for get action.'); + } + let response = await client.getCustomer(ctx.input.customerId); + customer = response.data; + } else if (ctx.input.action === 'create') { + let storeId = ctx.input.storeId || ctx.config.storeId; + if (!storeId) { + throw lemonSqueezyServiceError( + 'Store ID is required. Provide it in the input or configure it in the provider settings.' + ); + } + if (!ctx.input.name) { + throw lemonSqueezyServiceError('name is required for create action.'); + } + if (!ctx.input.email) { + throw lemonSqueezyServiceError('email is required for create action.'); + } + + let response = await client.createCustomer(storeId, { + name: ctx.input.name, + email: ctx.input.email, + city: ctx.input.city, + region: ctx.input.region, + country: ctx.input.country + }); + customer = response.data; + } else { + if (!ctx.input.customerId) { + throw lemonSqueezyServiceError(`${ctx.input.action} action requires customerId.`); + } + + let attributes: Record = {}; + if (ctx.input.action === 'archive') { + attributes.status = 'archived'; + } else { + if (ctx.input.name !== undefined) attributes.name = ctx.input.name; + if (ctx.input.email !== undefined) attributes.email = ctx.input.email; + if (ctx.input.city !== undefined) attributes.city = ctx.input.city; + if (ctx.input.region !== undefined) attributes.region = ctx.input.region; + if (ctx.input.country !== undefined) attributes.country = ctx.input.country; + + if (Object.keys(attributes).length === 0) { + throw lemonSqueezyServiceError( + 'Provide at least one customer field for update action.' + ); + } + } + + let response = await client.updateCustomer(ctx.input.customerId, attributes); + customer = response.data; + } + + let formatted = formatCustomer(customer); + let actionLabel = + ctx.input.action === 'get' + ? 'Retrieved' + : ctx.input.action === 'create' + ? 'Created' + : ctx.input.action === 'archive' + ? 'Archived' + : 'Updated'; + + return { + output: { + customer: formatted + }, + message: `${actionLabel} customer **${formatted.email}**.` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/manage-discount.ts b/integrations/lemon-squeezy/src/tools/manage-discount.ts new file mode 100644 index 0000000000..144f4802b2 --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/manage-discount.ts @@ -0,0 +1,91 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { lemonSqueezyServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let discountFields = { + discountId: z.string(), + deleted: z.boolean(), + storeId: z.number().optional(), + name: z.string().optional(), + code: z.string().optional(), + amount: z.number().optional(), + amountType: z.string().optional(), + isLimitedToProducts: z.boolean().optional(), + isLimitedRedemptions: z.boolean().optional(), + maxRedemptions: z.number().optional(), + status: z.string().optional(), + statusFormatted: z.string().optional(), + duration: z.string().optional(), + durationInMonths: z.number().nullable().optional(), + startsAt: z.string().nullable().optional(), + expiresAt: z.string().nullable().optional(), + testMode: z.boolean().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional() +}; + +let formatDiscount = (discount: any) => ({ + discountId: discount.id, + deleted: false, + storeId: discount.attributes.store_id, + name: discount.attributes.name, + code: discount.attributes.code, + amount: discount.attributes.amount, + amountType: discount.attributes.amount_type, + isLimitedToProducts: discount.attributes.is_limited_to_products, + isLimitedRedemptions: discount.attributes.is_limited_redemptions, + maxRedemptions: discount.attributes.max_redemptions, + status: discount.attributes.status, + statusFormatted: discount.attributes.status_formatted, + duration: discount.attributes.duration, + durationInMonths: discount.attributes.duration_in_months, + startsAt: discount.attributes.starts_at, + expiresAt: discount.attributes.expires_at, + testMode: discount.attributes.test_mode, + createdAt: discount.attributes.created_at, + updatedAt: discount.attributes.updated_at +}); + +export let manageDiscountTool = SlateTool.create(spec, { + name: 'Manage Discount', + key: 'manage_discount', + description: + 'Retrieve or delete a Lemon Squeezy discount. Use action "get" to inspect discount status and rules, or action "delete" to permanently remove a discount code.', + constraints: ['Deleting a discount is permanent.'] +}) + .input( + z.object({ + action: z.enum(['get', 'delete']).describe('The discount action to perform'), + discountId: z.string().describe('The discount ID') + }) + ) + .output(z.object(discountFields)) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + if (!ctx.input.discountId) { + throw lemonSqueezyServiceError('discountId is required.'); + } + + if (ctx.input.action === 'delete') { + await client.deleteDiscount(ctx.input.discountId); + return { + output: { + discountId: ctx.input.discountId, + deleted: true + }, + message: `Deleted discount **${ctx.input.discountId}**.` + }; + } + + let response = await client.getDiscount(ctx.input.discountId); + let output = formatDiscount(response.data); + + return { + output, + message: `Retrieved discount **${output.code}** — ${output.statusFormatted}.` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/tools/manage-license-key.ts b/integrations/lemon-squeezy/src/tools/manage-license-key.ts index bd4b55f2ba..30511fc6c2 100644 --- a/integrations/lemon-squeezy/src/tools/manage-license-key.ts +++ b/integrations/lemon-squeezy/src/tools/manage-license-key.ts @@ -81,17 +81,13 @@ export let manageLicenseKeyTool = SlateTool.create(spec, { let instances: any[] | undefined; if (action === 'get') { - try { - let instancesResponse = await client.listLicenseKeyInstances({ licenseKeyId }); - instances = (instancesResponse.data || []).map((inst: any) => ({ - instanceId: inst.id, - identifier: inst.attributes.identifier, - name: inst.attributes.name, - createdAt: inst.attributes.created_at - })); - } catch { - instances = []; - } + let instancesResponse = await client.listLicenseKeyInstances({ licenseKeyId }); + instances = (instancesResponse.data || []).map((inst: any) => ({ + instanceId: inst.id, + identifier: inst.attributes.identifier, + name: inst.attributes.name, + createdAt: inst.attributes.created_at + })); } let output = { diff --git a/integrations/lemon-squeezy/src/tools/manage-webhook.ts b/integrations/lemon-squeezy/src/tools/manage-webhook.ts new file mode 100644 index 0000000000..86011864a6 --- /dev/null +++ b/integrations/lemon-squeezy/src/tools/manage-webhook.ts @@ -0,0 +1,180 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { lemonSqueezyServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let WEBHOOK_EVENTS = [ + 'order_created', + 'order_refunded', + 'customer_updated', + 'subscription_created', + 'subscription_updated', + 'subscription_cancelled', + 'subscription_resumed', + 'subscription_expired', + 'subscription_paused', + 'subscription_unpaused', + 'subscription_payment_success', + 'subscription_payment_failed', + 'subscription_payment_recovered', + 'subscription_payment_refunded', + 'license_key_created', + 'license_key_updated', + 'affiliate_activated' +] as const; + +let webhookOutputSchema = z.object({ + webhookId: z.string(), + deleted: z.boolean(), + storeId: z.number().optional(), + url: z.string().optional(), + events: z.array(z.string()).optional(), + lastSentAt: z.string().nullable().optional(), + testMode: z.boolean().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional() +}); + +let formatWebhook = (webhook: any) => ({ + webhookId: webhook.id, + deleted: false, + storeId: webhook.attributes.store_id, + url: webhook.attributes.url, + events: webhook.attributes.events, + lastSentAt: webhook.attributes.last_sent_at, + testMode: webhook.attributes.test_mode, + createdAt: webhook.attributes.created_at, + updatedAt: webhook.attributes.updated_at +}); + +export let manageWebhookTool = SlateTool.create(spec, { + name: 'Manage Webhook', + key: 'manage_webhook', + description: + 'Create, retrieve, update, or delete a Lemon Squeezy webhook. Use action to choose the operation. Create requires storeId or configured storeId, URL, events, and a signing secret.', + instructions: [ + 'Use action "create" with url, events, secret, and storeId or configured storeId.', + 'Use action "update" with webhookId and at least one of url, events, or secret.', + 'Use action "get" or "delete" with webhookId.' + ], + constraints: ['Deleting a webhook is permanent and stops event delivery to that endpoint.'] +}) + .input( + z.object({ + action: z.enum(['get', 'create', 'update', 'delete']).describe('The webhook action'), + webhookId: z + .string() + .optional() + .describe('Webhook ID for get, update, or delete actions'), + storeId: z + .string() + .optional() + .describe('Store ID for create. Falls back to the configured store ID if omitted.'), + url: z.string().optional().describe('HTTPS endpoint URL for create or update'), + events: z + .array(z.enum(WEBHOOK_EVENTS)) + .optional() + .describe('Webhook event types for create or update'), + secret: z + .string() + .optional() + .describe('Signing secret for create or update. Lemon Squeezy never returns it.'), + testMode: z.boolean().optional().describe('Create the webhook in test mode') + }) + ) + .output( + z.object({ + webhook: webhookOutputSchema + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let webhook: any; + + if (ctx.input.action === 'create') { + let storeId = ctx.input.storeId || ctx.config.storeId; + if (!storeId) { + throw lemonSqueezyServiceError( + 'Store ID is required. Provide it in the input or configure it in the provider settings.' + ); + } + if (!ctx.input.url) { + throw lemonSqueezyServiceError('url is required for create action.'); + } + if (!ctx.input.events || ctx.input.events.length === 0) { + throw lemonSqueezyServiceError( + 'events must include at least one event for create action.' + ); + } + if (!ctx.input.secret) { + throw lemonSqueezyServiceError('secret is required for create action.'); + } + + let response = await client.createWebhook( + storeId, + ctx.input.url, + ctx.input.events, + ctx.input.secret, + ctx.input.testMode + ); + webhook = response.data; + } else { + if (!ctx.input.webhookId) { + throw lemonSqueezyServiceError(`${ctx.input.action} action requires webhookId.`); + } + + if (ctx.input.action === 'delete') { + await client.deleteWebhook(ctx.input.webhookId); + return { + output: { + webhook: { + webhookId: ctx.input.webhookId, + deleted: true + } + }, + message: `Deleted webhook **${ctx.input.webhookId}**.` + }; + } + + if (ctx.input.action === 'update') { + let attributes: { url?: string; events?: string[]; secret?: string } = {}; + if (ctx.input.url !== undefined) attributes.url = ctx.input.url; + if (ctx.input.events !== undefined) { + if (ctx.input.events.length === 0) { + throw lemonSqueezyServiceError( + 'events must include at least one event when updating.' + ); + } + attributes.events = ctx.input.events; + } + if (ctx.input.secret !== undefined) attributes.secret = ctx.input.secret; + + if (Object.keys(attributes).length === 0) { + throw lemonSqueezyServiceError('Provide url, events, or secret for update action.'); + } + + let response = await client.updateWebhook(ctx.input.webhookId, attributes); + webhook = response.data; + } else { + let response = await client.getWebhook(ctx.input.webhookId); + webhook = response.data; + } + } + + let formatted = formatWebhook(webhook); + let actionLabel = + ctx.input.action === 'create' + ? 'Created' + : ctx.input.action === 'update' + ? 'Updated' + : 'Retrieved'; + + return { + output: { + webhook: formatted + }, + message: `${actionLabel} webhook **${formatted.webhookId}** for ${formatted.url}.` + }; + }) + .build(); diff --git a/integrations/lemon-squeezy/src/triggers/license-key-events.ts b/integrations/lemon-squeezy/src/triggers/license-key-events.ts index f01c3f6bc4..6654a68913 100644 --- a/integrations/lemon-squeezy/src/triggers/license-key-events.ts +++ b/integrations/lemon-squeezy/src/triggers/license-key-events.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { lemonSqueezyServiceError } from '../lib/errors'; import { spec } from '../spec'; let ALL_LICENSE_KEY_EVENTS = ['license_key_created', 'license_key_updated']; @@ -62,7 +63,9 @@ export let licenseKeyEventsTrigger = SlateTrigger.create(spec, { if (!storeId) { let storesResponse = await client.listStores({ perPage: 1 }); storeId = storesResponse.data?.[0]?.id; - if (!storeId) throw new Error('No store found. Please configure a store ID.'); + if (!storeId) { + throw lemonSqueezyServiceError('No store found. Please configure a store ID.'); + } } let secret = generateSecret(); diff --git a/integrations/lemon-squeezy/src/triggers/order-events.ts b/integrations/lemon-squeezy/src/triggers/order-events.ts index 9d5077401e..c2d6ec9d9e 100644 --- a/integrations/lemon-squeezy/src/triggers/order-events.ts +++ b/integrations/lemon-squeezy/src/triggers/order-events.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { lemonSqueezyServiceError } from '../lib/errors'; import { spec } from '../spec'; let ALL_ORDER_EVENTS = ['order_created', 'order_refunded']; @@ -73,7 +74,9 @@ export let orderEventsTrigger = SlateTrigger.create(spec, { if (!storeId) { let storesResponse = await client.listStores({ perPage: 1 }); storeId = storesResponse.data?.[0]?.id; - if (!storeId) throw new Error('No store found. Please configure a store ID.'); + if (!storeId) { + throw lemonSqueezyServiceError('No store found. Please configure a store ID.'); + } } let secret = generateSecret(); diff --git a/integrations/lemon-squeezy/src/triggers/subscription-events.ts b/integrations/lemon-squeezy/src/triggers/subscription-events.ts index 7d246e6e65..d4fe105f99 100644 --- a/integrations/lemon-squeezy/src/triggers/subscription-events.ts +++ b/integrations/lemon-squeezy/src/triggers/subscription-events.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { lemonSqueezyServiceError } from '../lib/errors'; import { spec } from '../spec'; let ALL_SUBSCRIPTION_EVENTS = [ @@ -84,7 +85,9 @@ export let subscriptionEventsTrigger = SlateTrigger.create(spec, { if (!storeId) { let storesResponse = await client.listStores({ perPage: 1 }); storeId = storesResponse.data?.[0]?.id; - if (!storeId) throw new Error('No store found. Please configure a store ID.'); + if (!storeId) { + throw lemonSqueezyServiceError('No store found. Please configure a store ID.'); + } } let secret = generateSecret(); diff --git a/integrations/lemon-squeezy/src/triggers/subscription-payment-events.ts b/integrations/lemon-squeezy/src/triggers/subscription-payment-events.ts index de2adcf7a7..d9453ef673 100644 --- a/integrations/lemon-squeezy/src/triggers/subscription-payment-events.ts +++ b/integrations/lemon-squeezy/src/triggers/subscription-payment-events.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { lemonSqueezyServiceError } from '../lib/errors'; import { spec } from '../spec'; let ALL_SUBSCRIPTION_PAYMENT_EVENTS = [ @@ -79,7 +80,9 @@ export let subscriptionPaymentEventsTrigger = SlateTrigger.create(spec, { if (!storeId) { let storesResponse = await client.listStores({ perPage: 1 }); storeId = storesResponse.data?.[0]?.id; - if (!storeId) throw new Error('No store found. Please configure a store ID.'); + if (!storeId) { + throw lemonSqueezyServiceError('No store found. Please configure a store ID.'); + } } let secret = generateSecret(); diff --git a/integrations/lemon-squeezy/vitest.config.ts b/integrations/lemon-squeezy/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/lemon-squeezy/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/magento/README.md b/integrations/magento/README.md index 72aafcfb28..665647ac19 100644 --- a/integrations/magento/README.md +++ b/integrations/magento/README.md @@ -14,7 +14,7 @@ Retrieve store configuration, websites, store groups, currencies, and available ### Manage Cart -Create and manage shopping carts. Create carts for customers or guests, add/update/remove items, apply or remove coupon codes, and view cart contents. Supports the full pre-checkout shopping experience. +Create and manage shopping carts. Create carts for customers or guests, add/update/remove items, estimate shipping methods, apply or remove coupon codes, and view cart contents. Supports the full pre-checkout shopping experience. ### Manage Category @@ -40,6 +40,10 @@ Retrieve order details or perform order actions including adding comments, cance Create, update, retrieve, or delete products in the Magento catalog. Supports all product types including simple, configurable, bundle, grouped, and virtual. Set pricing, status, visibility, weight, custom attributes, and more. +### Manage Product Media + +List, add, update, or delete product media gallery entries. Use this for product images and storefront image roles such as image, small_image, and thumbnail. + ### Search Customers Search and filter customer accounts using flexible criteria. Find customers by email, name, group, registration date, or any customer field. Supports pagination and sorting. diff --git a/integrations/magento/package.json b/integrations/magento/package.json index 4f1055990a..bb89de529a 100644 --- a/integrations/magento/package.json +++ b/integrations/magento/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/magento/src/auth.ts b/integrations/magento/src/auth.ts index 3ed8b453a2..d72e690b6b 100644 --- a/integrations/magento/src/auth.ts +++ b/integrations/magento/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { magentoApiError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -42,21 +43,25 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { - let axios = createAxios({ - baseURL: ctx.input.storeUrl.replace(/\/+$/, '') - }); + try { + let axios = createAxios({ + baseURL: ctx.input.storeUrl.replace(/\/+$/, '') + }); - let response = await axios.post('/rest/V1/integration/admin/token', { - username: ctx.input.username, - password: ctx.input.password - }); + let response = await axios.post('/rest/V1/integration/admin/token', { + username: ctx.input.username, + password: ctx.input.password + }); - let token = response.data as string; + let token = response.data as string; - return { - output: { - token - } - }; + return { + output: { + token + } + }; + } catch (error) { + throw magentoApiError(error, 'authenticate admin credentials'); + } } }); diff --git a/integrations/magento/src/index.ts b/integrations/magento/src/index.ts index 13980819ef..6a98bf3ce0 100644 --- a/integrations/magento/src/index.ts +++ b/integrations/magento/src/index.ts @@ -10,6 +10,7 @@ import { manageInventory, manageOrder, manageProduct, + manageProductMedia, searchCustomers, searchOrders, searchProducts @@ -27,6 +28,7 @@ export let provider = Slate.create({ manageCustomer, searchCustomers, manageInventory, + manageProductMedia, manageCart, manageCategory, manageCms, diff --git a/integrations/magento/src/lib/client.ts b/integrations/magento/src/lib/client.ts index dab05c5dd0..9cc2f74a06 100644 --- a/integrations/magento/src/lib/client.ts +++ b/integrations/magento/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { magentoApiError } from './errors'; import type { MagentoCart, MagentoCartItem, @@ -8,6 +9,7 @@ import type { MagentoCustomer, MagentoInventorySourceItem, MagentoInvoice, + MagentoMediaGalleryEntry, MagentoOrder, MagentoProduct, MagentoSearchResult, @@ -30,6 +32,11 @@ export class MagentoClient { 'Content-Type': 'application/json' } }); + + this.axios.interceptors.response.use( + response => response, + (error: unknown) => Promise.reject(magentoApiError(error)) + ); } // ─── Search Criteria Helpers ────────────────────────────────── @@ -106,6 +113,40 @@ export class MagentoClient { return response.data as boolean; } + async listProductMedia(sku: string): Promise { + let response = await this.axios.get(`/products/${encodeURIComponent(sku)}/media`); + return response.data as MagentoMediaGalleryEntry[]; + } + + async createProductMedia( + sku: string, + entry: MagentoMediaGalleryEntry + ): Promise { + let response = await this.axios.post(`/products/${encodeURIComponent(sku)}/media`, { + entry + }); + return response.data as string | number; + } + + async updateProductMedia( + sku: string, + entryId: number, + entry: MagentoMediaGalleryEntry + ): Promise { + let response = await this.axios.put( + `/products/${encodeURIComponent(sku)}/media/${entryId}`, + { entry: { ...entry, id: entryId } } + ); + return response.data as boolean; + } + + async deleteProductMedia(sku: string, entryId: number): Promise { + let response = await this.axios.delete( + `/products/${encodeURIComponent(sku)}/media/${entryId}` + ); + return response.data as boolean; + } + // ─── Orders ─────────────────────────────────────────────────── async getOrder(orderId: number): Promise { diff --git a/integrations/magento/src/lib/errors.ts b/integrations/magento/src/lib/errors.ts new file mode 100644 index 0000000000..3625fb8385 --- /dev/null +++ b/integrations/magento/src/lib/errors.ts @@ -0,0 +1,93 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.error); + addDetail(details, value.title); + addDetail(details, value.detail); + + if (isRecord(value.parameters)) { + for (let parameter of Object.values(value.parameters)) { + addDetail(details, parameter); + } + } + + collectDetails(value.errors, details); + collectDetails(value.messages, details); +}; + +let extractMagentoMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let magentoServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let magentoApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = magentoServiceError( + `Magento API ${operation} failed: ${statusLabelFor(response)}${extractMagentoMessage(error)}` + ); + serviceError.data.reason = 'magento_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/magento/src/tools/index.ts b/integrations/magento/src/tools/index.ts index 2748ce0afd..a41ab04d10 100644 --- a/integrations/magento/src/tools/index.ts +++ b/integrations/magento/src/tools/index.ts @@ -7,6 +7,7 @@ export * from './manage-customer'; export * from './manage-inventory'; export * from './manage-order'; export * from './manage-product'; +export * from './manage-product-media'; export * from './search-customers'; export * from './search-orders'; export * from './search-products'; diff --git a/integrations/magento/src/tools/manage-cart.ts b/integrations/magento/src/tools/manage-cart.ts index cf86fc58d8..7f90509d15 100644 --- a/integrations/magento/src/tools/manage-cart.ts +++ b/integrations/magento/src/tools/manage-cart.ts @@ -1,18 +1,20 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MagentoClient } from '../lib/client'; +import { magentoServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCart = SlateTool.create(spec, { name: 'Manage Cart', key: 'manage_cart', - description: `Create and manage shopping carts. Create carts for customers or guests, add/update/remove items, apply or remove coupon codes, and view cart contents. Supports the full pre-checkout shopping experience.`, + description: `Create and manage shopping carts. Create carts for customers or guests, add/update/remove items, estimate shipping methods, apply or remove coupon codes, and view cart contents. Supports the full pre-checkout shopping experience.`, instructions: [ 'To **create** a cart, set action to "create" and optionally provide a customerId.', 'To **get** cart details, set action to "get" with the cartId.', 'To **add an item**, set action to "add_item" with cartId, itemSku, and itemQty.', 'To **update an item** quantity, set action to "update_item" with cartId, cartItemId, and itemQty.', 'To **remove an item**, set action to "remove_item" with cartId and cartItemId.', + 'To **estimate shipping**, set action to "estimate_shipping" with cartId and shippingAddress.', 'To **apply a coupon**, set action to "apply_coupon" with cartId and couponCode.', 'To **remove a coupon**, set action to "remove_coupon" with cartId.' ], @@ -30,6 +32,7 @@ export let manageCart = SlateTool.create(spec, { 'add_item', 'update_item', 'remove_item', + 'estimate_shipping', 'apply_coupon', 'remove_coupon' ]) @@ -49,7 +52,11 @@ export let manageCart = SlateTool.create(spec, { .number() .optional() .describe('Cart item ID (for update_item, remove_item)'), - couponCode: z.string().optional().describe('Coupon code (for apply_coupon)') + couponCode: z.string().optional().describe('Coupon code (for apply_coupon)'), + shippingAddress: z + .record(z.string(), z.any()) + .optional() + .describe('Shipping address object (for estimate_shipping)') }) ) .output( @@ -86,7 +93,20 @@ export let manageCart = SlateTool.create(spec, { }) .optional() .describe('Added or updated item details'), - success: z.boolean().optional().describe('Whether the operation succeeded') + success: z.boolean().optional().describe('Whether the operation succeeded'), + shippingMethods: z + .array( + z.object({ + carrierCode: z.string().optional().describe('Shipping carrier code'), + methodCode: z.string().optional().describe('Shipping method code'), + carrierTitle: z.string().optional().describe('Shipping carrier title'), + methodTitle: z.string().optional().describe('Shipping method title'), + amount: z.number().optional().describe('Shipping amount'), + available: z.boolean().optional().describe('Whether the method is available') + }) + ) + .optional() + .describe('Estimated shipping methods') }) ) .handleInvocation(async ctx => { @@ -119,7 +139,7 @@ export let manageCart = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.cartId) throw new Error('cartId is required for get action'); + if (!ctx.input.cartId) throw magentoServiceError('cartId is required for get action'); let cart = await client.getCart(ctx.input.cartId); return { output: { @@ -142,9 +162,10 @@ export let manageCart = SlateTool.create(spec, { } if (ctx.input.action === 'add_item') { - if (!ctx.input.cartId) throw new Error('cartId is required'); - if (!ctx.input.itemSku) throw new Error('itemSku is required for add_item'); - if (ctx.input.itemQty === undefined) throw new Error('itemQty is required for add_item'); + if (!ctx.input.cartId) throw magentoServiceError('cartId is required'); + if (!ctx.input.itemSku) throw magentoServiceError('itemSku is required for add_item'); + if (ctx.input.itemQty === undefined) + throw magentoServiceError('itemQty is required for add_item'); let item = await client.addCartItem(ctx.input.cartId, { sku: ctx.input.itemSku, qty: ctx.input.itemQty @@ -163,10 +184,11 @@ export let manageCart = SlateTool.create(spec, { } if (ctx.input.action === 'update_item') { - if (!ctx.input.cartId) throw new Error('cartId is required'); - if (!ctx.input.cartItemId) throw new Error('cartItemId is required for update_item'); + if (!ctx.input.cartId) throw magentoServiceError('cartId is required'); + if (!ctx.input.cartItemId) + throw magentoServiceError('cartItemId is required for update_item'); if (ctx.input.itemQty === undefined) - throw new Error('itemQty is required for update_item'); + throw magentoServiceError('itemQty is required for update_item'); let item = await client.updateCartItem(ctx.input.cartId, ctx.input.cartItemId, { qty: ctx.input.itemQty }); @@ -184,8 +206,9 @@ export let manageCart = SlateTool.create(spec, { } if (ctx.input.action === 'remove_item') { - if (!ctx.input.cartId) throw new Error('cartId is required'); - if (!ctx.input.cartItemId) throw new Error('cartItemId is required for remove_item'); + if (!ctx.input.cartId) throw magentoServiceError('cartId is required'); + if (!ctx.input.cartItemId) + throw magentoServiceError('cartItemId is required for remove_item'); await client.removeCartItem(ctx.input.cartId, ctx.input.cartItemId); return { output: { success: true }, @@ -193,9 +216,30 @@ export let manageCart = SlateTool.create(spec, { }; } + if (ctx.input.action === 'estimate_shipping') { + if (!ctx.input.cartId) throw magentoServiceError('cartId is required'); + if (!ctx.input.shippingAddress) + throw magentoServiceError('shippingAddress is required for estimate_shipping'); + let methods = await client.estimateShipping(ctx.input.cartId, ctx.input.shippingAddress); + return { + output: { + shippingMethods: methods.map(method => ({ + carrierCode: method.carrier_code, + methodCode: method.method_code, + carrierTitle: method.carrier_title, + methodTitle: method.method_title, + amount: method.amount, + available: method.available + })) + }, + message: `Found **${methods.length}** shipping method(s) for cart \`${ctx.input.cartId}\`.` + }; + } + if (ctx.input.action === 'apply_coupon') { - if (!ctx.input.cartId) throw new Error('cartId is required'); - if (!ctx.input.couponCode) throw new Error('couponCode is required for apply_coupon'); + if (!ctx.input.cartId) throw magentoServiceError('cartId is required'); + if (!ctx.input.couponCode) + throw magentoServiceError('couponCode is required for apply_coupon'); await client.applyCoupon(ctx.input.cartId, ctx.input.couponCode); return { output: { success: true }, @@ -204,7 +248,7 @@ export let manageCart = SlateTool.create(spec, { } // remove_coupon - if (!ctx.input.cartId) throw new Error('cartId is required'); + if (!ctx.input.cartId) throw magentoServiceError('cartId is required'); await client.removeCoupon(ctx.input.cartId); return { output: { success: true }, diff --git a/integrations/magento/src/tools/manage-category.ts b/integrations/magento/src/tools/manage-category.ts index 88aa8a4475..7a53168daf 100644 --- a/integrations/magento/src/tools/manage-category.ts +++ b/integrations/magento/src/tools/manage-category.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MagentoClient } from '../lib/client'; +import { magentoServiceError } from '../lib/errors'; import { spec } from '../spec'; let categoryOutputSchema = z.object({ @@ -107,7 +108,8 @@ export let manageCategory = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.categoryId) throw new Error('categoryId is required for get action'); + if (!ctx.input.categoryId) + throw magentoServiceError('categoryId is required for get action'); let category = await client.getCategory(ctx.input.categoryId); return { output: { category: mapCategory(category) }, @@ -116,7 +118,8 @@ export let manageCategory = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.categoryId) throw new Error('categoryId is required for delete action'); + if (!ctx.input.categoryId) + throw magentoServiceError('categoryId is required for delete action'); await client.deleteCategory(ctx.input.categoryId); return { output: { deleted: true }, @@ -125,8 +128,10 @@ export let manageCategory = SlateTool.create(spec, { } if (ctx.input.action === 'assign_product') { - if (!ctx.input.categoryId) throw new Error('categoryId is required for assign_product'); - if (!ctx.input.productSku) throw new Error('productSku is required for assign_product'); + if (!ctx.input.categoryId) + throw magentoServiceError('categoryId is required for assign_product'); + if (!ctx.input.productSku) + throw magentoServiceError('productSku is required for assign_product'); await client.assignProductToCategory( ctx.input.categoryId, ctx.input.productSku, @@ -139,8 +144,10 @@ export let manageCategory = SlateTool.create(spec, { } if (ctx.input.action === 'remove_product') { - if (!ctx.input.categoryId) throw new Error('categoryId is required for remove_product'); - if (!ctx.input.productSku) throw new Error('productSku is required for remove_product'); + if (!ctx.input.categoryId) + throw magentoServiceError('categoryId is required for remove_product'); + if (!ctx.input.productSku) + throw magentoServiceError('productSku is required for remove_product'); await client.removeProductFromCategory(ctx.input.categoryId, ctx.input.productSku); return { output: { success: true }, @@ -165,7 +172,8 @@ export let manageCategory = SlateTool.create(spec, { } // update - if (!ctx.input.categoryId) throw new Error('categoryId is required for update action'); + if (!ctx.input.categoryId) + throw magentoServiceError('categoryId is required for update action'); let category = await client.updateCategory(ctx.input.categoryId, categoryData); return { output: { category: mapCategory(category) }, diff --git a/integrations/magento/src/tools/manage-cms.ts b/integrations/magento/src/tools/manage-cms.ts index fafb771bfa..8b46e6292d 100644 --- a/integrations/magento/src/tools/manage-cms.ts +++ b/integrations/magento/src/tools/manage-cms.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MagentoClient } from '../lib/client'; +import { magentoServiceError } from '../lib/errors'; import { spec } from '../spec'; let pageOutputSchema = z.object({ @@ -114,7 +115,8 @@ export let manageCms = SlateTool.create(spec, { if (ctx.input.resourceType === 'page') { if (ctx.input.action === 'get') { - if (!ctx.input.resourceId) throw new Error('resourceId is required for get action'); + if (!ctx.input.resourceId) + throw magentoServiceError('resourceId is required for get action'); let page = await client.getCmsPage(ctx.input.resourceId); return { output: { page: mapPage(page) }, @@ -137,7 +139,8 @@ export let manageCms = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.resourceId) throw new Error('resourceId is required for delete action'); + if (!ctx.input.resourceId) + throw magentoServiceError('resourceId is required for delete action'); await client.deleteCmsPage(ctx.input.resourceId); return { output: { deleted: true }, @@ -168,7 +171,8 @@ export let manageCms = SlateTool.create(spec, { } // update - if (!ctx.input.resourceId) throw new Error('resourceId is required for update action'); + if (!ctx.input.resourceId) + throw magentoServiceError('resourceId is required for update action'); let page = await client.updateCmsPage(ctx.input.resourceId, pageData); return { output: { page: mapPage(page) }, @@ -178,7 +182,8 @@ export let manageCms = SlateTool.create(spec, { // block if (ctx.input.action === 'get') { - if (!ctx.input.resourceId) throw new Error('resourceId is required for get action'); + if (!ctx.input.resourceId) + throw magentoServiceError('resourceId is required for get action'); let block = await client.getCmsBlock(ctx.input.resourceId); return { output: { block: mapBlock(block) }, @@ -201,7 +206,8 @@ export let manageCms = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.resourceId) throw new Error('resourceId is required for delete action'); + if (!ctx.input.resourceId) + throw magentoServiceError('resourceId is required for delete action'); await client.deleteCmsBlock(ctx.input.resourceId); return { output: { deleted: true }, @@ -224,7 +230,8 @@ export let manageCms = SlateTool.create(spec, { } // update - if (!ctx.input.resourceId) throw new Error('resourceId is required for update action'); + if (!ctx.input.resourceId) + throw magentoServiceError('resourceId is required for update action'); let block = await client.updateCmsBlock(ctx.input.resourceId, blockData); return { output: { block: mapBlock(block) }, diff --git a/integrations/magento/src/tools/manage-customer.ts b/integrations/magento/src/tools/manage-customer.ts index 6869097e75..bc96d317de 100644 --- a/integrations/magento/src/tools/manage-customer.ts +++ b/integrations/magento/src/tools/manage-customer.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MagentoClient } from '../lib/client'; +import { magentoServiceError } from '../lib/errors'; import { spec } from '../spec'; let customerAddressSchema = z.object({ @@ -112,7 +113,8 @@ export let manageCustomer = SlateTool.create(spec, { }); if (ctx.input.action === 'get') { - if (!ctx.input.customerId) throw new Error('customerId is required for get action'); + if (!ctx.input.customerId) + throw magentoServiceError('customerId is required for get action'); let customer = await client.getCustomer(ctx.input.customerId); return { output: { customer: mapCustomer(customer) }, @@ -121,7 +123,8 @@ export let manageCustomer = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.customerId) throw new Error('customerId is required for delete action'); + if (!ctx.input.customerId) + throw magentoServiceError('customerId is required for delete action'); await client.deleteCustomer(ctx.input.customerId); return { output: { deleted: true }, @@ -165,7 +168,8 @@ export let manageCustomer = SlateTool.create(spec, { } // update - if (!ctx.input.customerId) throw new Error('customerId is required for update action'); + if (!ctx.input.customerId) + throw magentoServiceError('customerId is required for update action'); customerData.id = ctx.input.customerId; let customer = await client.updateCustomer(ctx.input.customerId, customerData); return { diff --git a/integrations/magento/src/tools/manage-inventory.ts b/integrations/magento/src/tools/manage-inventory.ts index 67d24b233b..c4d0722ade 100644 --- a/integrations/magento/src/tools/manage-inventory.ts +++ b/integrations/magento/src/tools/manage-inventory.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MagentoClient } from '../lib/client'; +import { magentoServiceError } from '../lib/errors'; import { spec } from '../spec'; let sourceItemSchema = z.object({ @@ -79,7 +80,7 @@ export let manageInventory = SlateTool.create(spec, { }); if (ctx.input.action === 'get_stock') { - if (!ctx.input.sku) throw new Error('sku is required for get_stock action'); + if (!ctx.input.sku) throw magentoServiceError('sku is required for get_stock action'); let stock = await client.getStockItem(ctx.input.sku); return { output: { @@ -96,7 +97,7 @@ export let manageInventory = SlateTool.create(spec, { } if (ctx.input.action === 'get_sources') { - if (!ctx.input.sku) throw new Error('sku is required for get_sources action'); + if (!ctx.input.sku) throw magentoServiceError('sku is required for get_sources action'); let result = await client.getSourceItems(ctx.input.sku); return { output: { @@ -113,7 +114,7 @@ export let manageInventory = SlateTool.create(spec, { if (ctx.input.action === 'update_sources') { if (!ctx.input.sourceItems || ctx.input.sourceItems.length === 0) { - throw new Error('sourceItems are required for update_sources action'); + throw magentoServiceError('sourceItems are required for update_sources action'); } await client.saveSourceItems( ctx.input.sourceItems.map(s => ({ @@ -130,9 +131,9 @@ export let manageInventory = SlateTool.create(spec, { } // check_salable - if (!ctx.input.sku) throw new Error('sku is required for check_salable action'); + if (!ctx.input.sku) throw magentoServiceError('sku is required for check_salable action'); if (ctx.input.stockId === undefined) - throw new Error('stockId is required for check_salable action'); + throw magentoServiceError('stockId is required for check_salable action'); let isSalable = await client.isProductSalable(ctx.input.sku, ctx.input.stockId); return { output: { isSalable }, diff --git a/integrations/magento/src/tools/manage-order.ts b/integrations/magento/src/tools/manage-order.ts index 6f513ef5bf..c1c8589ea6 100644 --- a/integrations/magento/src/tools/manage-order.ts +++ b/integrations/magento/src/tools/manage-order.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MagentoClient } from '../lib/client'; +import { magentoServiceError } from '../lib/errors'; import { spec } from '../spec'; let orderItemSchema = z.object({ @@ -128,7 +129,7 @@ export let manageOrder = SlateTool.create(spec, { if (ctx.input.action === 'comment') { if (!ctx.input.commentText) { - throw new Error('commentText is required for the comment action'); + throw magentoServiceError('commentText is required for the comment action'); } await client.addOrderComment( ctx.input.orderId, diff --git a/integrations/magento/src/tools/manage-product-media.ts b/integrations/magento/src/tools/manage-product-media.ts new file mode 100644 index 0000000000..e00b731583 --- /dev/null +++ b/integrations/magento/src/tools/manage-product-media.ts @@ -0,0 +1,180 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MagentoClient } from '../lib/client'; +import { magentoServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let mediaOutputSchema = z.object({ + mediaId: z.string().optional().describe('Product media entry ID'), + mediaType: z.string().optional().describe('Media type, such as image'), + label: z.string().optional().describe('Media label'), + position: z.number().optional().describe('Media sort position'), + disabled: z.boolean().optional().describe('Whether this media entry is disabled'), + types: z + .array(z.string()) + .optional() + .describe('Assigned image roles, such as image, small_image, and thumbnail'), + file: z.string().optional().describe('Stored media file path') +}); + +let mapMedia = (entry: any) => ({ + mediaId: entry.id !== undefined ? String(entry.id) : undefined, + mediaType: entry.media_type, + label: entry.label, + position: entry.position, + disabled: entry.disabled, + types: entry.types, + file: entry.file +}); + +let requireReplacementContent = (input: { + imageBase64?: string; + mimeType?: string; + fileName?: string; +}) => { + let hasAnyContent = + input.imageBase64 !== undefined || + input.mimeType !== undefined || + input.fileName !== undefined; + let hasAllContent = + input.imageBase64 !== undefined && + input.mimeType !== undefined && + input.fileName !== undefined; + + if (hasAnyContent && !hasAllContent) { + throw magentoServiceError( + 'imageBase64, mimeType, and fileName must be provided together.' + ); + } +}; + +export let manageProductMedia = SlateTool.create(spec, { + name: 'Manage Product Media', + key: 'manage_product_media', + description: + 'List, add, update, or delete product media gallery entries. Use this for product images and storefront image roles such as image, small_image, and thumbnail.', + instructions: [ + 'To **list** media entries, provide sku and set action to "list".', + 'To **add** an image, provide sku, imageBase64, mimeType, and fileName. Optionally set label, position, disabled, and types.', + 'To **update** an image, provide sku and entryId. Provide imageBase64, mimeType, and fileName together when replacing the image binary.', + 'To **delete** an image, provide sku and entryId.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + action: z.enum(['list', 'add', 'update', 'delete']).describe('Media operation'), + sku: z.string().describe('Product SKU'), + entryId: z.number().optional().describe('Media entry ID for update and delete'), + label: z.string().optional().describe('Media label'), + position: z.number().optional().describe('Media sort position'), + disabled: z.boolean().optional().describe('Whether the media entry is disabled'), + types: z + .array(z.string()) + .optional() + .describe('Image roles to assign, such as image, small_image, and thumbnail'), + imageBase64: z + .string() + .optional() + .describe('Base64-encoded image bytes for add or binary replacement update'), + mimeType: z.string().optional().describe('Image MIME type, such as image/png'), + fileName: z.string().optional().describe('Image file name, such as product-image.png') + }) + ) + .output( + z.object({ + mediaEntries: z.array(mediaOutputSchema).optional().describe('Product media entries'), + media: mediaOutputSchema.optional().describe('Created or updated product media entry'), + mediaId: z.string().optional().describe('Created media entry ID'), + success: z.boolean().optional().describe('Whether the operation succeeded'), + deleted: z.boolean().optional().describe('Whether the media entry was deleted') + }) + ) + .handleInvocation(async ctx => { + let client = new MagentoClient({ + storeUrl: ctx.config.storeUrl, + storeCode: ctx.config.storeCode, + token: ctx.auth.token + }); + + if (ctx.input.action === 'list') { + let mediaEntries = await client.listProductMedia(ctx.input.sku); + let entryLabel = mediaEntries.length === 1 ? 'entry' : 'entries'; + return { + output: { mediaEntries: mediaEntries.map(mapMedia) }, + message: `Found **${mediaEntries.length}** media ${entryLabel} for SKU \`${ctx.input.sku}\`.` + }; + } + + if (ctx.input.action === 'delete') { + if (ctx.input.entryId === undefined) { + throw magentoServiceError('entryId is required for delete action'); + } + + await client.deleteProductMedia(ctx.input.sku, ctx.input.entryId); + return { + output: { deleted: true }, + message: `Deleted media entry \`${ctx.input.entryId}\` from SKU \`${ctx.input.sku}\`.` + }; + } + + requireReplacementContent(ctx.input); + + if (ctx.input.action === 'add') { + if (!ctx.input.imageBase64 || !ctx.input.mimeType || !ctx.input.fileName) { + throw magentoServiceError( + 'imageBase64, mimeType, and fileName are required for add action' + ); + } + + let mediaId = await client.createProductMedia(ctx.input.sku, { + media_type: 'image', + label: ctx.input.label, + position: ctx.input.position, + disabled: ctx.input.disabled, + types: ctx.input.types, + content: { + base64_encoded_data: ctx.input.imageBase64, + type: ctx.input.mimeType, + name: ctx.input.fileName + } + }); + + return { + output: { mediaId: String(mediaId) }, + message: `Added media entry **${mediaId}** to SKU \`${ctx.input.sku}\`.` + }; + } + + if (ctx.input.entryId === undefined) { + throw magentoServiceError('entryId is required for update action'); + } + + let mediaEntry: Record = { + media_type: 'image' + }; + if (ctx.input.label !== undefined) mediaEntry.label = ctx.input.label; + if (ctx.input.position !== undefined) mediaEntry.position = ctx.input.position; + if (ctx.input.disabled !== undefined) mediaEntry.disabled = ctx.input.disabled; + if (ctx.input.types !== undefined) mediaEntry.types = ctx.input.types; + if (ctx.input.imageBase64 && ctx.input.mimeType && ctx.input.fileName) { + mediaEntry.content = { + base64_encoded_data: ctx.input.imageBase64, + type: ctx.input.mimeType, + name: ctx.input.fileName + }; + } + + await client.updateProductMedia(ctx.input.sku, ctx.input.entryId, mediaEntry); + return { + output: { + success: true, + media: mapMedia({ ...mediaEntry, id: ctx.input.entryId }) + }, + message: `Updated media entry \`${ctx.input.entryId}\` for SKU \`${ctx.input.sku}\`.` + }; + }) + .build(); diff --git a/integrations/mailerlite/README.md b/integrations/mailerlite/README.md index ff757eab79..2d83eba3a0 100644 --- a/integrations/mailerlite/README.md +++ b/integrations/mailerlite/README.md @@ -1,6 +1,6 @@ # Mailer Lite -Manage email marketing subscribers, campaigns, automations, and signup forms. Create, update, and segment subscribers into groups with custom fields. Build and send email campaigns (regular, A/B split, resend) with scheduling and activity reporting. List and monitor marketing automations and their subscriber activity. Manage signup forms and track form submissions. Connect external e-commerce shops to manage products, orders, carts, and customers for abandoned cart automations, post-purchase emails, and sales tracking. Configure webhooks to receive real-time notifications on subscriber events (created, unsubscribed, bounced, spam reported) and campaign events (sent, opened, clicked). +Manage email marketing subscribers, campaigns, automations, signup forms, and webhooks. Create, update, and segment subscribers into groups with custom fields. Build and send email campaigns (regular, A/B split, resend) with scheduling and activity reporting. List and monitor marketing automations and their subscriber activity. Inspect signup forms and track form submissions. Configure webhooks to receive real-time notifications on subscriber events (created, unsubscribed, bounced, spam reported) and campaign events (sent, opened, clicked). ## License diff --git a/integrations/mailerlite/package.json b/integrations/mailerlite/package.json index d348d6798c..f072e33326 100644 --- a/integrations/mailerlite/package.json +++ b/integrations/mailerlite/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/mailerlite/src/index.ts b/integrations/mailerlite/src/index.ts index 89a6f0105f..ba27207098 100644 --- a/integrations/mailerlite/src/index.ts +++ b/integrations/mailerlite/src/index.ts @@ -9,15 +9,19 @@ import { getSubscriber, getSubscriberActivity, listAutomations, + listCampaignLanguages, listCampaigns, listForms, listGroups, listSegments, listSubscribers, + listTimezones, manageCustomField, manageGroup, manageGroupSubscribers, - scheduleOrSendCampaign + manageWebhook, + scheduleOrSendCampaign, + updateCampaign } from './tools'; import { campaignEvents, subscriberEvents } from './triggers'; @@ -35,12 +39,16 @@ export let provider = Slate.create({ listSegments, manageCustomField, createCampaign, + updateCampaign, listCampaigns, scheduleOrSendCampaign, deleteCampaign, getCampaignReport, listAutomations, - listForms + listForms, + manageWebhook, + listTimezones, + listCampaignLanguages ], triggers: [subscriberEvents, campaignEvents] }); diff --git a/integrations/mailerlite/src/lib/client.ts b/integrations/mailerlite/src/lib/client.ts index 152c49e5a8..31fc3875dc 100644 --- a/integrations/mailerlite/src/lib/client.ts +++ b/integrations/mailerlite/src/lib/client.ts @@ -1,13 +1,25 @@ import { createAxios } from 'slates'; +import { mailerLiteApiError, mailerLiteServiceError } from './errors'; let api = createAxios({ baseURL: 'https://connect.mailerlite.com/api' }); +api.interceptors.response.use( + response => response, + error => { + throw mailerLiteApiError(error); + } +); + export class Client { private headers: Record; constructor(config: { token: string }) { + if (!config.token?.trim()) { + throw mailerLiteServiceError('MailerLite API key is required.'); + } + this.headers = { 'Content-Type': 'application/json', Accept: 'application/json', @@ -17,10 +29,20 @@ export class Client { // ── Subscribers ────────────────────────────────────────────────── - async listSubscribers(params?: { status?: string; limit?: number; cursor?: string }) { + async listSubscribers(params?: { + status?: string; + limit?: number; + cursor?: string; + includeGroups?: boolean; + }) { let response = await api.get('/subscribers', { headers: this.headers, - params + params: { + ...(params?.status ? { 'filter[status]': params.status } : {}), + limit: params?.limit, + cursor: params?.cursor, + ...(params?.includeGroups ? { include: 'groups' } : {}) + } }); return response.data; } @@ -34,6 +56,8 @@ export class Client { ip_address?: string; opted_in_at?: string; optin_ip?: string; + unsubscribed_at?: string; + resubscribe?: boolean; }) { let response = await api.post('/subscribers', data, { headers: this.headers @@ -54,6 +78,11 @@ export class Client { fields?: Record; groups?: string[]; status?: string; + subscribed_at?: string; + ip_address?: string; + opted_in_at?: string; + optin_ip?: string; + unsubscribed_at?: string; } ) { let response = await api.put(`/subscribers/${subscriberId}`, data, { @@ -91,14 +120,18 @@ export class Client { async getSubscriberActivity( subscriberId: string, params?: { - type?: string; + logName?: string; limit?: number; - cursor?: string; + page?: number; } ) { - let response = await api.get(`/subscribers/${subscriberId}/activity`, { + let response = await api.get(`/subscribers/${subscriberId}/activity-log`, { headers: this.headers, - params + params: { + ...(params?.logName ? { 'filter[log_name]': params.logName } : {}), + limit: params?.limit, + page: params?.page + } }); return response.data; } @@ -312,15 +345,18 @@ export class Client { async createCampaign(data: { name: string; type: string; + language_id?: number; emails: Array<{ subject: string; from_name: string; from: string; + reply_to?: string; content?: string; }>; groups?: string[]; segments?: string[]; filter?: Record; + settings?: Record; }) { let response = await api.post('/campaigns', data, { headers: this.headers @@ -332,15 +368,18 @@ export class Client { campaignId: string, data: { name?: string; + language_id?: number; emails?: Array<{ subject?: string; from_name?: string; from?: string; + reply_to?: string; content?: string; }>; groups?: string[]; segments?: string[]; filter?: Record; + settings?: Record; } ) { let response = await api.put(`/campaigns/${campaignId}`, data, { @@ -357,12 +396,36 @@ export class Client { hours?: string; minutes?: string; timezone_id?: number; - resend?: string; + resend?: { + delivery?: string; + date?: string; + hours?: string; + minutes?: string; + timezone_id?: number; + }; } ) { - let response = await api.post(`/campaigns/${campaignId}/schedule`, data, { - headers: this.headers - }); + let schedule = + data.date || data.hours || data.minutes || data.timezone_id !== undefined + ? { + date: data.date, + hours: data.hours, + minutes: data.minutes, + timezone_id: data.timezone_id + } + : undefined; + + let response = await api.post( + `/campaigns/${campaignId}/schedule`, + { + delivery: data.delivery, + ...(schedule ? { schedule } : {}), + ...(data.resend ? { resend: data.resend } : {}) + }, + { + headers: this.headers + } + ); return response.data; } @@ -388,13 +451,23 @@ export class Client { campaignId: string, params?: { type?: string; + search?: string; limit?: number; page?: number; + sort?: string; + include?: string; } ) { let response = await api.get(`/campaigns/${campaignId}/reports/subscriber-activity`, { headers: this.headers, - params + params: { + ...(params?.type ? { 'filter[type]': params.type } : {}), + ...(params?.search ? { 'filter[search]': params.search } : {}), + limit: params?.limit, + page: params?.page, + sort: params?.sort, + include: params?.include + } }); return response.data; } @@ -432,6 +505,10 @@ export class Client { automationId: string, params?: { status?: string; + date_from?: string; + date_to?: string; + scheduled_from?: string; + scheduled_to?: string; limit?: number; page?: number; } @@ -440,6 +517,10 @@ export class Client { headers: this.headers, params: { ...(params?.status ? { 'filter[status]': params.status } : {}), + ...(params?.date_from ? { 'filter[date_from]': params.date_from } : {}), + ...(params?.date_to ? { 'filter[date_to]': params.date_to } : {}), + ...(params?.scheduled_from ? { 'filter[scheduled_from]': params.scheduled_from } : {}), + ...(params?.scheduled_to ? { 'filter[scheduled_to]': params.scheduled_to } : {}), limit: params?.limit, page: params?.page } @@ -500,7 +581,7 @@ export class Client { params?: { status?: string; limit?: number; - page?: number; + cursor?: string; } ) { let response = await api.get(`/forms/${formId}/subscribers`, { @@ -508,7 +589,7 @@ export class Client { params: { ...(params?.status ? { 'filter[status]': params.status } : {}), limit: params?.limit, - page: params?.page + cursor: params?.cursor } }); return response.data; diff --git a/integrations/mailerlite/src/lib/errors.ts b/integrations/mailerlite/src/lib/errors.ts new file mode 100644 index 0000000000..3f9a2fe676 --- /dev/null +++ b/integrations/mailerlite/src/lib/errors.ts @@ -0,0 +1,94 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[], prefix?: string) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details, prefix); + } + return; + } + + if (!isRecord(value)) { + if (prefix && (typeof value === 'string' || typeof value === 'number')) { + pushDetail(details, `${prefix}: ${value}`); + return; + } + pushDetail(details, value); + return; + } + + pushDetail(details, value.message); + pushDetail(details, value.error); + pushDetail(details, value.error_description); + + for (let [key, child] of Object.entries(value)) { + if (key === 'message' || key === 'error' || key === 'error_description') { + continue; + } + collectDetails(child, details, key); + } +}; + +let extractMailerLiteMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let mailerLiteServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let mailerLiteApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = mailerLiteServiceError( + `MailerLite API ${operation} failed: ${statusLabelFor(response)}${extractMailerLiteMessage(error)}` + ); + serviceError.data.reason = 'mailerlite_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/mailerlite/src/spec.ts b/integrations/mailerlite/src/spec.ts index d192017592..e780578141 100644 --- a/integrations/mailerlite/src/spec.ts +++ b/integrations/mailerlite/src/spec.ts @@ -6,7 +6,7 @@ export let spec = SlateSpecification.create({ key: 'mailerlite', name: 'MailerLite', description: - 'Email marketing platform for subscriber management, campaign creation, automations, and e-commerce integration.', + 'Email marketing platform for subscriber management, campaign creation, automations, signup forms, and webhooks.', metadata: {}, config, auth diff --git a/integrations/mailerlite/src/tools.schema.test.ts b/integrations/mailerlite/src/tools.schema.test.ts new file mode 100644 index 0000000000..955dda6ee3 --- /dev/null +++ b/integrations/mailerlite/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('MailerLite tool input schemas', provider.actions); diff --git a/integrations/mailerlite/src/tools/create-campaign.ts b/integrations/mailerlite/src/tools/create-campaign.ts index 6f7bd5acae..9150767d9b 100644 --- a/integrations/mailerlite/src/tools/create-campaign.ts +++ b/integrations/mailerlite/src/tools/create-campaign.ts @@ -16,18 +16,27 @@ export let createCampaign = SlateTool.create(spec, { z.object({ name: z.string().describe('Campaign name'), type: z.enum(['regular', 'ab', 'resend']).describe('Campaign type'), + languageId: z + .number() + .optional() + .describe('Campaign language ID for unsubscribe templates'), emails: z .array( z.object({ subject: z.string().describe('Email subject line'), fromName: z.string().describe('Sender name'), from: z.string().describe('Sender email address'), + replyTo: z.string().optional().describe('Verified reply-to email address'), content: z.string().optional().describe('Email HTML content') }) ) .describe('Email configurations (one for regular, multiple for A/B)'), groupIds: z.array(z.string()).optional().describe('Target group IDs'), - segmentIds: z.array(z.string()).optional().describe('Target segment IDs') + segmentIds: z.array(z.string()).optional().describe('Target segment IDs'), + ecommerceTracking: z + .boolean() + .optional() + .describe('Enable e-commerce link tracking for campaign content') }) ) .output( @@ -45,14 +54,20 @@ export let createCampaign = SlateTool.create(spec, { let result = await client.createCampaign({ name: ctx.input.name, type: ctx.input.type, + language_id: ctx.input.languageId, emails: ctx.input.emails.map(e => ({ subject: e.subject, from_name: e.fromName, from: e.from, + reply_to: e.replyTo, content: e.content })), groups: ctx.input.groupIds, - segments: ctx.input.segmentIds + segments: ctx.input.segmentIds, + settings: + ctx.input.ecommerceTracking === undefined + ? undefined + : { ecommerce_tracking: ctx.input.ecommerceTracking } }); let campaign = result.data; diff --git a/integrations/mailerlite/src/tools/create-or-update-subscriber.ts b/integrations/mailerlite/src/tools/create-or-update-subscriber.ts index 1a2cd2ef4a..52b5646a26 100644 --- a/integrations/mailerlite/src/tools/create-or-update-subscriber.ts +++ b/integrations/mailerlite/src/tools/create-or-update-subscriber.ts @@ -34,13 +34,21 @@ export let createOrUpdateSubscriber = SlateTool.create(spec, { subscribedAt: z .string() .optional() - .describe('Date when the subscriber was added (ISO 8601 format)'), + .describe('Date when the subscriber was added, in yyyy-MM-dd HH:mm:ss format'), ipAddress: z.string().optional().describe('IP address of the subscriber'), optedInAt: z .string() .optional() - .describe('Date when the subscriber opted in (ISO 8601 format)'), - optinIp: z.string().optional().describe('IP address used when opting in') + .describe('Date when the subscriber opted in, in yyyy-MM-dd HH:mm:ss format'), + optinIp: z.string().optional().describe('IP address used when opting in'), + unsubscribedAt: z + .string() + .optional() + .describe('Unsubscribe date in yyyy-MM-dd HH:mm:ss format'), + resubscribe: z + .boolean() + .optional() + .describe('Set true to resubscribe a previously unsubscribed subscriber when allowed') }) ) .output( @@ -64,7 +72,9 @@ export let createOrUpdateSubscriber = SlateTool.create(spec, { subscribed_at: ctx.input.subscribedAt, ip_address: ctx.input.ipAddress, opted_in_at: ctx.input.optedInAt, - optin_ip: ctx.input.optinIp + optin_ip: ctx.input.optinIp, + unsubscribed_at: ctx.input.unsubscribedAt, + resubscribe: ctx.input.resubscribe }); let subscriber = result.data; diff --git a/integrations/mailerlite/src/tools/get-campaign-report.ts b/integrations/mailerlite/src/tools/get-campaign-report.ts index 0e0dc4b773..8e62eba7ca 100644 --- a/integrations/mailerlite/src/tools/get-campaign-report.ts +++ b/integrations/mailerlite/src/tools/get-campaign-report.ts @@ -16,11 +16,29 @@ export let getCampaignReport = SlateTool.create(spec, { z.object({ campaignId: z.string().describe('ID of the campaign'), activityType: z - .enum(['opens', 'clicks', 'unsubscribes', 'bounces', 'junks', 'forwards']) + .enum([ + 'opened', + 'unopened', + 'clicked', + 'unsubscribed', + 'forwarded', + 'hardbounced', + 'softbounced', + 'junk' + ]) .optional() .describe('Filter subscriber activity by type'), + search: z.string().optional().describe('Filter subscriber activity by subscriber email'), limit: z.number().optional().describe('Number of activity records per page'), - page: z.number().optional().describe('Page number') + page: z.number().optional().describe('Page number'), + sort: z + .enum(['id', 'updated_at', 'clicks_count', 'opens_count']) + .optional() + .describe('Sort subscriber activity by MailerLite-supported field'), + includeSubscriberGroups: z + .boolean() + .optional() + .describe('Include subscriber.groups in campaign activity records') }) ) .output( @@ -53,8 +71,11 @@ export let getCampaignReport = SlateTool.create(spec, { if (campaign.status === 'sent') { let activityResult = await client.getCampaignSubscriberActivity(ctx.input.campaignId, { type: ctx.input.activityType, + search: ctx.input.search, limit: ctx.input.limit, - page: ctx.input.page + page: ctx.input.page, + sort: ctx.input.sort, + include: ctx.input.includeSubscriberGroups ? 'subscriber.groups' : 'subscriber' }); activities = (activityResult.data || []).map((a: any) => ({ subscriberEmail: a.subscriber?.email || a.email, diff --git a/integrations/mailerlite/src/tools/get-subscriber-activity.ts b/integrations/mailerlite/src/tools/get-subscriber-activity.ts index ac959f1d07..dd1ce99ec8 100644 --- a/integrations/mailerlite/src/tools/get-subscriber-activity.ts +++ b/integrations/mailerlite/src/tools/get-subscriber-activity.ts @@ -18,9 +18,26 @@ export let getSubscriberActivity = SlateTool.create(spec, { type: z .enum(['opens', 'clicks', 'junks', 'bounces', 'unsubscribes', 'forwards', 'sent']) .optional() - .describe('Filter by activity type'), + .describe( + 'Deprecated activity filter. Use activityLogName for current MailerLite log names.' + ), + activityLogName: z + .enum([ + 'campaign_send', + 'automation_email_sent', + 'email_open', + 'link_click', + 'email_bounce', + 'spam_complaint', + 'unsubscribed', + 'email_forward', + 'marketing_preferences_change', + 'preference_center' + ]) + .optional() + .describe('Filter by current MailerLite activity log_name value'), limit: z.number().optional().describe('Number of activity records to return'), - cursor: z.string().optional().describe('Pagination cursor from a previous response') + page: z.number().optional().describe('Page number for pagination') }) ) .output( @@ -41,15 +58,28 @@ export let getSubscriberActivity = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token }); + let legacyLogNameMap: Record = { + opens: 'email_open', + clicks: 'link_click', + junks: 'spam_complaint', + bounces: 'email_bounce', + unsubscribes: 'unsubscribed', + forwards: 'email_forward', + sent: 'campaign_send' + }; + let logName = + ctx.input.activityLogName ?? + (ctx.input.type ? legacyLogNameMap[ctx.input.type] : undefined); + let result = await client.getSubscriberActivity(ctx.input.subscriberId, { - type: ctx.input.type, + logName, limit: ctx.input.limit, - cursor: ctx.input.cursor + page: ctx.input.page }); let activities = (result.data || []).map((a: any) => ({ activityId: a.id, - type: a.type, + type: a.log_name, timestamp: a.created_at || a.timestamp, details: a })); @@ -57,7 +87,7 @@ export let getSubscriberActivity = SlateTool.create(spec, { return { output: { activities, - nextCursor: result.meta?.next_cursor || null + nextCursor: null }, message: `Retrieved **${activities.length}** activity records for subscriber **${ctx.input.subscriberId}**.` }; diff --git a/integrations/mailerlite/src/tools/get-subscriber.ts b/integrations/mailerlite/src/tools/get-subscriber.ts index 999d6acffd..039a98f4d2 100644 --- a/integrations/mailerlite/src/tools/get-subscriber.ts +++ b/integrations/mailerlite/src/tools/get-subscriber.ts @@ -45,8 +45,8 @@ export let getSubscriber = SlateTool.create(spec, { groups: s.groups, subscribedAt: s.subscribed_at, createdAt: s.created_at, - openedCount: s.opened_count, - clickedCount: s.clicked_count + openedCount: s.opens_count, + clickedCount: s.clicks_count }, message: `Found subscriber **${s.email}** (status: **${s.status}**).` }; diff --git a/integrations/mailerlite/src/tools/index.ts b/integrations/mailerlite/src/tools/index.ts index 992b261d47..7261c6cd43 100644 --- a/integrations/mailerlite/src/tools/index.ts +++ b/integrations/mailerlite/src/tools/index.ts @@ -6,12 +6,16 @@ export * from './get-campaign-report'; export * from './get-subscriber'; export * from './get-subscriber-activity'; export * from './list-automations'; +export * from './list-campaign-languages'; export * from './list-campaigns'; export * from './list-forms'; export * from './list-groups'; export * from './list-segments'; export * from './list-subscribers'; +export * from './list-timezones'; export * from './manage-custom-field'; export * from './manage-group'; export * from './manage-group-subscribers'; +export * from './manage-webhook'; export * from './schedule-or-send-campaign'; +export * from './update-campaign'; diff --git a/integrations/mailerlite/src/tools/list-automations.ts b/integrations/mailerlite/src/tools/list-automations.ts index cb85de552e..8f61993019 100644 --- a/integrations/mailerlite/src/tools/list-automations.ts +++ b/integrations/mailerlite/src/tools/list-automations.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { mailerLiteServiceError } from '../lib/errors'; import { spec } from '../spec'; export let listAutomations = SlateTool.create(spec, { @@ -32,6 +33,22 @@ export let listAutomations = SlateTool.create(spec, { .enum(['completed', 'active', 'canceled', 'failed']) .optional() .describe('Filter activity by status'), + activityDateFrom: z + .string() + .optional() + .describe('Activity date_from filter in Y-m-d format for completed/canceled/failed'), + activityDateTo: z + .string() + .optional() + .describe('Activity date_to filter in Y-m-d format for completed/canceled/failed'), + activityScheduledFrom: z + .string() + .optional() + .describe('Scheduled_from filter in Y-m-d format for active automation activity'), + activityScheduledTo: z + .string() + .optional() + .describe('Scheduled_to filter in Y-m-d format for active automation activity'), limit: z.number().optional().describe('Number of results per page'), page: z.number().optional().describe('Page number') }) @@ -77,10 +94,20 @@ export let listAutomations = SlateTool.create(spec, { let activities: any[] | undefined; if (ctx.input.includeActivity) { + if (!ctx.input.activityStatus) { + throw mailerLiteServiceError( + 'activityStatus is required when includeActivity is true.' + ); + } + let activityResult = await client.getAutomationSubscriberActivity( ctx.input.automationId, { status: ctx.input.activityStatus, + date_from: ctx.input.activityDateFrom, + date_to: ctx.input.activityDateTo, + scheduled_from: ctx.input.activityScheduledFrom, + scheduled_to: ctx.input.activityScheduledTo, limit: ctx.input.limit, page: ctx.input.page } diff --git a/integrations/mailerlite/src/tools/list-campaign-languages.ts b/integrations/mailerlite/src/tools/list-campaign-languages.ts new file mode 100644 index 0000000000..9d7b910db4 --- /dev/null +++ b/integrations/mailerlite/src/tools/list-campaign-languages.ts @@ -0,0 +1,47 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listCampaignLanguages = SlateTool.create(spec, { + name: 'List Campaign Languages', + key: 'list_campaign_languages', + description: `Lists MailerLite campaign language IDs and codes. Use these language IDs when creating or updating localized campaigns.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + languages: z + .array( + z.object({ + languageId: z.string().describe('MailerLite campaign language ID'), + shortcode: z.string().optional().describe('Short language code'), + iso639: z.string().optional().describe('ISO 639 locale code'), + name: z.string().describe('Language name'), + direction: z.string().optional().describe('Text direction') + }) + ) + .describe('Available MailerLite campaign languages') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listCampaignLanguages(); + let languages = (result.data || []).map((language: any) => ({ + languageId: String(language.id), + shortcode: language.shortcode, + iso639: language.iso639, + name: language.name, + direction: language.direction + })); + + return { + output: { languages }, + message: `Retrieved **${languages.length}** MailerLite campaign languages.` + }; + }) + .build(); diff --git a/integrations/mailerlite/src/tools/list-forms.ts b/integrations/mailerlite/src/tools/list-forms.ts index 5937777901..038e288ff2 100644 --- a/integrations/mailerlite/src/tools/list-forms.ts +++ b/integrations/mailerlite/src/tools/list-forms.ts @@ -26,6 +26,7 @@ export let listForms = SlateTool.create(spec, { .describe('Filter form subscribers by status (only when formId is provided)'), limit: z.number().optional().describe('Number of results per page'), page: z.number().optional().describe('Page number'), + cursor: z.string().optional().describe('Pagination cursor for form subscriber results'), sort: z.string().optional().describe('Sort field') }) ) @@ -54,7 +55,12 @@ export let listForms = SlateTool.create(spec, { }) ) .optional() - .describe('Subscribers who signed up through the form') + .describe('Subscribers who signed up through the form'), + nextCursor: z + .string() + .optional() + .nullable() + .describe('Cursor for the next form-subscriber page') }) ) .handleInvocation(async ctx => { @@ -64,7 +70,7 @@ export let listForms = SlateTool.create(spec, { let result = await client.getFormSubscribers(ctx.input.formId, { status: ctx.input.subscriberStatus, limit: ctx.input.limit, - page: ctx.input.page + cursor: ctx.input.cursor }); let subscribers = (result.data || []).map((s: any) => ({ @@ -74,7 +80,7 @@ export let listForms = SlateTool.create(spec, { })); return { - output: { subscribers }, + output: { subscribers, nextCursor: result.meta?.next_cursor || null }, message: `Retrieved **${subscribers.length}** subscribers from form **${ctx.input.formId}**.` }; } diff --git a/integrations/mailerlite/src/tools/list-subscribers.ts b/integrations/mailerlite/src/tools/list-subscribers.ts index 2298c76197..5e01723b9d 100644 --- a/integrations/mailerlite/src/tools/list-subscribers.ts +++ b/integrations/mailerlite/src/tools/list-subscribers.ts @@ -6,7 +6,7 @@ import { spec } from '../spec'; export let listSubscribers = SlateTool.create(spec, { name: 'List Subscribers', key: 'list_subscribers', - description: `Retrieves a paginated list of subscribers. Can filter by subscriber status (active, unsubscribed, unconfirmed, bounced, junk). Use cursor-based pagination to iterate through large lists.`, + description: `Retrieves a paginated list of subscribers. Can filter by subscriber status (active, unsubscribed, unconfirmed, bounced, junk), include group memberships, or request the total subscriber count with limit 0. Use cursor-based pagination to iterate through large lists.`, tags: { destructive: false, readOnly: true @@ -21,8 +21,14 @@ export let listSubscribers = SlateTool.create(spec, { limit: z .number() .optional() - .describe('Number of subscribers to return per page (default 25, max 50)'), - cursor: z.string().optional().describe('Pagination cursor from a previous response') + .describe( + 'Number of subscribers to return per page. Use 0 to fetch total count only.' + ), + cursor: z.string().optional().describe('Pagination cursor from a previous response'), + includeGroups: z + .boolean() + .optional() + .describe('Include each subscriber group memberships in the response') }) ) .output( @@ -34,11 +40,19 @@ export let listSubscribers = SlateTool.create(spec, { email: z.string().describe('Email address'), status: z.string().describe('Subscriber status'), fields: z.record(z.string(), z.any()).optional().describe('Custom field values'), + groups: z + .array(z.any()) + .optional() + .describe('Groups included for this subscriber'), subscribedAt: z.string().optional().describe('Subscription timestamp'), createdAt: z.string().optional().describe('Creation timestamp') }) ) .describe('List of subscribers'), + total: z + .number() + .optional() + .describe('Total subscriber count when returned by MailerLite'), nextCursor: z .string() .optional() @@ -52,7 +66,8 @@ export let listSubscribers = SlateTool.create(spec, { let result = await client.listSubscribers({ status: ctx.input.status, limit: ctx.input.limit, - cursor: ctx.input.cursor + cursor: ctx.input.cursor, + includeGroups: ctx.input.includeGroups }); let subscribers = (result.data || []).map((s: any) => ({ @@ -60,6 +75,7 @@ export let listSubscribers = SlateTool.create(spec, { email: s.email, status: s.status, fields: s.fields, + groups: s.groups, subscribedAt: s.subscribed_at, createdAt: s.created_at })); @@ -67,6 +83,7 @@ export let listSubscribers = SlateTool.create(spec, { return { output: { subscribers, + total: result.total ?? result.meta?.total, nextCursor: result.meta?.next_cursor || null }, message: `Retrieved **${subscribers.length}** subscribers${ctx.input.status ? ` with status **${ctx.input.status}**` : ''}.` diff --git a/integrations/mailerlite/src/tools/list-timezones.ts b/integrations/mailerlite/src/tools/list-timezones.ts new file mode 100644 index 0000000000..8325447f02 --- /dev/null +++ b/integrations/mailerlite/src/tools/list-timezones.ts @@ -0,0 +1,47 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listTimezones = SlateTool.create(spec, { + name: 'List Timezones', + key: 'list_timezones', + description: `Lists MailerLite timezone IDs and offsets. Use these timezoneId values when scheduling campaigns or assigning timezone-aware settings.`, + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + timezones: z + .array( + z.object({ + timezoneId: z.string().describe('MailerLite timezone ID'), + name: z.string().describe('Timezone name'), + nameForHumans: z.string().optional().describe('Display name with offset'), + offsetName: z.string().optional().describe('UTC offset label'), + offset: z.number().optional().describe('Offset in minutes') + }) + ) + .describe('Available MailerLite timezones') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + let result = await client.listTimezones(); + let timezones = (result.data || []).map((timezone: any) => ({ + timezoneId: String(timezone.id), + name: timezone.name, + nameForHumans: timezone.name_for_humans, + offsetName: timezone.offset_name, + offset: timezone.offset + })); + + return { + output: { timezones }, + message: `Retrieved **${timezones.length}** MailerLite timezones.` + }; + }) + .build(); diff --git a/integrations/mailerlite/src/tools/manage-custom-field.ts b/integrations/mailerlite/src/tools/manage-custom-field.ts index 9ca39a1002..aefab3ac75 100644 --- a/integrations/mailerlite/src/tools/manage-custom-field.ts +++ b/integrations/mailerlite/src/tools/manage-custom-field.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { mailerLiteServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageCustomField = SlateTool.create(spec, { @@ -84,8 +85,10 @@ export let manageCustomField = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('Field name is required for create action'); - if (!ctx.input.fieldType) throw new Error('Field type is required for create action'); + if (!ctx.input.name) + throw mailerLiteServiceError('Field name is required for create action'); + if (!ctx.input.fieldType) + throw mailerLiteServiceError('Field type is required for create action'); let result = await client.createField({ name: ctx.input.name, type: ctx.input.fieldType @@ -101,8 +104,10 @@ export let manageCustomField = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.fieldId) throw new Error('Field ID is required for update action'); - if (!ctx.input.name) throw new Error('Field name is required for update action'); + if (!ctx.input.fieldId) + throw mailerLiteServiceError('Field ID is required for update action'); + if (!ctx.input.name) + throw mailerLiteServiceError('Field name is required for update action'); let result = await client.updateField(ctx.input.fieldId, ctx.input.name); let f = result.data; return { @@ -114,7 +119,8 @@ export let manageCustomField = SlateTool.create(spec, { }; } - if (!ctx.input.fieldId) throw new Error('Field ID is required for delete action'); + if (!ctx.input.fieldId) + throw mailerLiteServiceError('Field ID is required for delete action'); await client.deleteField(ctx.input.fieldId); return { output: { success: true }, diff --git a/integrations/mailerlite/src/tools/manage-group-subscribers.ts b/integrations/mailerlite/src/tools/manage-group-subscribers.ts index a3ef29e40e..42039fddab 100644 --- a/integrations/mailerlite/src/tools/manage-group-subscribers.ts +++ b/integrations/mailerlite/src/tools/manage-group-subscribers.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { mailerLiteServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageGroupSubscribers = SlateTool.create(spec, { @@ -56,7 +57,7 @@ export let manageGroupSubscribers = SlateTool.create(spec, { if (ctx.input.action === 'assign') { if (!ctx.input.subscriberId) - throw new Error('Subscriber ID is required for assign action'); + throw mailerLiteServiceError('Subscriber ID is required for assign action'); await client.assignSubscriberToGroup(ctx.input.subscriberId, ctx.input.groupId); return { output: { success: true }, @@ -66,7 +67,7 @@ export let manageGroupSubscribers = SlateTool.create(spec, { if (ctx.input.action === 'unassign') { if (!ctx.input.subscriberId) - throw new Error('Subscriber ID is required for unassign action'); + throw mailerLiteServiceError('Subscriber ID is required for unassign action'); await client.unassignSubscriberFromGroup(ctx.input.subscriberId, ctx.input.groupId); return { output: { success: true }, diff --git a/integrations/mailerlite/src/tools/manage-group.ts b/integrations/mailerlite/src/tools/manage-group.ts index b95e636d58..7a64928aaf 100644 --- a/integrations/mailerlite/src/tools/manage-group.ts +++ b/integrations/mailerlite/src/tools/manage-group.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { mailerLiteServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageGroup = SlateTool.create(spec, { @@ -38,7 +39,8 @@ export let manageGroup = SlateTool.create(spec, { let client = new Client({ token: ctx.auth.token }); if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('Group name is required for create action'); + if (!ctx.input.name) + throw mailerLiteServiceError('Group name is required for create action'); let result = await client.createGroup(ctx.input.name); let group = result.data; return { @@ -55,8 +57,10 @@ export let manageGroup = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.groupId) throw new Error('Group ID is required for update action'); - if (!ctx.input.name) throw new Error('Group name is required for update action'); + if (!ctx.input.groupId) + throw mailerLiteServiceError('Group ID is required for update action'); + if (!ctx.input.name) + throw mailerLiteServiceError('Group name is required for update action'); let result = await client.updateGroup(ctx.input.groupId, ctx.input.name); let group = result.data; return { @@ -72,7 +76,8 @@ export let manageGroup = SlateTool.create(spec, { }; } - if (!ctx.input.groupId) throw new Error('Group ID is required for delete action'); + if (!ctx.input.groupId) + throw mailerLiteServiceError('Group ID is required for delete action'); await client.deleteGroup(ctx.input.groupId); return { output: { diff --git a/integrations/mailerlite/src/tools/manage-webhook.ts b/integrations/mailerlite/src/tools/manage-webhook.ts new file mode 100644 index 0000000000..44c54b40f1 --- /dev/null +++ b/integrations/mailerlite/src/tools/manage-webhook.ts @@ -0,0 +1,176 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { mailerLiteServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let webhookEventSchema = z.enum([ + 'subscriber.created', + 'subscriber.updated', + 'subscriber.unsubscribed', + 'subscriber.added_to_group', + 'subscriber.removed_from_group', + 'subscriber.bounced', + 'subscriber.automation_triggered', + 'subscriber.automation_completed', + 'subscriber.spam_reported', + 'subscriber.deleted', + 'subscriber.active', + 'campaign.sent', + 'campaign.click', + 'campaign.open' +]); + +let webhookOutputSchema = z.object({ + webhookId: z.string().describe('Webhook ID'), + name: z.string().optional().nullable().describe('Webhook name'), + url: z.string().optional().describe('Webhook URL'), + events: z.array(z.string()).optional().describe('Subscribed event names'), + enabled: z.boolean().optional().describe('Whether the webhook is enabled'), + batchable: z.boolean().optional().describe('Whether MailerLite batches webhook events'), + createdAt: z.string().optional().describe('Creation timestamp'), + updatedAt: z.string().optional().describe('Update timestamp') +}); + +let mapWebhook = (webhook: any) => ({ + webhookId: String(webhook.id), + name: webhook.name, + url: webhook.url, + events: webhook.events, + enabled: webhook.enabled, + batchable: webhook.batchable, + createdAt: webhook.created_at, + updatedAt: webhook.updated_at +}); + +let requireWebhookId = (webhookId: string | undefined, action: string) => { + if (!webhookId) { + throw mailerLiteServiceError(`webhookId is required for ${action} action.`); + } + + return webhookId; +}; + +let requireEvents = (events: string[] | undefined, action: string) => { + if (!events?.length) { + throw mailerLiteServiceError(`events are required for ${action} action.`); + } + + return events; +}; + +let requiresBatchable = (events: string[] | undefined) => + events?.some( + event => + event === 'campaign.open' || event === 'campaign.click' || event === 'subscriber.deleted' + ) ?? false; + +export let manageWebhook = SlateTool.create(spec, { + name: 'Manage Webhook', + key: 'manage_webhook', + description: `Lists, gets, creates, updates, or deletes MailerLite webhooks for subscriber and campaign events. For campaign.open, campaign.click, and subscriber.deleted events, MailerLite requires batchable to be true.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + action: z + .enum(['list', 'get', 'create', 'update', 'delete']) + .describe('Webhook action to perform'), + webhookId: z.string().optional().describe('Webhook ID for get, update, or delete'), + name: z.string().optional().describe('Webhook name for create or update'), + url: z.string().optional().describe('Webhook callback URL for create or update'), + events: z + .array(webhookEventSchema) + .optional() + .describe('MailerLite event names for create or update'), + enabled: z.boolean().optional().describe('Whether the webhook is enabled'), + batchable: z + .boolean() + .optional() + .describe('Whether MailerLite should batch webhook events') + }) + ) + .output( + z.object({ + webhooks: z.array(webhookOutputSchema).optional().describe('Webhook list'), + webhook: webhookOutputSchema + .optional() + .describe('Created, retrieved, or updated webhook'), + success: z.boolean().describe('Whether the action succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + if (ctx.input.action === 'list') { + let result = await client.listWebhooks(); + let webhooks = (result.data || []).map(mapWebhook); + return { + output: { webhooks, success: true }, + message: `Retrieved **${webhooks.length}** MailerLite webhooks.` + }; + } + + if (ctx.input.action === 'get') { + let webhookId = requireWebhookId(ctx.input.webhookId, 'get'); + let result = await client.getWebhook(webhookId); + let webhook = mapWebhook(result.data); + return { + output: { webhook, success: true }, + message: `Retrieved webhook **${webhook.webhookId}**.` + }; + } + + if (ctx.input.action === 'create') { + if (!ctx.input.url) { + throw mailerLiteServiceError('url is required for create action.'); + } + + let events = requireEvents(ctx.input.events, 'create'); + let batchable = requiresBatchable(events) ? true : ctx.input.batchable; + let result = await client.createWebhook({ + name: ctx.input.name, + url: ctx.input.url, + events, + enabled: ctx.input.enabled, + batchable + }); + let webhook = mapWebhook(result.data); + + return { + output: { webhook, success: true }, + message: `Created webhook **${webhook.webhookId}**.` + }; + } + + if (ctx.input.action === 'update') { + let webhookId = requireWebhookId(ctx.input.webhookId, 'update'); + let batchable = + ctx.input.batchable ?? (requiresBatchable(ctx.input.events) ? true : undefined); + let result = await client.updateWebhook(webhookId, { + name: ctx.input.name, + url: ctx.input.url, + events: ctx.input.events, + enabled: ctx.input.enabled, + batchable + }); + let webhook = mapWebhook(result.data); + + return { + output: { webhook, success: true }, + message: `Updated webhook **${webhook.webhookId}**.` + }; + } + + let webhookId = requireWebhookId(ctx.input.webhookId, 'delete'); + await client.deleteWebhook(webhookId); + + return { + output: { success: true }, + message: `Deleted webhook **${webhookId}**.` + }; + }) + .build(); diff --git a/integrations/mailerlite/src/tools/schedule-or-send-campaign.ts b/integrations/mailerlite/src/tools/schedule-or-send-campaign.ts index 804776addc..49116ab0b3 100644 --- a/integrations/mailerlite/src/tools/schedule-or-send-campaign.ts +++ b/integrations/mailerlite/src/tools/schedule-or-send-campaign.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { mailerLiteServiceError } from '../lib/errors'; import { spec } from '../spec'; export let scheduleOrSendCampaign = SlateTool.create(spec, { @@ -16,9 +17,9 @@ export let scheduleOrSendCampaign = SlateTool.create(spec, { z.object({ campaignId: z.string().describe('ID of the campaign to schedule or send'), action: z - .enum(['send', 'schedule', 'cancel']) + .enum(['send', 'schedule', 'timezone_based', 'smart_sending', 'cancel']) .describe( - '"send" for instant delivery, "schedule" to set a future date, "cancel" to revert to draft' + '"send" for instant delivery, "schedule" to set a future date, "timezone_based" for local-recipient time delivery, "smart_sending" for MailerLite smart sending, "cancel" to revert a ready campaign to draft' ), date: z .string() @@ -54,8 +55,31 @@ export let scheduleOrSendCampaign = SlateTool.create(spec, { }; } + if (ctx.input.action === 'schedule' || ctx.input.action === 'smart_sending') { + if (!ctx.input.date) { + throw mailerLiteServiceError( + 'date is required for schedule and smart_sending actions.' + ); + } + } + + if (ctx.input.action === 'schedule' || ctx.input.action === 'timezone_based') { + if (!ctx.input.hours || !ctx.input.minutes) { + throw mailerLiteServiceError( + 'hours and minutes are required for schedule and timezone_based actions.' + ); + } + } + + let deliveryByAction = { + send: 'instant', + schedule: 'scheduled', + timezone_based: 'timezone_based', + smart_sending: 'smart_sending' + } as const; + let result = await client.scheduleCampaign(ctx.input.campaignId, { - delivery: ctx.input.action === 'send' ? 'instant' : 'scheduled', + delivery: deliveryByAction[ctx.input.action], date: ctx.input.date, hours: ctx.input.hours, minutes: ctx.input.minutes, diff --git a/integrations/mailerlite/src/tools/update-campaign.ts b/integrations/mailerlite/src/tools/update-campaign.ts new file mode 100644 index 0000000000..7c96385b22 --- /dev/null +++ b/integrations/mailerlite/src/tools/update-campaign.ts @@ -0,0 +1,103 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { mailerLiteServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let updateCampaign = SlateTool.create(spec, { + name: 'Update Campaign', + key: 'update_campaign', + description: `Updates a draft MailerLite campaign. MailerLite only allows campaign updates while the campaign is in draft status. Use this to change the campaign name, sender details, content, language, or target groups/segments before scheduling or sending.`, + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + campaignId: z.string().describe('ID of the draft campaign to update'), + name: z.string().optional().describe('Updated campaign name'), + languageId: z + .number() + .optional() + .describe('Campaign language ID for unsubscribe templates'), + emails: z + .array( + z.object({ + subject: z.string().optional().describe('Email subject line'), + fromName: z.string().optional().describe('Sender name'), + from: z.string().optional().describe('Verified sender email address'), + replyTo: z.string().optional().describe('Verified reply-to email address'), + content: z.string().optional().describe('Email HTML content') + }) + ) + .optional() + .describe('Updated email configuration. Regular campaigns must contain one item.'), + groupIds: z.array(z.string()).optional().describe('Updated target group IDs'), + segmentIds: z + .array(z.string()) + .optional() + .describe( + 'Updated target segment IDs. If provided with groups, MailerLite uses segments.' + ), + ecommerceTracking: z + .boolean() + .optional() + .describe('Enable or disable e-commerce link tracking for campaign content') + }) + ) + .output( + z.object({ + campaignId: z.string().describe('ID of the updated campaign'), + name: z.string().describe('Campaign name'), + type: z.string().describe('Campaign type'), + status: z.string().describe('Campaign status'), + updatedAt: z.string().optional().describe('Update timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ token: ctx.auth.token }); + + if ( + !ctx.input.name && + ctx.input.languageId === undefined && + !ctx.input.emails?.length && + !ctx.input.groupIds?.length && + !ctx.input.segmentIds?.length && + ctx.input.ecommerceTracking === undefined + ) { + throw mailerLiteServiceError('Provide at least one campaign field to update.'); + } + + let result = await client.updateCampaign(ctx.input.campaignId, { + name: ctx.input.name, + language_id: ctx.input.languageId, + emails: ctx.input.emails?.map(email => ({ + subject: email.subject, + from_name: email.fromName, + from: email.from, + reply_to: email.replyTo, + content: email.content + })), + groups: ctx.input.groupIds, + segments: ctx.input.segmentIds, + settings: + ctx.input.ecommerceTracking === undefined + ? undefined + : { ecommerce_tracking: ctx.input.ecommerceTracking } + }); + + let campaign = result.data; + + return { + output: { + campaignId: campaign.id, + name: campaign.name, + type: campaign.type, + status: campaign.status, + updatedAt: campaign.updated_at + }, + message: `Campaign **${campaign.name}** updated with status **${campaign.status}**.` + }; + }) + .build(); diff --git a/integrations/mailerlite/vitest.config.ts b/integrations/mailerlite/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/mailerlite/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/mailgun/README.md b/integrations/mailgun/README.md index a3935cc9e3..51d56c726b 100644 --- a/integrations/mailgun/README.md +++ b/integrations/mailgun/README.md @@ -1,16 +1,28 @@ # Mailgun -Send, receive, and track transactional and marketing emails via API or SMTP. Manage sending domains, DNS verification, and DKIM keys. Route and parse inbound emails with custom rules. Validate email addresses in real-time or in bulk. Manage suppressions (bounces, complaints, unsubscribes), mailing lists, and reusable email templates. Query detailed email analytics, event logs, and deliverability metrics. Monitor IP blocklists, manage dedicated IPs and IP pools, and configure webhook notifications for email events like delivery, opens, clicks, bounces, and complaints. +Send, receive, and track transactional and marketing emails via Mailgun. Send test-mode or production messages with templates, tags, tracking options, and file attachments. Manage sending domains, tracking settings, inbound routes, suppressions, allowlists, mailing lists, reusable templates, current logs and metrics, legacy events and stats, stored messages, validation, and domain webhook notifications. ## Tools ### Get Events -Query the event log for a domain. Returns delivery, open, click, bounce, complaint, and other email events. Filter by event type, recipient, sender, subject, date range, and more. Useful for tracking email delivery status and debugging issues. +Query Mailgun's legacy Events API for a domain. Prefer Query Logs for current delivery/debug logging. ### Get Stats -Get email sending statistics for a domain. Returns aggregate counts for events like accepted, delivered, failed, opened, clicked, unsubscribed, and complained over a time range. Useful for monitoring email performance and deliverability. +Get email sending statistics from Mailgun's legacy Stats API for a domain. Prefer Query Metrics for current analytics. + +### Query Logs + +Query Mailgun's current Logs API for event logs with filters, pagination, and totals. + +### Query Metrics + +Query Mailgun's current Metrics API for analytics with dimensions, filters, rates, and aggregates. + +### Get Stored Message + +Retrieve a stored Mailgun message by storage key and return the stored payload as a Slate attachment. ### Get Domain Tracking @@ -20,29 +32,165 @@ Get the current tracking settings for a domain. Shows whether open tracking, cli List all sending domains in the Mailgun account. Returns domain names, states, and configuration. Use to discover available domains for sending or to check domain verification status. +### Get Domain + +Get detailed information and DNS records for a specific sending domain. + +### Create Domain + +Create a sending domain and return DNS records for setup. + +### Delete Domain + +Delete a sending domain. + +### Verify Domain + +Trigger DNS verification for a sending domain. + ### List Mailing Lists List all mailing lists in the account. Returns list addresses, names, member counts, and access levels. +### Get Mailing List + +Get a mailing list by address. + +### Create Mailing List + +Create a mailing list. + +### Update Mailing List + +Update mailing list properties. + +### Delete Mailing List + +Delete a mailing list. + +### List Mailing List Members + +List members in a mailing list. + +### Get Mailing List Member + +Get one mailing list member. + +### Add Mailing List Member + +Add or upsert a mailing list member. + +### Update Mailing List Member + +Update a mailing list member. + +### Remove Mailing List Member + +Remove a mailing list member. + ### List Routes List all inbound email routes. Routes define rules for handling incoming emails by matching recipient addresses or headers, then forwarding, storing, or stopping processing. +### Create Route + +Create an inbound route. + +### Update Route + +Update an inbound route. + +### Delete Route + +Delete an inbound route. + ### List Suppressions List suppressed email addresses for a domain. Retrieves bounces, complaints, or unsubscribes depending on the type selected. Suppressed addresses are blocked from receiving further emails to protect sending reputation. +### Add Suppression + +Add an address to a bounce, complaint, or unsubscribe suppression list. + +### Remove Suppression + +Remove an address from a suppression list. + +### List Allowlist + +List allowlisted addresses or domains. + +### Add Allowlist Entry + +Add an email address or domain to the Mailgun allowlist. + +### Remove Allowlist Entry + +Remove an allowlist entry. + ### List Templates List all email templates for a domain. Templates are reusable email content with variable substitution support. +### Get Template + +Get a template and its active version. + +### Create Template + +Create a domain-level template. + +### Update Template + +Update template metadata. + +### Delete Template + +Delete a template. + +### Create Template Version + +Create a template version. + +### List Template Versions + +List versions for a template. + +### Get Template Version + +Get a specific template version. + +### Update Template Version + +Update template version content, comment, or active status. + +### Delete Template Version + +Delete a specific template version. + ### List Webhooks List all configured webhooks for a domain. Shows which event types have webhook URLs configured. +### Get Webhook + +Get webhook URLs for one domain event type. + +### Create Webhook + +Create a domain webhook. + +### Update Webhook + +Replace the webhook URL list for an event type. + +### Delete Webhook + +Delete webhooks for an event type. + ### Send Email -Send an email through Mailgun. Supports plain text, HTML, and template-based emails with personalization. Can send to up to 1,000 recipients per call with individual personalization via recipient variables. Supports scheduling, tracking options, tags for analytics, custom headers, and reply-to addresses. +Send an email through Mailgun. Supports plain text, HTML, templates, attachments, inline attachments, personalization, scheduling, tracking options, tags, custom headers, and reply-to addresses. ### Validate Email diff --git a/integrations/mailgun/docs/SPEC.md b/integrations/mailgun/docs/SPEC.md index d7e3a72f25..ac0ba0565b 100644 --- a/integrations/mailgun/docs/SPEC.md +++ b/integrations/mailgun/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Mailgun is an email service provider (owned by Sinch) that offers APIs for sending, receiving, and tracking email. Mailgun allows the ability to send and receive email in both US and EU regions. It supports transactional and marketing email delivery, email validation, inbound email routing/parsing, and deliverability optimization tools. +Mailgun is an email service provider (owned by Sinch) that offers APIs for sending, receiving, and tracking email. Mailgun allows the ability to send and receive email in both US and EU regions. This integration focuses on practical Mailgun Send workflows: sending messages, managing domains, tracking, logs, metrics, suppressions, allowlists, mailing lists, templates, inbound routes, stored messages, validation, and domain webhooks. ## Authentication @@ -43,7 +43,7 @@ Mailgun uses the account's HTTP Webhook Signing Key to sign all HTTP payloads se ### Email Sending -Send transactional and marketing emails programmatically via REST API or SMTP relay. With a single API call, you can send up to 1,000 fully personalized emails. Mailgun will properly assemble the MIME message and send the email to each user individually. Supports attachments, inline images, custom headers, scheduled delivery, and tagging. +Send transactional and marketing emails programmatically via REST API. With a single API call, you can send up to 1,000 fully personalized emails. Mailgun will properly assemble the MIME message and send the email to each user individually. The integration supports attachments, inline attachments, custom headers, scheduled delivery, tracking options, test mode, templates, and tagging. ### Domain Management @@ -59,7 +59,7 @@ Define a list of routes to handle incoming emails. When a message matches a rout ### Suppressions Management -Mailgun automatically classifies and records bounce events (hard and soft), spam complaints, and unsubscribes into a Suppressions list. Once an address is added to your suppressions, Mailgun prevents further delivery attempts to protect your sending reputation. An allowlist API lets you prevent specific addresses from being added to the bounce list. +Mailgun automatically classifies and records bounce events (hard and soft), spam complaints, and unsubscribes into suppression lists. Once an address is added to your suppressions, Mailgun prevents further delivery attempts to protect your sending reputation. The allowlist API lets you prevent specific addresses or domains from being added to the bounce list. ### Mailing Lists @@ -71,31 +71,11 @@ Manage reusable email templates with variable substitution. Templates can be sto ### Analytics and Reporting -The Metrics API provides programmatic access to detailed analytics data about your email sending activity, allowing you to query, filter, and analyze email performance metrics. Mailgun keeps track of every inbound and outbound message event and stores this log data. Using the Logs API, this data can be queried and filtered. You can tag messages for aggregate reporting. +The current Metrics API provides programmatic access to detailed analytics data about email sending activity, allowing you to query, filter, and analyze email performance metrics. Mailgun keeps track of inbound and outbound message events and stores this log data. The current Logs API can query and filter those records. Legacy Events and Stats tools are retained for accounts or workflows that still rely on those endpoints. ### Email Validation -Mailgun provides a real-time Email Validation API to quickly remove invalid and high-risk addresses. Supports both single-address validation and bulk list validation. - -### Deliverability Optimization (Mailgun Optimize) - -- IP blocklist monitoring allows you to take immediate action if your IP is listed. -- Mailgun Optimize identifies and helps you avoid spam traps within your email lists. -- Integration with Google Postmaster Tools allows you to gain insights into how your emails are performing within the Gmail ecosystem. -- The Email Health Score API provides health scores for the overall account, as well as by domain, IP, and subaccount. -- Bounce classification identifies critical bounces and classifies them by sending domain and mailbox provider/spam filter. - -### Send Alerts - -Mailgun allows you to get instant notifications on the sending metrics that matter most, configured specifically for your unique business needs. Route these alerts to the channels your team relies on. - -### IP Management - -Manage dedicated IPs, IP pools, and dynamic IP pools. Supports automated IP address warmup for new dedicated IPs. - -### Subaccounts - -Mailgun allows you to set limits on your subaccounts to help you manage usage and costs. You can create, update, retrieve, and delete limits for various pre-send features such as email previews and email validations. +Mailgun provides a real-time Email Validation API to identify invalid and high-risk addresses. This integration exposes single-address validation. ## Events diff --git a/integrations/mailgun/package.json b/integrations/mailgun/package.json index ad13ede850..c34d89ea77 100644 --- a/integrations/mailgun/package.json +++ b/integrations/mailgun/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/mailgun/slate.json b/integrations/mailgun/slate.json index 4fdaf44838..e80102a634 100644 --- a/integrations/mailgun/slate.json +++ b/integrations/mailgun/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/mailgun", - "description": "Send, receive, and track transactional and marketing emails via API or SMTP. Manage sending domains, DNS verification, and DKIM keys. Route and parse inbound emails with custom rules. Validate email addresses in real-time or in bulk. Manage suppressions (bounces, complaints, unsubscribes), mailing lists, and reusable email templates. Query detailed email analytics, event logs, and deliverability metrics. Monitor IP blocklists, manage dedicated IPs and IP pools, and configure webhook notifications for email events like delivery, opens, clicks, bounces, and complaints.", + "description": "Send, receive, and track transactional and marketing emails via Mailgun. Manage sending domains, tracking settings, inbound routes, suppressions, allowlists, mailing lists, reusable templates, current logs and metrics, legacy events and stats, stored messages, validation, and domain webhook notifications.", "categories": ["apis-and-http-requests", "crm-and-sales-tools", "email-and-messaging"], "skills": [ "send transactional emails", @@ -12,7 +12,7 @@ "manage mailing lists", "manage email templates", "query email analytics", - "manage dedicated IPs" + "retrieve stored messages" ], "logoUrl": "https://provider-logos.metorial-cdn.com/mailgun.png" } diff --git a/integrations/mailgun/src/index.ts b/integrations/mailgun/src/index.ts index cc7361dec8..075327868f 100644 --- a/integrations/mailgun/src/index.ts +++ b/integrations/mailgun/src/index.ts @@ -1,6 +1,7 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + addAllowlistEntry, addMailingListMember, addSuppression, createDomain, @@ -13,25 +14,40 @@ import { deleteMailingList, deleteRoute, deleteTemplate, + deleteTemplateVersion, deleteWebhook, getDomain, getDomainTracking, getEvents, + getMailingList, + getMailingListMember, getStats, + getStoredMessage, getTemplate, + getTemplateVersion, + getWebhook, + listAllowlist, listDomains, listMailingListMembers, listMailingLists, listRoutes, listSuppressions, listTemplates, + listTemplateVersions, listWebhooks, + queryLogs, + queryMetrics, + removeAllowlistEntry, removeMailingListMember, removeSuppression, sendEmail, updateDomainTracking, updateMailingList, + updateMailingListMember, updateRoute, + updateTemplate, + updateTemplateVersion, + updateWebhook, validateEmail, verifyDomain } from './tools'; @@ -41,6 +57,7 @@ export let provider = Slate.create({ spec, tools: [ sendEmail, + getStoredMessage, listDomains, getDomain, createDomain, @@ -48,30 +65,45 @@ export let provider = Slate.create({ verifyDomain, getDomainTracking, updateDomainTracking, + queryLogs, + queryMetrics, getEvents, + getStats, listSuppressions, addSuppression, removeSuppression, + listAllowlist, + addAllowlistEntry, + removeAllowlistEntry, listMailingLists, + getMailingList, createMailingList, updateMailingList, deleteMailingList, listMailingListMembers, + getMailingListMember, addMailingListMember, + updateMailingListMember, removeMailingListMember, listTemplates, getTemplate, createTemplate, + updateTemplate, deleteTemplate, createTemplateVersion, + listTemplateVersions, + getTemplateVersion, + updateTemplateVersion, + deleteTemplateVersion, listRoutes, createRoute, updateRoute, deleteRoute, validateEmail, - getStats, listWebhooks, + getWebhook, createWebhook, + updateWebhook, deleteWebhook ], triggers: [emailEvents] diff --git a/integrations/mailgun/src/lib/client.ts b/integrations/mailgun/src/lib/client.ts index b2a643af6c..15800acf2b 100644 --- a/integrations/mailgun/src/lib/client.ts +++ b/integrations/mailgun/src/lib/client.ts @@ -1,10 +1,79 @@ import { createAxios } from 'slates'; +import { mailgunApiError } from './errors'; let BASE_URLS: Record = { us: 'https://api.mailgun.net', eu: 'https://api.eu.mailgun.net' }; +let buildFilter = (filters?: MailgunFilter[]) => { + if (!filters || filters.length === 0) return undefined; + + return { + AND: filters.map(filter => ({ + attribute: filter.attribute, + comparator: filter.comparator, + values: filter.values?.map(value => ({ + value: value.value, + label: value.label + })) + })) + }; +}; + +let sanitizeMultipartHeader = (value: string) => value.replace(/[\r\n"]/g, '_'); + +let appendMultipartField = ( + parts: Buffer[], + boundary: string, + name: string, + value: string | number | boolean +) => { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${sanitizeMultipartHeader(name)}"\r\n\r\n${String(value)}\r\n` + ) + ); +}; + +let appendMultipartFile = ( + parts: Buffer[], + boundary: string, + fieldName: string, + file: MailgunMessageFile +) => { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${sanitizeMultipartHeader(file.filename)}"\r\nContent-Type: ${file.contentType ?? 'application/octet-stream'}\r\n\r\n` + ) + ); + parts.push(Buffer.from(file.contentBase64, 'base64')); + parts.push(Buffer.from('\r\n')); +}; + +let buildMultipartBody = (params: { + fields: [string, string | number | boolean][]; + files?: Array<{ fieldName: string; file: MailgunMessageFile }>; +}) => { + let boundary = `----SlatesMailgunBoundary${Date.now()}${Math.random().toString(16).slice(2)}`; + let parts: Buffer[] = []; + + for (let [name, value] of params.fields) { + appendMultipartField(parts, boundary, name, value); + } + + for (let file of params.files ?? []) { + appendMultipartFile(parts, boundary, file.fieldName, file.file); + } + + parts.push(Buffer.from(`--${boundary}--\r\n`)); + + return { + body: Buffer.concat(parts), + contentType: `multipart/form-data; boundary=${boundary}` + }; +}; + export class MailgunClient { private axios; @@ -17,6 +86,10 @@ export class MailgunClient { password: config.token } }); + this.axios.interceptors?.response?.use( + (response: any) => response, + (error: unknown) => Promise.reject(mailgunApiError(error)) + ); } // ==================== Messages ==================== @@ -48,77 +121,92 @@ export class MailgunClient { replyTo?: string; sendingIp?: string; sendingIpPool?: string; + attachments?: MailgunMessageFile[]; + inlineAttachments?: MailgunMessageFile[]; } ) { - let formData = new URLSearchParams(); - formData.append('from', params.from); + let fields: [string, string | number | boolean][] = []; + fields.push(['from', params.from]); for (let recipient of params.to) { - formData.append('to', recipient); + fields.push(['to', recipient]); } if (params.cc) { for (let recipient of params.cc) { - formData.append('cc', recipient); + fields.push(['cc', recipient]); } } if (params.bcc) { for (let recipient of params.bcc) { - formData.append('bcc', recipient); + fields.push(['bcc', recipient]); } } - if (params.subject) formData.append('subject', params.subject); - if (params.text) formData.append('text', params.text); - if (params.html) formData.append('html', params.html); - if (params.template) formData.append('template', params.template); - if (params.templateVersion) formData.append('t:version', params.templateVersion); + if (params.subject) fields.push(['subject', params.subject]); + if (params.text) fields.push(['text', params.text]); + if (params.html) fields.push(['html', params.html]); + if (params.template) fields.push(['template', params.template]); + if (params.templateVersion) fields.push(['t:version', params.templateVersion]); if (params.templateVariables) - formData.append('t:variables', JSON.stringify(params.templateVariables)); - if (params.replyTo) formData.append('h:Reply-To', params.replyTo); + fields.push(['t:variables', JSON.stringify(params.templateVariables)]); + if (params.replyTo) fields.push(['h:Reply-To', params.replyTo]); if (params.tags) { for (let tag of params.tags) { - formData.append('o:tag', tag); + fields.push(['o:tag', tag]); } } - if (params.deliveryTime) formData.append('o:deliverytime', params.deliveryTime); - if (params.testMode) formData.append('o:testmode', 'yes'); + if (params.deliveryTime) fields.push(['o:deliverytime', params.deliveryTime]); + if (params.testMode) fields.push(['o:testmode', 'yes']); if (params.tracking !== undefined) - formData.append('o:tracking', params.tracking ? 'yes' : 'no'); - if (params.trackingClicks) formData.append('o:tracking-clicks', params.trackingClicks); + fields.push(['o:tracking', params.tracking ? 'yes' : 'no']); + if (params.trackingClicks) fields.push(['o:tracking-clicks', params.trackingClicks]); if (params.trackingOpens !== undefined) - formData.append('o:tracking-opens', params.trackingOpens ? 'yes' : 'no'); - if (params.requireTls) formData.append('o:require-tls', 'yes'); - if (params.skipVerification) formData.append('o:skip-verification', 'yes'); - if (params.sendingIp) formData.append('o:sending-ip', params.sendingIp); - if (params.sendingIpPool) formData.append('o:sending-ip-pool', params.sendingIpPool); + fields.push(['o:tracking-opens', params.trackingOpens ? 'yes' : 'no']); + if (params.requireTls) fields.push(['o:require-tls', 'yes']); + if (params.skipVerification) fields.push(['o:skip-verification', 'yes']); + if (params.sendingIp) fields.push(['o:sending-ip', params.sendingIp]); + if (params.sendingIpPool) fields.push(['o:sending-ip-pool', params.sendingIpPool]); if (params.customHeaders) { for (let [key, value] of Object.entries(params.customHeaders)) { - formData.append(`h:${key}`, value); + fields.push([`h:${key}`, value]); } } if (params.customVariables) { for (let [key, value] of Object.entries(params.customVariables)) { - formData.append(`v:${key}`, value); + fields.push([`v:${key}`, value]); } } if (params.recipientVariables) { - formData.append('recipient-variables', JSON.stringify(params.recipientVariables)); + fields.push(['recipient-variables', JSON.stringify(params.recipientVariables)]); } - let response = await this.axios.post(`/v3/${domain}/messages`, formData.toString(), { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + let files = [ + ...(params.attachments ?? []).map(file => ({ fieldName: 'attachment', file })), + ...(params.inlineAttachments ?? []).map(file => ({ fieldName: 'inline', file })) + ]; + let multipart = buildMultipartBody({ fields, files }); + + let response = await this.axios.post(`/v3/${domain}/messages`, multipart.body, { + headers: { 'Content-Type': multipart.contentType } }); return response.data as { id: string; message: string }; } + async getStoredMessage(domain: string, storageKey: string) { + let response = await this.axios.get( + `/v3/domains/${encodeURIComponent(domain)}/messages/${encodeURIComponent(storageKey)}` + ); + return response.data as StoredMessage; + } + // ==================== Domains ==================== async listDomains(params?: { limit?: number; skip?: number; state?: string }) { @@ -164,7 +252,7 @@ export class MailgunClient { } async deleteDomain(domainName: string) { - let response = await this.axios.delete(`/v4/domains/${domainName}`); + let response = await this.axios.delete(`/v3/domains/${encodeURIComponent(domainName)}`); return response.data; } @@ -338,6 +426,36 @@ export class MailgunClient { return response.data; } + // ==================== Allowlist ==================== + + async listAllowlist( + domain: string, + params?: { limit?: number; page?: string; address?: string; term?: string } + ) { + let response = await this.axios.get(`/v3/${domain}/whitelists`, { params }); + return response.data as { items: AllowlistItem[]; paging: PagingInfo }; + } + + async addAllowlistEntry( + domain: string, + params: { entryType: 'address' | 'domain'; value: string } + ) { + let formData = new URLSearchParams(); + formData.append(params.entryType, params.value); + + let response = await this.axios.post(`/v3/${domain}/whitelists`, formData.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + return response.data; + } + + async deleteAllowlistEntry(domain: string, value: string) { + let response = await this.axios.delete( + `/v3/${domain}/whitelists/${encodeURIComponent(value)}` + ); + return response.data; + } + // ==================== Mailing Lists ==================== async listMailingLists(params?: { limit?: number; skip?: number; address?: string }) { @@ -561,6 +679,22 @@ export class MailgunClient { return response.data; } + async listTemplateVersions( + domain: string, + templateName: string, + params?: { page?: string; limit?: number; pivot?: string } + ) { + let queryParams: Record = {}; + if (params?.page) queryParams.page = params.page; + if (params?.limit) queryParams.limit = params.limit; + if (params?.pivot) queryParams.p = params.pivot; + + let response = await this.axios.get(`/v3/${domain}/templates/${templateName}/versions`, { + params: queryParams + }); + return response.data as { template: { versions: TemplateVersionItem[] } }; + } + async getTemplateVersion(domain: string, templateName: string, tag: string) { let response = await this.axios.get( `/v3/${domain}/templates/${templateName}/versions/${tag}` @@ -568,6 +702,27 @@ export class MailgunClient { return response.data as { template: TemplateItem }; } + async updateTemplateVersion( + domain: string, + templateName: string, + tag: string, + params: { template?: string; comment?: string; active?: boolean } + ) { + let formData = new URLSearchParams(); + if (params.template) formData.append('template', params.template); + if (params.comment) formData.append('comment', params.comment); + if (params.active !== undefined) formData.append('active', params.active ? 'yes' : 'no'); + + let response = await this.axios.put( + `/v3/${domain}/templates/${templateName}/versions/${tag}`, + formData.toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + } + ); + return response.data; + } + async deleteTemplateVersion(domain: string, templateName: string, tag: string) { let response = await this.axios.delete( `/v3/${domain}/templates/${templateName}/versions/${tag}` @@ -690,6 +845,40 @@ export class MailgunClient { return response.data; } + // ==================== Logs and Metrics ==================== + + async queryMetrics(params: MetricsQuery) { + let response = await this.axios.post('/v1/analytics/metrics', { + start: params.start, + end: params.end, + resolution: params.resolution, + duration: params.duration, + dimensions: params.dimensions, + metrics: params.metrics, + filter: buildFilter(params.filters), + include_subaccounts: params.includeSubaccounts, + include_aggregates: params.includeAggregates + }); + + return response.data as MetricsResponse; + } + + async queryLogs(params: LogsQuery) { + let response = await this.axios.post('/v1/analytics/logs', { + start: params.start, + end: params.end, + duration: params.duration, + events: params.events, + metric_events: params.metricEvents, + filter: buildFilter(params.filters), + include_subaccounts: params.includeSubaccounts, + include_totals: params.includeTotals, + pagination: params.pagination + }); + + return response.data as LogsResponse; + } + // ==================== Stats ==================== async getStats( @@ -776,6 +965,14 @@ export type EventItem = { [key: string]: unknown; }; +export type StoredMessage = Record; + +export type MailgunMessageFile = { + filename: string; + contentBase64: string; + contentType?: string; +}; + export type BounceItem = { address: string; code: string; @@ -794,6 +991,13 @@ export type UnsubscribeItem = { created_at: string; }; +export type AllowlistItem = { + type?: string; + value?: string; + reason?: string; + createdAt?: string; +}; + export type MailingListItem = { address: string; name: string; @@ -827,6 +1031,15 @@ export type TemplateItem = { }; }; +export type TemplateVersionItem = { + tag: string; + template?: string; + engine?: string; + active?: boolean; + comment?: string; + createdAt?: string; +}; + export type RouteItem = { id: string; priority: number; @@ -852,6 +1065,69 @@ export type PagingInfo = { previous: string; }; +export type MailgunFilter = { + attribute: string; + comparator: string; + values?: Array<{ + value: string; + label?: string; + }>; +}; + +export type MetricsQuery = { + start?: string; + end?: string; + resolution?: string; + duration?: string; + dimensions?: string[]; + metrics?: string[]; + filters?: MailgunFilter[]; + includeSubaccounts?: boolean; + includeAggregates?: boolean; +}; + +export type MetricsResponse = { + start?: string; + end?: string; + resolution?: string; + duration?: string; + dimensions?: string[]; + items: Array<{ + dimensions?: Array<{ + dimension?: string; + value?: string; + display_value?: string; + }>; + metrics?: Record; + }>; + aggregates?: Record; + pagination?: Record; +}; + +export type LogsQuery = { + start?: string; + end?: string; + duration: string; + events?: string[]; + metricEvents?: string[]; + filters?: MailgunFilter[]; + includeSubaccounts?: boolean; + includeTotals?: boolean; + pagination?: { + sort?: string; + token?: string; + limit?: number; + }; +}; + +export type LogsResponse = { + start: string; + end: string; + items: Record[]; + pagination: Record; + aggregates?: Record; +}; + export type StatsItem = { time: string; accepted?: { incoming: number; outgoing: number; total: number }; diff --git a/integrations/mailgun/src/lib/errors.ts b/integrations/mailgun/src/lib/errors.ts new file mode 100644 index 0000000000..ac4c36bda3 --- /dev/null +++ b/integrations/mailgun/src/lib/errors.ts @@ -0,0 +1,78 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractMailgunMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + for (let key of ['message', 'error', 'detail', 'reason']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getMailgunErrorStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + return response?.status ?? error.status; +}; + +export let mailgunServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let mailgunApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getMailgunErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = mailgunServiceError( + `Mailgun API ${operation} failed: ${statusLabel}${extractMailgunMessage(error)}` + ); + serviceError.data.reason = 'mailgun_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/mailgun/src/tools.schema.test.ts b/integrations/mailgun/src/tools.schema.test.ts new file mode 100644 index 0000000000..9d090229e6 --- /dev/null +++ b/integrations/mailgun/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Mailgun tool input schemas', provider.actions); diff --git a/integrations/mailgun/src/tools/get-events.ts b/integrations/mailgun/src/tools/get-events.ts index 774a7c0df5..fa82bec48c 100644 --- a/integrations/mailgun/src/tools/get-events.ts +++ b/integrations/mailgun/src/tools/get-events.ts @@ -27,8 +27,8 @@ let eventOutputSchema = z.object({ export let getEvents = SlateTool.create(spec, { name: 'Get Events', key: 'get_events', - description: `Query the event log for a domain. Returns delivery, open, click, bounce, complaint, and other email events. -Filter by event type, recipient, sender, subject, date range, and more. Useful for tracking email delivery status and debugging issues.`, + description: `Query Mailgun's legacy Events API for a domain. Returns delivery, open, click, bounce, complaint, and other email events. +Filter by event type, recipient, sender, subject, date range, and more. Prefer the Query Logs tool for current delivery/debug logging unless you specifically need the legacy Events API.`, instructions: [ 'Dates should be in RFC 2822 format (e.g. "Thu, 13 Oct 2011 18:02:00 GMT").', 'Use eventType to filter by specific events like "delivered", "failed", "opened", "clicked".', diff --git a/integrations/mailgun/src/tools/get-stats.ts b/integrations/mailgun/src/tools/get-stats.ts index 77b8175e03..cdd5f7ea55 100644 --- a/integrations/mailgun/src/tools/get-stats.ts +++ b/integrations/mailgun/src/tools/get-stats.ts @@ -6,8 +6,8 @@ import { spec } from '../spec'; export let getStats = SlateTool.create(spec, { name: 'Get Stats', key: 'get_stats', - description: `Get email sending statistics for a domain. Returns aggregate counts for events like accepted, delivered, failed, opened, clicked, unsubscribed, and complained over a time range. -Useful for monitoring email performance and deliverability.`, + description: `Get email sending statistics from Mailgun's legacy Stats API for a domain. Returns aggregate counts for events like accepted, delivered, failed, opened, clicked, unsubscribed, and complained over a time range. +Prefer the Query Metrics tool for current analytics unless you specifically need the legacy Stats API.`, instructions: [ 'At least one event type must be specified.', 'Use duration for relative time ranges (e.g. "7d" for 7 days, "1m" for 1 month).', diff --git a/integrations/mailgun/src/tools/index.ts b/integrations/mailgun/src/tools/index.ts index 05a16dcf65..c61b9f685f 100644 --- a/integrations/mailgun/src/tools/index.ts +++ b/integrations/mailgun/src/tools/index.ts @@ -7,5 +7,6 @@ export * from './manage-routes'; export * from './manage-suppressions'; export * from './manage-templates'; export * from './manage-webhooks'; +export * from './query-reporting'; export * from './send-email'; export * from './validate-email'; diff --git a/integrations/mailgun/src/tools/manage-domain-tracking.ts b/integrations/mailgun/src/tools/manage-domain-tracking.ts index 0b0bc97041..e484651c62 100644 --- a/integrations/mailgun/src/tools/manage-domain-tracking.ts +++ b/integrations/mailgun/src/tools/manage-domain-tracking.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MailgunClient } from '../lib/client'; +import { mailgunServiceError } from '../lib/errors'; import { spec } from '../spec'; export let getDomainTracking = SlateTool.create(spec, { @@ -76,6 +77,15 @@ export let updateDomainTracking = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if ( + ctx.input.trackingType !== 'unsubscribe' && + (ctx.input.htmlFooter !== undefined || ctx.input.textFooter !== undefined) + ) { + throw mailgunServiceError( + 'htmlFooter and textFooter only apply to unsubscribe tracking.' + ); + } + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); await client.updateDomainTracking(ctx.input.domainName, ctx.input.trackingType, { active: ctx.input.active, diff --git a/integrations/mailgun/src/tools/manage-mailing-lists.ts b/integrations/mailgun/src/tools/manage-mailing-lists.ts index 8bde3a3e8a..0f9c3aa4be 100644 --- a/integrations/mailgun/src/tools/manage-mailing-lists.ts +++ b/integrations/mailgun/src/tools/manage-mailing-lists.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MailgunClient } from '../lib/client'; +import { mailgunServiceError } from '../lib/errors'; import { spec } from '../spec'; let mailingListSchema = z.object({ @@ -63,6 +64,45 @@ export let listMailingLists = SlateTool.create(spec, { }) .build(); +// ==================== Get Mailing List ==================== + +export let getMailingList = SlateTool.create(spec, { + name: 'Get Mailing List', + key: 'get_mailing_list', + description: `Get one Mailgun mailing list by address, including access level, reply preference, and member count.`, + tags: { readOnly: true } +}) + .input( + z.object({ + listAddress: z.string().describe('Email address of the mailing list') + }) + ) + .output( + z.object({ + mailingList: mailingListSchema + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + let result = await client.getMailingList(ctx.input.listAddress); + + return { + output: { + mailingList: { + address: result.list.address, + name: result.list.name, + description: result.list.description, + accessLevel: result.list.access_level, + replyPreference: result.list.reply_preference, + membersCount: result.list.members_count, + createdAt: result.list.created_at + } + }, + message: `Retrieved mailing list **${result.list.address}**.` + }; + }) + .build(); + // ==================== Create Mailing List ==================== export let createMailingList = SlateTool.create(spec, { @@ -243,6 +283,46 @@ export let listMailingListMembers = SlateTool.create(spec, { }) .build(); +// ==================== Get Member ==================== + +export let getMailingListMember = SlateTool.create(spec, { + name: 'Get Mailing List Member', + key: 'get_mailing_list_member', + description: `Get a specific member from a Mailgun mailing list, including subscription status and custom variables.`, + tags: { readOnly: true } +}) + .input( + z.object({ + listAddress: z.string().describe('Email address of the mailing list'), + memberAddress: z.string().describe('Email address of the member to retrieve') + }) + ) + .output( + z.object({ + member: memberSchema + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + let result = await client.getMailingListMember( + ctx.input.listAddress, + ctx.input.memberAddress + ); + + return { + output: { + member: { + address: result.member.address, + name: result.member.name, + subscribed: result.member.subscribed, + vars: result.member.vars + } + }, + message: `Retrieved member **${result.member.address}** from **${ctx.input.listAddress}**.` + }; + }) + .build(); + // ==================== Add/Update Member ==================== export let addMailingListMember = SlateTool.create(spec, { @@ -299,6 +379,65 @@ export let addMailingListMember = SlateTool.create(spec, { }) .build(); +// ==================== Update Member ==================== + +export let updateMailingListMember = SlateTool.create(spec, { + name: 'Update Mailing List Member', + key: 'update_mailing_list_member', + description: `Update a Mailgun mailing list member's display name, custom variables, or subscription status.`, + tags: { destructive: false } +}) + .input( + z.object({ + listAddress: z.string().describe('Email address of the mailing list'), + memberAddress: z.string().describe('Email address of the member to update'), + name: z.string().optional().describe('Updated display name'), + vars: z + .record(z.string(), z.unknown()) + .optional() + .describe('Updated custom member variables'), + subscribed: z.boolean().optional().describe('Updated subscription status') + }) + ) + .output( + z.object({ + member: memberSchema + }) + ) + .handleInvocation(async ctx => { + if ( + ctx.input.name === undefined && + ctx.input.vars === undefined && + ctx.input.subscribed === undefined + ) { + throw mailgunServiceError('Provide at least one member field to update.'); + } + + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + let result = await client.updateMailingListMember( + ctx.input.listAddress, + ctx.input.memberAddress, + { + name: ctx.input.name, + vars: ctx.input.vars, + subscribed: ctx.input.subscribed + } + ); + + return { + output: { + member: { + address: result.member.address, + name: result.member.name, + subscribed: result.member.subscribed, + vars: result.member.vars + } + }, + message: `Updated member **${result.member.address}** in **${ctx.input.listAddress}**.` + }; + }) + .build(); + // ==================== Remove Member ==================== export let removeMailingListMember = SlateTool.create(spec, { diff --git a/integrations/mailgun/src/tools/manage-suppressions.ts b/integrations/mailgun/src/tools/manage-suppressions.ts index 4657081d00..790c0d059d 100644 --- a/integrations/mailgun/src/tools/manage-suppressions.ts +++ b/integrations/mailgun/src/tools/manage-suppressions.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MailgunClient } from '../lib/client'; +import { mailgunServiceError } from '../lib/errors'; import { spec } from '../spec'; // ==================== List Suppressions ==================== @@ -112,10 +113,21 @@ export let addSuppression = SlateTool.create(spec, { let { domain, type, address, code, error, tag } = ctx.input; if (type === 'bounce') { + if (tag) { + throw mailgunServiceError('tag only applies to unsubscribe suppressions.'); + } await client.addBounce(domain, { address, code, error }); } else if (type === 'complaint') { + if (code !== undefined || error || tag) { + throw mailgunServiceError( + 'code, error, and tag do not apply to complaint suppressions.' + ); + } await client.addComplaint(domain, { address }); } else { + if (code !== undefined || error) { + throw mailgunServiceError('code and error only apply to bounce suppressions.'); + } await client.addUnsubscribe(domain, { address, tag }); } @@ -126,6 +138,123 @@ export let addSuppression = SlateTool.create(spec, { }) .build(); +// ==================== Allowlist ==================== + +let allowlistEntrySchema = z.object({ + value: z.string().describe('Allowlisted email address or domain'), + entryType: z.string().optional().describe('Whether the entry is an address or domain'), + reason: z.string().optional().describe('Reason or note if returned by Mailgun'), + createdAt: z.string().optional().describe('When the entry was created') +}); + +export let listAllowlist = SlateTool.create(spec, { + name: 'List Allowlist', + key: 'list_allowlist', + description: `List Mailgun allowlist entries for a domain. Allowlisted addresses or domains are protected from being added to bounce suppressions, which is useful for test and controlled-recipient workflows.`, + tags: { readOnly: true } +}) + .input( + z.object({ + domain: z.string().describe('Domain name to query allowlist entries for'), + limit: z.number().optional().describe('Maximum number of records to return'), + page: z + .enum(['next', 'previous', 'last']) + .optional() + .describe('Page direction relative to address'), + address: z.string().optional().describe('Pagination divider address'), + term: z.string().optional().describe('Filter entries that start with this substring') + }) + ) + .output( + z.object({ + entries: z.array(allowlistEntrySchema) + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + let result = await client.listAllowlist(ctx.input.domain, { + limit: ctx.input.limit, + page: ctx.input.page, + address: ctx.input.address, + term: ctx.input.term + }); + + let entries = (result.items || []).map(entry => { + return { + value: entry.value ?? '', + entryType: entry.type, + reason: entry.reason, + createdAt: entry.createdAt + }; + }); + + return { + output: { entries }, + message: `Retrieved **${entries.length}** allowlist entr${entries.length === 1 ? 'y' : 'ies'} for **${ctx.input.domain}**.` + }; + }) + .build(); + +export let addAllowlistEntry = SlateTool.create(spec, { + name: 'Add Allowlist Entry', + key: 'add_allowlist_entry', + description: `Add an email address or domain to Mailgun's allowlist for a domain. Allowlisted values are prevented from being added to the bounce suppression list.`, + tags: { destructive: false } +}) + .input( + z.object({ + domain: z.string().describe('Domain name'), + entryType: z.enum(['address', 'domain']).describe('Whether value is an email or domain'), + value: z.string().describe('Email address or domain to allowlist') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the allowlist entry was added') + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + await client.addAllowlistEntry(ctx.input.domain, { + entryType: ctx.input.entryType, + value: ctx.input.value + }); + + return { + output: { success: true }, + message: `Added **${ctx.input.value}** to the allowlist for **${ctx.input.domain}**.` + }; + }) + .build(); + +export let removeAllowlistEntry = SlateTool.create(spec, { + name: 'Remove Allowlist Entry', + key: 'remove_allowlist_entry', + description: `Remove an email address or domain from Mailgun's allowlist for a domain.`, + tags: { destructive: true } +}) + .input( + z.object({ + domain: z.string().describe('Domain name'), + value: z.string().describe('Email address or domain to remove from the allowlist') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the allowlist entry was removed') + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + await client.deleteAllowlistEntry(ctx.input.domain, ctx.input.value); + + return { + output: { success: true }, + message: `Removed **${ctx.input.value}** from the allowlist for **${ctx.input.domain}**.` + }; + }) + .build(); + // ==================== Remove Suppression ==================== export let removeSuppression = SlateTool.create(spec, { diff --git a/integrations/mailgun/src/tools/manage-templates.ts b/integrations/mailgun/src/tools/manage-templates.ts index 7466e02deb..dd6c62a63b 100644 --- a/integrations/mailgun/src/tools/manage-templates.ts +++ b/integrations/mailgun/src/tools/manage-templates.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MailgunClient } from '../lib/client'; +import { mailgunServiceError } from '../lib/errors'; import { spec } from '../spec'; let templateSchema = z.object({ @@ -21,6 +22,15 @@ let templateSchema = z.object({ .describe('Active template version') }); +let templateVersionSchema = z.object({ + tag: z.string().describe('Version tag'), + content: z.string().optional().describe('Template content'), + engine: z.string().optional().describe('Template engine'), + active: z.boolean().optional().describe('Whether this version is active'), + comment: z.string().optional().describe('Version comment'), + createdAt: z.string().optional().describe('Version creation timestamp') +}); + // ==================== List Templates ==================== export let listTemplates = SlateTool.create(spec, { @@ -192,6 +202,43 @@ Templates support Handlebars variable substitution with helpers like \`if\`, \`u }) .build(); +// ==================== Update Template ==================== + +export let updateTemplate = SlateTool.create(spec, { + name: 'Update Template', + key: 'update_template', + description: `Update a domain-level Mailgun template's metadata. Use template version tools to update content or active version.`, + tags: { destructive: false } +}) + .input( + z.object({ + domain: z.string().describe('Domain name'), + templateName: z.string().describe('Name of the template to update'), + description: z.string().optional().describe('Updated template description') + }) + ) + .output( + z.object({ + success: z.boolean() + }) + ) + .handleInvocation(async ctx => { + if (ctx.input.description === undefined) { + throw mailgunServiceError('Provide a description to update the template metadata.'); + } + + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + await client.updateTemplate(ctx.input.domain, ctx.input.templateName, { + description: ctx.input.description + }); + + return { + output: { success: true }, + message: `Updated template **${ctx.input.templateName}**.` + }; + }) + .build(); + // ==================== Delete Template ==================== export let deleteTemplate = SlateTool.create(spec, { @@ -260,3 +307,189 @@ export let createTemplateVersion = SlateTool.create(spec, { }; }) .build(); + +// ==================== List Template Versions ==================== + +export let listTemplateVersions = SlateTool.create(spec, { + name: 'List Template Versions', + key: 'list_template_versions', + description: `List versions for a domain-level Mailgun template. Use before selecting, updating, activating, or deleting a specific version.`, + tags: { readOnly: true } +}) + .input( + z.object({ + domain: z.string().describe('Domain name'), + templateName: z.string().describe('Name of the template'), + page: z + .enum(['first', 'last', 'next', 'previous']) + .optional() + .describe('Page to retrieve'), + limit: z.number().optional().describe('Number of versions to retrieve (max 100)'), + pivot: z.string().optional().describe('Pagination pivot token') + }) + ) + .output( + z.object({ + versions: z.array(templateVersionSchema) + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + let result = await client.listTemplateVersions(ctx.input.domain, ctx.input.templateName, { + page: ctx.input.page, + limit: ctx.input.limit, + pivot: ctx.input.pivot + }); + + let versions = (result.template.versions || []).map(version => ({ + tag: version.tag, + content: version.template, + engine: version.engine, + active: version.active, + comment: version.comment, + createdAt: version.createdAt + })); + + return { + output: { versions }, + message: `Found **${versions.length}** version(s) for template **${ctx.input.templateName}**.` + }; + }) + .build(); + +// ==================== Get Template Version ==================== + +export let getTemplateVersion = SlateTool.create(spec, { + name: 'Get Template Version', + key: 'get_template_version', + description: `Get a specific version of a domain-level Mailgun template, including its content and active status.`, + tags: { readOnly: true } +}) + .input( + z.object({ + domain: z.string().describe('Domain name'), + templateName: z.string().describe('Name of the template'), + tag: z.string().describe('Version tag') + }) + ) + .output( + z.object({ + version: templateVersionSchema + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + let result = await client.getTemplateVersion( + ctx.input.domain, + ctx.input.templateName, + ctx.input.tag + ); + let version = result.template.version; + + if (!version) { + throw mailgunServiceError( + `Mailgun did not return version ${ctx.input.tag} for template ${ctx.input.templateName}.` + ); + } + + return { + output: { + version: { + tag: version.tag, + content: version.template, + engine: version.engine, + active: version.active, + comment: version.comment, + createdAt: version.createdAt + } + }, + message: `Retrieved version **${ctx.input.tag}** for template **${ctx.input.templateName}**.` + }; + }) + .build(); + +// ==================== Update Template Version ==================== + +export let updateTemplateVersion = SlateTool.create(spec, { + name: 'Update Template Version', + key: 'update_template_version', + description: `Update a domain-level Mailgun template version's content, comment, or active status.`, + tags: { destructive: false } +}) + .input( + z.object({ + domain: z.string().describe('Domain name'), + templateName: z.string().describe('Name of the template'), + tag: z.string().describe('Version tag to update'), + content: z.string().optional().describe('Updated template content'), + comment: z.string().optional().describe('Updated version comment'), + active: z.boolean().optional().describe('Whether this version should be active') + }) + ) + .output( + z.object({ + success: z.boolean() + }) + ) + .handleInvocation(async ctx => { + if ( + ctx.input.content === undefined && + ctx.input.comment === undefined && + ctx.input.active === undefined + ) { + throw mailgunServiceError('Provide at least one template version field to update.'); + } + + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + await client.updateTemplateVersion( + ctx.input.domain, + ctx.input.templateName, + ctx.input.tag, + { + template: ctx.input.content, + comment: ctx.input.comment, + active: ctx.input.active + } + ); + + return { + output: { success: true }, + message: `Updated version **${ctx.input.tag}** for template **${ctx.input.templateName}**.` + }; + }) + .build(); + +// ==================== Delete Template Version ==================== + +export let deleteTemplateVersion = SlateTool.create(spec, { + name: 'Delete Template Version', + key: 'delete_template_version', + description: `Delete a specific version from a Mailgun domain-level template.`, + tags: { destructive: true } +}) + .input( + z.object({ + domain: z.string().describe('Domain name'), + templateName: z.string().describe('Name of the template'), + tag: z.string().describe('Version tag to delete') + }) + ) + .output( + z.object({ + success: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + await client.deleteTemplateVersion( + ctx.input.domain, + ctx.input.templateName, + ctx.input.tag + ); + + return { + output: { success: true }, + message: `Deleted version **${ctx.input.tag}** from template **${ctx.input.templateName}**.` + }; + }) + .build(); diff --git a/integrations/mailgun/src/tools/manage-webhooks.ts b/integrations/mailgun/src/tools/manage-webhooks.ts index facf6e9fae..0cf2974379 100644 --- a/integrations/mailgun/src/tools/manage-webhooks.ts +++ b/integrations/mailgun/src/tools/manage-webhooks.ts @@ -41,6 +41,47 @@ export let listWebhooks = SlateTool.create(spec, { }) .build(); +// ==================== Get Webhook ==================== + +export let getWebhook = SlateTool.create(spec, { + name: 'Get Webhook', + key: 'get_webhook', + description: `Get configured webhook URLs for one Mailgun domain event type.`, + tags: { readOnly: true } +}) + .input( + z.object({ + domain: z.string().describe('Domain name'), + eventType: z + .enum([ + 'accepted', + 'delivered', + 'opened', + 'clicked', + 'unsubscribed', + 'complained', + 'permanent_fail', + 'temporary_fail' + ]) + .describe('Event type webhook to retrieve') + }) + ) + .output( + z.object({ + webhook: z.unknown().describe('Webhook details returned by Mailgun') + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + let result = await client.getWebhook(ctx.input.domain, ctx.input.eventType); + + return { + output: { webhook: result }, + message: `Retrieved webhook for **${ctx.input.eventType}** events on **${ctx.input.domain}**.` + }; + }) + .build(); + // ==================== Create Webhook ==================== export let createWebhook = SlateTool.create(spec, { @@ -87,6 +128,49 @@ export let createWebhook = SlateTool.create(spec, { }) .build(); +// ==================== Update Webhook ==================== + +export let updateWebhook = SlateTool.create(spec, { + name: 'Update Webhook', + key: 'update_webhook', + description: `Replace the webhook URL list for a Mailgun domain event type with a new URL.`, + constraints: ['Mailgun replaces the URL list for the event type with the provided URL.'], + tags: { destructive: false } +}) + .input( + z.object({ + domain: z.string().describe('Domain name'), + eventType: z + .enum([ + 'accepted', + 'delivered', + 'opened', + 'clicked', + 'unsubscribed', + 'complained', + 'permanent_fail', + 'temporary_fail' + ]) + .describe('Event type to update'), + url: z.string().describe('Webhook URL to set') + }) + ) + .output( + z.object({ + success: z.boolean() + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + await client.updateWebhook(ctx.input.domain, ctx.input.eventType, ctx.input.url); + + return { + output: { success: true }, + message: `Webhook for **${ctx.input.eventType}** events updated on **${ctx.input.domain}**.` + }; + }) + .build(); + // ==================== Delete Webhook ==================== export let deleteWebhook = SlateTool.create(spec, { diff --git a/integrations/mailgun/src/tools/query-reporting.ts b/integrations/mailgun/src/tools/query-reporting.ts new file mode 100644 index 0000000000..f2d492825b --- /dev/null +++ b/integrations/mailgun/src/tools/query-reporting.ts @@ -0,0 +1,223 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MailgunClient } from '../lib/client'; +import { spec } from '../spec'; + +let filterSchema = z.object({ + attribute: z + .string() + .describe('Mailgun filter attribute, such as domain, tag, or recipient'), + comparator: z + .string() + .describe('Filter comparator supported by Mailgun, such as =, !=, contains'), + values: z + .array( + z.object({ + value: z.string().describe('Filter value'), + label: z.string().optional().describe('Optional display label') + }) + ) + .optional() + .describe('Values to compare against') +}); + +let metricDimensionSchema = z.object({ + dimension: z.string().optional().describe('Dimension name'), + value: z.string().optional().describe('Dimension value'), + displayValue: z.string().optional().describe('Display value returned by Mailgun') +}); + +let logsPaginationSchema = z.object({ + sort: z + .string() + .optional() + .describe('Sort order, such as "timestamp:desc" or "timestamp:asc"'), + token: z.string().optional().describe('Page token from a previous logs response'), + limit: z.number().optional().describe('Maximum logs to return, up to 100') +}); + +export let queryMetrics = SlateTool.create(spec, { + name: 'Query Metrics', + key: 'query_metrics', + description: `Query Mailgun's current Metrics API for account or domain analytics. Use this for current reporting instead of the legacy Stats API when you need flexible dimensions, filters, rates, and aggregate metrics.`, + instructions: [ + 'Use filters with attribute "domain" to scope metrics to a single sending domain.', + 'Use dimensions like "time", "domain", "tag", "recipient_provider", or "recipient_domain" to group results.', + 'Use duration for relative windows such as "7d"; if duration is provided, Mailgun calculates start from end.' + ], + tags: { readOnly: true } +}) + .input( + z.object({ + start: z.string().optional().describe('Start date in RFC 2822 format'), + end: z.string().optional().describe('End date in RFC 2822 format'), + resolution: z + .enum(['hour', 'day', 'month']) + .optional() + .describe('Time resolution for time dimensions'), + duration: z.string().optional().describe('Relative duration, such as "1d" or "7d"'), + dimensions: z + .array( + z.enum([ + 'bot', + 'country', + 'ip_pool', + 'recipient_domain', + 'recipient_provider', + 'ip', + 'domain', + 'tag', + 'device', + 'subaccount', + 'time' + ]) + ) + .optional() + .describe('Dimensions to group metrics by'), + metrics: z + .array(z.string()) + .optional() + .describe('Metric names to return, such as delivered_count or clicked_rate'), + filters: z.array(filterSchema).optional().describe('AND filters to apply'), + includeSubaccounts: z.boolean().optional().describe('Include subaccount metrics'), + includeAggregates: z.boolean().optional().describe('Include top-level aggregates') + }) + ) + .output( + z.object({ + start: z.string().optional().describe('Start of the metrics window'), + end: z.string().optional().describe('End of the metrics window'), + resolution: z.string().optional().describe('Resolution used by Mailgun'), + duration: z.string().optional().describe('Duration used by Mailgun'), + items: z.array( + z.object({ + dimensions: z.array(metricDimensionSchema).optional(), + metrics: z.record(z.string(), z.unknown()).optional() + }) + ), + aggregates: z.record(z.string(), z.unknown()).optional(), + pagination: z.record(z.string(), z.unknown()).optional() + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + let result = await client.queryMetrics({ + start: ctx.input.start, + end: ctx.input.end, + resolution: ctx.input.resolution, + duration: ctx.input.duration, + dimensions: ctx.input.dimensions, + metrics: ctx.input.metrics, + filters: ctx.input.filters, + includeSubaccounts: ctx.input.includeSubaccounts, + includeAggregates: ctx.input.includeAggregates + }); + + let items = (result.items || []).map(item => ({ + dimensions: item.dimensions?.map(dimension => ({ + dimension: dimension.dimension, + value: dimension.value, + displayValue: dimension.display_value + })), + metrics: item.metrics + })); + + return { + output: { + start: result.start, + end: result.end, + resolution: result.resolution, + duration: result.duration, + items, + aggregates: result.aggregates, + pagination: result.pagination + }, + message: `Retrieved **${items.length}** Mailgun metric item(s).` + }; + }) + .build(); + +export let queryLogs = SlateTool.create(spec, { + name: 'Query Logs', + key: 'query_logs', + description: `Query Mailgun's current Logs API for account event logs. Use this for current delivery/debug logging instead of the legacy Events API when you need pagination, totals, or richer log records.`, + instructions: [ + 'duration is required by Mailgun and defaults to "1d".', + 'Use filters with attributes like domain, recipient, or tag to narrow results.', + 'Use pagination.token from a previous response to fetch another page.' + ], + tags: { readOnly: true } +}) + .input( + z.object({ + start: z.string().optional().describe('Start date in RFC 2822 format'), + end: z.string().optional().describe('End date in RFC 2822 format'), + duration: z.string().default('1d').describe('Relative duration, such as "1d" or "2h"'), + events: z + .array( + z.enum([ + 'accepted', + 'delivered', + 'failed', + 'opened', + 'unique_opened', + 'clicked', + 'unique_clicked', + 'unsubscribed', + 'complained', + 'rejected', + 'stored', + 'email_validation', + 'list_uploaded', + 'list_member_uploaded', + 'list_member_upload_error', + 'trapped' + ]) + ) + .optional() + .describe('Event types to include'), + metricEvents: z + .array(z.string()) + .optional() + .describe('Analytics metric events to convert into log event filters'), + filters: z.array(filterSchema).optional().describe('AND filters to apply'), + includeSubaccounts: z.boolean().optional().describe('Include subaccount logs'), + includeTotals: z.boolean().optional().describe('Include total log count'), + pagination: logsPaginationSchema.optional().describe('Logs pagination options') + }) + ) + .output( + z.object({ + start: z.string().describe('Start of the logs window'), + end: z.string().describe('End of the logs window'), + logs: z.array(z.record(z.string(), z.unknown())).describe('Log records from Mailgun'), + pagination: z.record(z.string(), z.unknown()).describe('Mailgun pagination metadata'), + aggregates: z.record(z.string(), z.unknown()).optional() + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region }); + let result = await client.queryLogs({ + start: ctx.input.start, + end: ctx.input.end, + duration: ctx.input.duration, + events: ctx.input.events, + metricEvents: ctx.input.metricEvents, + filters: ctx.input.filters, + includeSubaccounts: ctx.input.includeSubaccounts, + includeTotals: ctx.input.includeTotals, + pagination: ctx.input.pagination + }); + + return { + output: { + start: result.start, + end: result.end, + logs: result.items || [], + pagination: result.pagination, + aggregates: result.aggregates + }, + message: `Retrieved **${(result.items || []).length}** Mailgun log record(s).` + }; + }) + .build(); diff --git a/integrations/mailgun/src/tools/send-email.ts b/integrations/mailgun/src/tools/send-email.ts index 5e28c64b01..7ca70f9693 100644 --- a/integrations/mailgun/src/tools/send-email.ts +++ b/integrations/mailgun/src/tools/send-email.ts @@ -1,8 +1,35 @@ -import { SlateTool } from 'slates'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; import { MailgunClient } from '../lib/client'; +import { mailgunServiceError } from '../lib/errors'; import { spec } from '../spec'; +let getStoredMessageString = (message: Record, key: string) => { + let value = message[key]; + return typeof value === 'string' ? value : undefined; +}; + +let messageFileSchema = z.object({ + filename: z.string().describe('Filename Mailgun should use for the attachment'), + contentBase64: z.string().describe('Base64-encoded file content'), + contentType: z + .string() + .optional() + .describe('MIME type for the file, defaulting to application/octet-stream') +}); + +let assertValidBase64Files = ( + files: Array<{ filename: string; contentBase64: string }> | undefined, + label: string +) => { + for (let [index, file] of (files ?? []).entries()) { + let buffer = Buffer.from(file.contentBase64, 'base64'); + if (buffer.length === 0) { + throw mailgunServiceError(`${label}[${index}] must contain non-empty base64 content.`); + } + } +}; + export let sendEmail = SlateTool.create(spec, { name: 'Send Email', key: 'send_email', @@ -69,7 +96,15 @@ Supports scheduling, tracking options, tags for analytics, custom headers, and r .optional() .describe('Per-recipient personalization variables keyed by email address'), sendingIp: z.string().optional().describe('Specific IP address to send from'), - sendingIpPool: z.string().optional().describe('IP pool ID to send from') + sendingIpPool: z.string().optional().describe('IP pool ID to send from'), + attachments: z + .array(messageFileSchema) + .optional() + .describe('Files to attach to the message'), + inlineAttachments: z + .array(messageFileSchema) + .optional() + .describe('Files to attach with inline disposition for CID references') }) ) .output( @@ -79,6 +114,28 @@ Supports scheduling, tracking options, tags for analytics, custom headers, and r }) ) .handleInvocation(async ctx => { + if (!ctx.input.text && !ctx.input.html && !ctx.input.template) { + throw mailgunServiceError('At least one of text, html, or template must be provided.'); + } + + let recipientCount = + ctx.input.to.length + (ctx.input.cc?.length || 0) + (ctx.input.bcc?.length || 0); + + if (recipientCount < 1) { + throw mailgunServiceError('At least one recipient is required.'); + } + + if (recipientCount > 1000) { + throw mailgunServiceError('Mailgun supports at most 1,000 recipients per send.'); + } + + if ((ctx.input.tags?.length ?? 0) > 10) { + throw mailgunServiceError('Mailgun supports at most 10 tags per message.'); + } + + assertValidBase64Files(ctx.input.attachments, 'attachments'); + assertValidBase64Files(ctx.input.inlineAttachments, 'inlineAttachments'); + let client = new MailgunClient({ token: ctx.auth.token, region: ctx.config.region @@ -107,12 +164,11 @@ Supports scheduling, tracking options, tags for analytics, custom headers, and r customVariables: ctx.input.customVariables, recipientVariables: ctx.input.recipientVariables, sendingIp: ctx.input.sendingIp, - sendingIpPool: ctx.input.sendingIpPool + sendingIpPool: ctx.input.sendingIpPool, + attachments: ctx.input.attachments, + inlineAttachments: ctx.input.inlineAttachments }); - let recipientCount = - ctx.input.to.length + (ctx.input.cc?.length || 0) + (ctx.input.bcc?.length || 0); - return { output: { messageId: result.id, @@ -122,3 +178,54 @@ Supports scheduling, tracking options, tags for analytics, custom headers, and r }; }) .build(); + +export let getStoredMessage = SlateTool.create(spec, { + name: 'Get Stored Message', + key: 'get_stored_message', + description: `Retrieve a message stored by Mailgun from a storage key found in events, logs, or inbound route store actions. The stored message payload is returned as a JSON attachment, with metadata in the tool output.`, + tags: { readOnly: true } +}) + .input( + z.object({ + domain: z.string().describe('Domain that stored the message'), + storageKey: z.string().describe('Mailgun storage key for the stored message') + }) + ) + .output( + z.object({ + domain: z.string().describe('Domain used for retrieval'), + storageKey: z.string().describe('Storage key used for retrieval'), + subject: z.string().optional().describe('Stored message subject if present'), + sender: z.string().optional().describe('Stored message sender if present'), + recipients: z.string().optional().describe('Stored message recipients if present'), + attachmentCount: z.number().describe('Number of Slate attachments returned'), + byteLength: z.number().describe('UTF-8 byte length of the JSON attachment') + }) + ) + .handleInvocation(async ctx => { + let client = new MailgunClient({ + token: ctx.auth.token, + region: ctx.config.region + }); + + let message = await client.getStoredMessage(ctx.input.domain, ctx.input.storageKey); + let content = JSON.stringify(message, null, 2); + + return { + output: { + domain: ctx.input.domain, + storageKey: ctx.input.storageKey, + subject: getStoredMessageString(message, 'Subject'), + sender: + getStoredMessageString(message, 'From') ?? getStoredMessageString(message, 'sender'), + recipients: + getStoredMessageString(message, 'To') ?? + getStoredMessageString(message, 'recipients'), + attachmentCount: 1, + byteLength: Buffer.byteLength(content, 'utf8') + }, + attachments: [createTextAttachment(content, 'application/json')], + message: `Retrieved stored Mailgun message **${ctx.input.storageKey}** as a JSON attachment.` + }; + }) + .build(); diff --git a/integrations/mailgun/vitest.config.ts b/integrations/mailgun/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/mailgun/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/metaads/README.md b/integrations/metaads/README.md index bff6afe31f..5554cb9446 100644 --- a/integrations/metaads/README.md +++ b/integrations/metaads/README.md @@ -1,6 +1,6 @@ # Meta Ads -Create, manage, and optimize ad campaigns across Facebook, Instagram, Messenger, and WhatsApp. Build and configure campaigns, ad sets, and ads with targeting, budgets, and creatives. Retrieve performance insights including impressions, clicks, spend, conversions, and ROAS with configurable breakdowns. Manage custom audiences from customer data, website traffic, or app activity, and create lookalike audiences. Upload and manage ad creatives including images, videos, and carousels. Send server-side conversion events via the Conversions API. Manage product catalogs for dynamic ads. Retrieve lead ad submissions. Access the public Ad Library for competitive research. Receive webhooks for ad account status changes and lead generation events. +Create, manage, and optimize ad campaigns across Facebook, Instagram, Messenger, and WhatsApp. Build and configure campaigns, ad sets, and ads with targeting, budgets, and creatives. Retrieve performance insights including impressions, clicks, spend, conversions, and ROAS with configurable breakdowns. Manage custom audiences from customer data, website traffic, or app activity, and create lookalike audiences. Upload and manage ad creatives including images, videos, and carousels. Send server-side conversion events via the Conversions API. Discover businesses, ad accounts, Pages, and product catalogs for dynamic ads. Retrieve lead ad submissions. Access the public Ad Library for competitive research. Receive webhooks for ad account status changes and lead generation events. ## License diff --git a/integrations/metaads/docs/SPEC.md b/integrations/metaads/docs/SPEC.md index d6e43632b9..607996c98d 100644 --- a/integrations/metaads/docs/SPEC.md +++ b/integrations/metaads/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Meta Ads (officially the Marketing API) provides programmatic access to Meta's advertising platform, enabling management of ad campaigns across Facebook, Instagram, Messenger, and WhatsApp. The Marketing API is a collection of Graph API endpoints and other features that can be used to help you advertise across Meta technologies. The API encompasses campaign management, performance reporting via the Insights API, conversion tracking via the Conversions API, audience management, and access to the public Ad Library. +Meta Ads (officially the Marketing API) provides programmatic access to Meta's advertising platform, enabling management of ad campaigns across Facebook, Instagram, Messenger, and WhatsApp. The Marketing API is a collection of Graph API endpoints and other features that can be used to help you advertise across Meta technologies. The API encompasses campaign management, performance reporting via the Insights API, conversion tracking via the Conversions API, audience management, product catalog discovery, account and Page discovery, and access to the public Ad Library. ## Authentication @@ -69,7 +69,7 @@ The Conversions API is designed to create a connection between an advertiser's m ### Product Catalog Management -Automate catalog updates to keep product information, pricing, availability, and other details up-to-date across campaigns. Supports dynamic ads (Advantage+ catalog ads) that automatically show products from your catalog based on user behavior. +Discover product catalogs owned by a Meta Business and list products in a catalog. Product catalogs support dynamic ads (Advantage+ catalog ads) that automatically show products from your catalog based on user behavior. ### Ad Library API diff --git a/integrations/metaads/package.json b/integrations/metaads/package.json index 48bec8498e..09a7bf59db 100644 --- a/integrations/metaads/package.json +++ b/integrations/metaads/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/metaads/src/auth.ts b/integrations/metaads/src/auth.ts index 139b9815aa..29fdd9c780 100644 --- a/integrations/metaads/src/auth.ts +++ b/integrations/metaads/src/auth.ts @@ -1,5 +1,17 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { metaAdsApiError } from './lib/errors'; + +let createGraphAxios = () => { + let axios = createAxios({ baseURL: 'https://graph.facebook.com' }); + + axios.interceptors.response.use( + response => response, + error => Promise.reject(metaAdsApiError(error, 'auth request')) + ); + + return axios; +}; export let auth = SlateAuth.create() .output( @@ -62,7 +74,7 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let axios = createAxios({ baseURL: 'https://graph.facebook.com' }); + let axios = createGraphAxios(); let response = await axios.get('/oauth/access_token', { params: { @@ -100,7 +112,7 @@ export let auth = SlateAuth.create() }, handleTokenRefresh: async (ctx: any) => { - let axios = createAxios({ baseURL: 'https://graph.facebook.com' }); + let axios = createGraphAxios(); // Meta long-lived tokens can be refreshed by exchanging again let response = await axios.get('/oauth/access_token', { @@ -131,7 +143,7 @@ export let auth = SlateAuth.create() input: {}; scopes: string[]; }) => { - let axios = createAxios({ baseURL: 'https://graph.facebook.com' }); + let axios = createGraphAxios(); let response = await axios.get('/me', { params: { @@ -174,7 +186,7 @@ export let auth = SlateAuth.create() output: { token: string; refreshToken?: string; expiresAt?: string }; input: { apiToken: string }; }) => { - let axios = createAxios({ baseURL: 'https://graph.facebook.com' }); + let axios = createGraphAxios(); let response = await axios.get('/me', { params: { diff --git a/integrations/metaads/src/config.ts b/integrations/metaads/src/config.ts index b941aeebbb..2630e4eb73 100644 --- a/integrations/metaads/src/config.ts +++ b/integrations/metaads/src/config.ts @@ -8,6 +8,12 @@ export let config = SlateConfig.create( .describe( 'Meta Ad Account ID (e.g., act_123456789). This is used as the default ad account for all operations.' ), - apiVersion: z.string().default('v21.0').describe('Graph API version to use (e.g., v21.0)') + businessId: z + .string() + .optional() + .describe( + 'Optional Meta Business ID used by product catalog tools. You can also pass businessId directly to those tools.' + ), + apiVersion: z.string().default('v25.0').describe('Graph API version to use (e.g., v25.0)') }) ); diff --git a/integrations/metaads/src/index.ts b/integrations/metaads/src/index.ts index 9b830be4f9..68f6701c79 100644 --- a/integrations/metaads/src/index.ts +++ b/integrations/metaads/src/index.ts @@ -18,12 +18,18 @@ import { getCustomAudience, getInsights, getLeads, + listAdAccounts, listAdCreatives, listAdSets, listAds, + listBusinesses, listCampaigns, + listCatalogProducts, listCustomAudiences, listLeadForms, + listPages, + listProductCatalogs, + removeUsersFromAudience, searchAdLibrary, sendConversionEvents, updateAd, @@ -37,6 +43,9 @@ export let provider = Slate.create({ tools: [ listCampaigns, getCampaign, + listBusinesses, + listAdAccounts, + listPages, createCampaign, updateCampaign, deleteCampaign, @@ -58,7 +67,10 @@ export let provider = Slate.create({ getCustomAudience, createCustomAudience, addUsersToAudience, + removeUsersFromAudience, deleteCustomAudience, + listProductCatalogs, + listCatalogProducts, sendConversionEvents, searchAdLibrary, listLeadForms, diff --git a/integrations/metaads/src/lib/client.ts b/integrations/metaads/src/lib/client.ts index 7bb69f13a1..1a5b28ecff 100644 --- a/integrations/metaads/src/lib/client.ts +++ b/integrations/metaads/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { metaAdsApiError } from './errors'; export class MetaAdsClient { private token: string; @@ -14,12 +15,19 @@ export class MetaAdsClient { } private get axios() { - return createAxios({ + let axios = createAxios({ baseURL: this.baseUrl, params: { access_token: this.token } }); + + axios.interceptors.response.use( + response => response, + error => Promise.reject(metaAdsApiError(error)) + ); + + return axios; } private get accountPath() { @@ -395,17 +403,28 @@ export class MetaAdsClient { // ---- Product Catalogs ---- - async getCatalogs(params?: { fields?: string; limit?: number; after?: string }) { - let response = await this.axios.get( - `/${this.adAccountId.replace('act_', '')}/owned_product_catalogs`, - { - params: { - fields: params?.fields || 'id,name,product_count,vertical', - limit: params?.limit || 25, - after: params?.after - } + async getBusinesses(params?: { fields?: string; limit?: number; after?: string }) { + let response = await this.axios.get('/me/businesses', { + params: { + fields: params?.fields || 'id,name,created_time,verification_status', + limit: params?.limit || 25, + after: params?.after } - ); + }); + return response.data; + } + + async getCatalogs( + businessId: string, + params?: { fields?: string; limit?: number; after?: string } + ) { + let response = await this.axios.get(`/${businessId}/owned_product_catalogs`, { + params: { + fields: params?.fields || 'id,name,product_count,vertical,is_catalog_segment', + limit: params?.limit || 25, + after: params?.after + } + }); return response.data; } @@ -433,19 +452,25 @@ export class MetaAdsClient { // ---- Generic helpers ---- - async getAdAccounts(fields?: string) { + async getAdAccounts(params?: { fields?: string; limit?: number; after?: string }) { let response = await this.axios.get('/me/adaccounts', { params: { - fields: fields || 'id,name,account_status,currency,timezone_name,business' + fields: + params?.fields || + 'id,account_id,name,account_status,currency,timezone_name,business', + limit: params?.limit || 25, + after: params?.after } }); return response.data; } - async getPages(fields?: string) { + async getPages(params?: { fields?: string; limit?: number; after?: string }) { let response = await this.axios.get('/me/accounts', { params: { - fields: fields || 'id,name,access_token,category' + fields: params?.fields || 'id,name,category,tasks,instagram_business_account', + limit: params?.limit || 25, + after: params?.after } }); return response.data; diff --git a/integrations/metaads/src/lib/errors.ts b/integrations/metaads/src/lib/errors.ts new file mode 100644 index 0000000000..cb51ca17fc --- /dev/null +++ b/integrations/metaads/src/lib/errors.ts @@ -0,0 +1,90 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let stringValue = (value: unknown) => + typeof value === 'string' || typeof value === 'number' ? String(value) : undefined; + +let extractMetaMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + let graphError = isRecord(data.error) ? data.error : data; + + for (let key of [ + 'message', + 'error_user_title', + 'error_user_msg', + 'type', + 'error', + 'error_description' + ]) { + let value = stringValue(graphError[key]); + if (value && !details.includes(value)) { + details.push(value); + } + } + + for (let key of ['code', 'error_subcode', 'fbtrace_id']) { + let value = stringValue(graphError[key]); + if (value && !details.includes(`${key}: ${value}`)) { + details.push(`${key}: ${value}`); + } + } + } else if (typeof data === 'string' && data.trim()) { + details.push(data.trim()); + } + + if (response?.status !== undefined) { + let status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`; + return details.length > 0 ? `${status}: ${details.join(' - ')}` : status; + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let upstreamCodeFor = (error: unknown, key: 'code' | 'error_subcode') => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = isRecord(response?.data) ? response.data : undefined; + let graphError = isRecord(data?.error) ? data.error : data; + let value = graphError ? graphError[key] : undefined; + return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined; +}; + +export let metaAdsServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let metaAdsApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = metaAdsServiceError( + `Meta Marketing API ${operation} failed: ${extractMetaMessage(error)}` + ); + + serviceError.data.reason = 'meta_ads_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(error, 'code'); + serviceError.data.upstreamSubcode = upstreamCodeFor(error, 'error_subcode'); + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/metaads/src/tools/discover-assets.ts b/integrations/metaads/src/tools/discover-assets.ts new file mode 100644 index 0000000000..a8f0f6c24f --- /dev/null +++ b/integrations/metaads/src/tools/discover-assets.ts @@ -0,0 +1,310 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MetaAdsClient } from '../lib/client'; +import { metaAdsServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let businessSchema = z.object({ + businessId: z.string().describe('Meta Business ID'), + name: z.string().optional().describe('Business name'), + createdTime: z.string().optional().describe('Creation timestamp'), + verificationStatus: z.string().optional().describe('Business verification status') +}); + +let adAccountSchema = z.object({ + adAccountId: z.string().describe('Ad account ID, usually prefixed with act_'), + accountId: z.string().optional().describe('Numeric ad account ID'), + name: z.string().optional().describe('Ad account name'), + accountStatus: z.number().optional().describe('Meta account status code'), + currency: z.string().optional().describe('Account currency'), + timezoneName: z.string().optional().describe('Account time zone'), + business: z.any().optional().describe('Owning business summary') +}); + +let pageSchema = z.object({ + pageId: z.string().describe('Facebook Page ID'), + name: z.string().optional().describe('Page name'), + category: z.string().optional().describe('Page category'), + tasks: z.array(z.string()).optional().describe('Tasks granted for the authenticated user'), + instagramBusinessAccount: z.any().optional().describe('Connected Instagram business account') +}); + +let catalogSchema = z.object({ + catalogId: z.string().describe('Product catalog ID'), + name: z.string().optional().describe('Catalog name'), + productCount: z.number().optional().describe('Number of products in the catalog'), + vertical: z.string().optional().describe('Catalog vertical'), + isCatalogSegment: z.boolean().optional().describe('Whether the catalog is a segment') +}); + +let catalogProductSchema = z.object({ + productId: z.string().describe('Catalog product ID'), + name: z.string().optional().describe('Product name'), + description: z.string().optional().describe('Product description'), + price: z.string().optional().describe('Product price'), + currency: z.string().optional().describe('Product currency'), + availability: z.string().optional().describe('Product availability'), + imageUrl: z.string().optional().describe('Product image URL'), + url: z.string().optional().describe('Product URL'), + retailerId: z.string().optional().describe('Retailer product ID') +}); + +let createClient = (ctx: { + auth: { token: string }; + config: { adAccountId: string; apiVersion: string }; +}) => + new MetaAdsClient({ + token: ctx.auth.token, + adAccountId: ctx.config.adAccountId, + apiVersion: ctx.config.apiVersion + }); + +export let listBusinesses = SlateTool.create(spec, { + name: 'List Businesses', + key: 'list_businesses', + description: + 'List Meta Business accounts available to the authenticated user. Use this to find the businessId needed for product catalog tools.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + limit: z.number().optional().describe('Max number of businesses to return (default 25)'), + afterCursor: z.string().optional().describe('Pagination cursor') + }) + ) + .output( + z.object({ + businesses: z.array(businessSchema), + nextCursor: z.string().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let result = await createClient(ctx).getBusinesses({ + limit: ctx.input.limit, + after: ctx.input.afterCursor + }); + + let businesses = (result.data || []).map((business: any) => ({ + businessId: business.id, + name: business.name, + createdTime: business.created_time, + verificationStatus: business.verification_status + })); + + return { + output: { + businesses, + nextCursor: result.paging?.cursors?.after + }, + message: `Retrieved **${businesses.length}** businesses.` + }; + }) + .build(); + +export let listAdAccounts = SlateTool.create(spec, { + name: 'List Ad Accounts', + key: 'list_ad_accounts', + description: + 'List ad accounts visible to the authenticated user. Use this to discover adAccountId values for integration configuration.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + limit: z + .number() + .optional() + .describe('Max number of ad accounts to return (default 25)'), + afterCursor: z.string().optional().describe('Pagination cursor') + }) + ) + .output( + z.object({ + adAccounts: z.array(adAccountSchema), + nextCursor: z.string().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let result = await createClient(ctx).getAdAccounts({ + limit: ctx.input.limit, + after: ctx.input.afterCursor + }); + + let adAccounts = (result.data || []).map((account: any) => ({ + adAccountId: account.id, + accountId: account.account_id, + name: account.name, + accountStatus: account.account_status, + currency: account.currency, + timezoneName: account.timezone_name, + business: account.business + })); + + return { + output: { + adAccounts, + nextCursor: result.paging?.cursors?.after + }, + message: `Retrieved **${adAccounts.length}** ad accounts.` + }; + }) + .build(); + +export let listPages = SlateTool.create(spec, { + name: 'List Pages', + key: 'list_pages', + description: + 'List Facebook Pages available to the authenticated user. Use this to find page IDs for lead forms and ad creative object story specs.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + limit: z.number().optional().describe('Max number of pages to return (default 25)'), + afterCursor: z.string().optional().describe('Pagination cursor') + }) + ) + .output( + z.object({ + pages: z.array(pageSchema), + nextCursor: z.string().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let result = await createClient(ctx).getPages({ + limit: ctx.input.limit, + after: ctx.input.afterCursor + }); + + let pages = (result.data || []).map((page: any) => ({ + pageId: page.id, + name: page.name, + category: page.category, + tasks: page.tasks, + instagramBusinessAccount: page.instagram_business_account + })); + + return { + output: { + pages, + nextCursor: result.paging?.cursors?.after + }, + message: `Retrieved **${pages.length}** pages.` + }; + }) + .build(); + +export let listProductCatalogs = SlateTool.create(spec, { + name: 'List Product Catalogs', + key: 'list_product_catalogs', + description: + 'List product catalogs owned by a Meta Business. Product catalogs power Advantage+ catalog ads and dynamic product ads.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + businessId: z + .string() + .optional() + .describe('Meta Business ID. If omitted, uses the optional businessId config value.'), + limit: z.number().optional().describe('Max number of catalogs to return (default 25)'), + afterCursor: z.string().optional().describe('Pagination cursor') + }) + ) + .output( + z.object({ + catalogs: z.array(catalogSchema), + nextCursor: z.string().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let businessId = ctx.input.businessId ?? ctx.config.businessId; + if (!businessId) { + throw metaAdsServiceError( + 'businessId is required. Pass it to the tool or set the optional integration businessId config.' + ); + } + + let result = await createClient(ctx).getCatalogs(businessId, { + limit: ctx.input.limit, + after: ctx.input.afterCursor + }); + + let catalogs = (result.data || []).map((catalog: any) => ({ + catalogId: catalog.id, + name: catalog.name, + productCount: catalog.product_count, + vertical: catalog.vertical, + isCatalogSegment: catalog.is_catalog_segment + })); + + return { + output: { + catalogs, + nextCursor: result.paging?.cursors?.after + }, + message: `Retrieved **${catalogs.length}** product catalogs.` + }; + }) + .build(); + +export let listCatalogProducts = SlateTool.create(spec, { + name: 'List Catalog Products', + key: 'list_catalog_products', + description: + 'List products in a Meta product catalog, with optional catalog API filter support.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + catalogId: z.string().describe('Product catalog ID'), + filter: z + .record(z.string(), z.any()) + .optional() + .describe('Optional Meta catalog products filter object'), + limit: z.number().optional().describe('Max number of products to return (default 25)'), + afterCursor: z.string().optional().describe('Pagination cursor') + }) + ) + .output( + z.object({ + products: z.array(catalogProductSchema), + nextCursor: z.string().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let result = await createClient(ctx).getCatalogProducts(ctx.input.catalogId, { + filter: ctx.input.filter, + limit: ctx.input.limit, + after: ctx.input.afterCursor + }); + + let products = (result.data || []).map((product: any) => ({ + productId: product.id, + name: product.name, + description: product.description, + price: product.price, + currency: product.currency, + availability: product.availability, + imageUrl: product.image_url, + url: product.url, + retailerId: product.retailer_id + })); + + return { + output: { + products, + nextCursor: result.paging?.cursors?.after + }, + message: `Retrieved **${products.length}** catalog products.` + }; + }) + .build(); diff --git a/integrations/metaads/src/tools/get-insights.ts b/integrations/metaads/src/tools/get-insights.ts index e417c6be9f..4db2259e23 100644 --- a/integrations/metaads/src/tools/get-insights.ts +++ b/integrations/metaads/src/tools/get-insights.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MetaAdsClient } from '../lib/client'; +import { metaAdsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let getInsights = SlateTool.create(spec, { @@ -107,6 +108,10 @@ Use **objectId** to scope to a specific campaign/ad set/ad, or omit it to get ac }) ) .handleInvocation(async ctx => { + if (ctx.input.datePreset && ctx.input.timeRange) { + throw metaAdsServiceError('datePreset and timeRange are mutually exclusive.'); + } + let client = new MetaAdsClient({ token: ctx.auth.token, adAccountId: ctx.config.adAccountId, diff --git a/integrations/metaads/src/tools/index.ts b/integrations/metaads/src/tools/index.ts index 2086215e8e..97c8d3cd65 100644 --- a/integrations/metaads/src/tools/index.ts +++ b/integrations/metaads/src/tools/index.ts @@ -1,3 +1,4 @@ +export * from './discover-assets'; export * from './get-insights'; export * from './manage-ad-sets'; export * from './manage-ads'; diff --git a/integrations/metaads/src/tools/manage-ad-sets.ts b/integrations/metaads/src/tools/manage-ad-sets.ts index 4fd293a9a7..d1219c51c9 100644 --- a/integrations/metaads/src/tools/manage-ad-sets.ts +++ b/integrations/metaads/src/tools/manage-ad-sets.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MetaAdsClient } from '../lib/client'; +import { metaAdsServiceError } from '../lib/errors'; import { spec } from '../spec'; let adSetSchema = z.object({ @@ -213,6 +214,14 @@ export let createAdSet = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.dailyBudget && ctx.input.lifetimeBudget) { + throw metaAdsServiceError('Provide either dailyBudget or lifetimeBudget, not both.'); + } + + if (ctx.input.lifetimeBudget && !ctx.input.endTime) { + throw metaAdsServiceError('endTime is required when lifetimeBudget is provided.'); + } + let client = new MetaAdsClient({ token: ctx.auth.token, adAccountId: ctx.config.adAccountId, @@ -280,6 +289,27 @@ export let updateAdSet = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if ( + !ctx.input.name && + !ctx.input.status && + !ctx.input.targeting && + !ctx.input.dailyBudget && + !ctx.input.lifetimeBudget && + !ctx.input.bidAmount && + !ctx.input.endTime && + !ctx.input.optimizationGoal + ) { + throw metaAdsServiceError('Provide at least one ad set field to update.'); + } + + if (ctx.input.dailyBudget && ctx.input.lifetimeBudget) { + throw metaAdsServiceError('Provide either dailyBudget or lifetimeBudget, not both.'); + } + + if (ctx.input.lifetimeBudget && !ctx.input.endTime) { + throw metaAdsServiceError('endTime is required when lifetimeBudget is provided.'); + } + let client = new MetaAdsClient({ token: ctx.auth.token, adAccountId: ctx.config.adAccountId, diff --git a/integrations/metaads/src/tools/manage-ads.ts b/integrations/metaads/src/tools/manage-ads.ts index 0c4b8cdf38..3b8e40dc77 100644 --- a/integrations/metaads/src/tools/manage-ads.ts +++ b/integrations/metaads/src/tools/manage-ads.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MetaAdsClient } from '../lib/client'; +import { metaAdsServiceError } from '../lib/errors'; import { spec } from '../spec'; let adSchema = z.object({ @@ -191,6 +192,10 @@ export let updateAd = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (!ctx.input.name && !ctx.input.status && !ctx.input.creativeId) { + throw metaAdsServiceError('Provide at least one ad field to update.'); + } + let client = new MetaAdsClient({ token: ctx.auth.token, adAccountId: ctx.config.adAccountId, diff --git a/integrations/metaads/src/tools/manage-audiences.ts b/integrations/metaads/src/tools/manage-audiences.ts index adf1746050..58a0378b31 100644 --- a/integrations/metaads/src/tools/manage-audiences.ts +++ b/integrations/metaads/src/tools/manage-audiences.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MetaAdsClient } from '../lib/client'; +import { metaAdsServiceError } from '../lib/errors'; import { spec } from '../spec'; let audienceSchema = z.object({ @@ -28,6 +29,16 @@ let audienceSchema = z.object({ timeUpdated: z.string().optional().describe('Last update timestamp') }); +let validateAudienceRows = (schema: string[], userData: string[][]) => { + for (let [index, row] of userData.entries()) { + if (row.length !== schema.length) { + throw metaAdsServiceError( + `userData row ${index + 1} has ${row.length} values but schema has ${schema.length} fields.` + ); + } + } +}; + export let listCustomAudiences = SlateTool.create(spec, { name: 'List Custom Audiences', key: 'list_custom_audiences', @@ -124,6 +135,67 @@ export let getCustomAudience = SlateTool.create(spec, { }) .build(); +export let removeUsersFromAudience = SlateTool.create(spec, { + name: 'Remove Users from Audience', + key: 'remove_users_from_audience', + description: `Remove users from an existing custom audience using the same SHA-256 hashed identifier schema used for uploads.`, + instructions: [ + 'Use the same schema and hashed values that were uploaded to the custom audience.', + 'All PII values must be SHA-256 hashed before including them.' + ], + tags: { + destructive: true + } +}) + .input( + z.object({ + audienceId: z.string().describe('Custom audience ID to remove users from'), + schema: z + .array(z.string()) + .describe( + 'Array of field names in the data (e.g., ["EMAIL", "PHONE", "FN", "LN", "COUNTRY"])' + ), + userData: z + .array(z.array(z.string())) + .describe( + 'Array of user records, each an array of SHA-256 hashed values matching the schema order' + ) + }) + ) + .output( + z.object({ + audienceId: z.string().describe('The audience ID'), + numReceived: z.number().optional().describe('Number of records received'), + numInvalid: z.number().optional().describe('Number of invalid records') + }) + ) + .handleInvocation(async ctx => { + validateAudienceRows(ctx.input.schema, ctx.input.userData); + + let client = new MetaAdsClient({ + token: ctx.auth.token, + adAccountId: ctx.config.adAccountId, + apiVersion: ctx.config.apiVersion + }); + + let result = await client.removeUsersFromCustomAudience(ctx.input.audienceId, { + payload: JSON.stringify({ + schema: ctx.input.schema, + data: ctx.input.userData + }) + }); + + return { + output: { + audienceId: ctx.input.audienceId, + numReceived: result.num_received, + numInvalid: result.num_invalid_entries + }, + message: `Removed users from audience \`${ctx.input.audienceId}\`: **${result.num_received || 0}** received, **${result.num_invalid_entries || 0}** invalid.` + }; + }) + .build(); + export let createCustomAudience = SlateTool.create(spec, { name: 'Create Custom Audience', key: 'create_custom_audience', @@ -176,6 +248,22 @@ export let createCustomAudience = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.subtype === 'CUSTOM' && !ctx.input.customerFileSource) { + throw metaAdsServiceError( + 'customerFileSource is required when creating a CUSTOM customer-list audience.' + ); + } + + if (ctx.input.subtype === 'LOOKALIKE' && !ctx.input.lookalikeSpec) { + throw metaAdsServiceError( + 'lookalikeSpec is required when creating a LOOKALIKE audience.' + ); + } + + if (ctx.input.subtype !== 'LOOKALIKE' && ctx.input.lookalikeSpec) { + throw metaAdsServiceError('lookalikeSpec can only be used with subtype LOOKALIKE.'); + } + let client = new MetaAdsClient({ token: ctx.auth.token, adAccountId: ctx.config.adAccountId, @@ -248,6 +336,8 @@ export let addUsersToAudience = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + validateAudienceRows(ctx.input.schema, ctx.input.userData); + let client = new MetaAdsClient({ token: ctx.auth.token, adAccountId: ctx.config.adAccountId, diff --git a/integrations/metaads/src/tools/manage-campaigns.ts b/integrations/metaads/src/tools/manage-campaigns.ts index 5908bb619c..959523be44 100644 --- a/integrations/metaads/src/tools/manage-campaigns.ts +++ b/integrations/metaads/src/tools/manage-campaigns.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MetaAdsClient } from '../lib/client'; +import { metaAdsServiceError } from '../lib/errors'; import { spec } from '../spec'; let campaignSchema = z.object({ @@ -196,6 +197,10 @@ export let createCampaign = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.dailyBudget && ctx.input.lifetimeBudget) { + throw metaAdsServiceError('Provide either dailyBudget or lifetimeBudget, not both.'); + } + let client = new MetaAdsClient({ token: ctx.auth.token, adAccountId: ctx.config.adAccountId, @@ -255,6 +260,20 @@ export let updateCampaign = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if ( + !ctx.input.name && + !ctx.input.status && + !ctx.input.dailyBudget && + !ctx.input.lifetimeBudget && + !ctx.input.bidStrategy + ) { + throw metaAdsServiceError('Provide at least one campaign field to update.'); + } + + if (ctx.input.dailyBudget && ctx.input.lifetimeBudget) { + throw metaAdsServiceError('Provide either dailyBudget or lifetimeBudget, not both.'); + } + let client = new MetaAdsClient({ token: ctx.auth.token, adAccountId: ctx.config.adAccountId, diff --git a/integrations/metaads/src/tools/send-conversion-events.ts b/integrations/metaads/src/tools/send-conversion-events.ts index 939f73b242..73a6d14aed 100644 --- a/integrations/metaads/src/tools/send-conversion-events.ts +++ b/integrations/metaads/src/tools/send-conversion-events.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MetaAdsClient } from '../lib/client'; +import { metaAdsServiceError } from '../lib/errors'; import { spec } from '../spec'; let userDataSchema = z @@ -109,7 +110,21 @@ Events are sent to a dataset/pixel ID and used for ad optimization, targeting, a apiVersion: ctx.config.apiVersion }); - let formattedEvents = ctx.input.events.map(event => { + let formattedEvents = ctx.input.events.map((event, index) => { + if (event.actionSource === 'website') { + if (!event.eventSourceUrl) { + throw metaAdsServiceError( + `events[${index}].eventSourceUrl is required for website conversion events.` + ); + } + + if (!event.userData.clientUserAgent) { + throw metaAdsServiceError( + `events[${index}].userData.clientUserAgent is required for website conversion events.` + ); + } + } + let formatted: Record = { event_name: event.eventName, event_time: event.eventTime, @@ -140,6 +155,12 @@ Events are sent to a dataset/pixel ID and used for ad optimization, targeting, a } } + if (Object.keys(formatted.user_data).length === 0) { + throw metaAdsServiceError( + `events[${index}].userData must include at least one matching identifier.` + ); + } + if (event.eventId) formatted.event_id = event.eventId; if (event.eventSourceUrl) formatted.event_source_url = event.eventSourceUrl; if (event.customData) formatted.custom_data = event.customData; diff --git a/integrations/metorial-admin/package.json b/integrations/metorial-admin/package.json index 3e66fdede5..eccc9436a5 100644 --- a/integrations/metorial-admin/package.json +++ b/integrations/metorial-admin/package.json @@ -9,13 +9,14 @@ }, "dependencies": { "@lowerdeck/error": "^1.1.0", - "@slates/provider": "1.0.0-rc.15", + "@slates/provider": "1.0.0-rc.16", "@types/node": "^20", "zod": "^4.2" }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", "vitest": "^3.1.2" }, - "version": "0.1.0-rc.6" + "version": "0.1.0-rc.7" } diff --git a/integrations/metorial-admin/src/tools.schema.test.ts b/integrations/metorial-admin/src/tools.schema.test.ts index 5d4f1d5f0f..fc6af4ff23 100644 --- a/integrations/metorial-admin/src/tools.schema.test.ts +++ b/integrations/metorial-admin/src/tools.schema.test.ts @@ -1,18 +1,4 @@ -import { describe, expect, it } from 'vitest'; -import { z } from 'zod'; +import { describeMcpCompatibleToolSchemas } from '@slates/test'; import { provider } from './index'; -let tools = provider.actions.filter(action => action.type === 'tool'); - -describe('Metorial Admin tool input schemas', () => { - it.each( - tools.map(tool => [tool.key, tool] as const) - )('%s uses an MCP-compatible top-level object schema', (_key, tool) => { - let jsonSchema = z.toJSONSchema(tool.inputSchema) as Record; - - expect(jsonSchema.type).toBe('object'); - expect(jsonSchema).not.toHaveProperty('oneOf'); - expect(jsonSchema).not.toHaveProperty('anyOf'); - expect(jsonSchema).not.toHaveProperty('allOf'); - }); -}); +describeMcpCompatibleToolSchemas('Metorial Admin tool input schemas', provider.actions); diff --git a/integrations/midjourney/README.md b/integrations/midjourney/README.md index 0bec9aa895..c49ccfb992 100644 --- a/integrations/midjourney/README.md +++ b/integrations/midjourney/README.md @@ -1,6 +1,6 @@ # Midjourney -Generate images from text prompts with configurable parameters including aspect ratio, model version, stylization, chaos, quality, and negative prompts. Create variations of generated images and upscale them to higher resolution. Blend multiple images into new compositions. Describe images to generate text prompt suggestions. Use image references, style references, character references, and omni references to influence generation. Inpaint specific regions of images using masks and new prompts. Pan and zoom to expand image canvases. Generate short videos from images. Apply personalized style profiles and use specialized anime/illustration modes. Note: Midjourney does not offer an official public API; programmatic access requires unofficial third-party providers, which may violate Midjourney's terms of service. +Generate images from text prompts through APIFRAME's unofficial Midjourney API. Create variations, rerolls, upscales, blends, inpaints, pans, zoom-outs, seed lookups, short image-to-video tasks, and video extensions; fetch one or many asynchronous tasks; and inspect APIFRAME account credits. Note: Midjourney does not offer an official public API; programmatic access requires unofficial third-party providers, which may violate Midjourney's terms of service. ## Tools @@ -16,17 +16,53 @@ Generate variations of a previously generated Midjourney image. Select one of th Analyze an image and generate text prompt suggestions that could produce similar images in Midjourney. Returns up to 4 descriptive prompts inspired by the visual content, style, and composition of the input image. +### Extend Video + +Extend a previously generated Midjourney video using APIFRAME. + +### Fetch Many Tasks + +Check the statuses and results of multiple APIFRAME Midjourney tasks in one request. + ### Fetch Task -Check the status and retrieve results of a Midjourney task. Use this to poll for completion after submitting an asynchronous generation, variation, upscale, blend, or describe request. +Check the status and retrieve results of a Midjourney task. Use this to poll for completion after submitting an asynchronous generation, variation, upscale, blend, describe, edit, seed, or video request. ### Generate Image -Generate images from a text prompt using Midjourney. Submits an imagine request and optionally waits for the result. Supports Midjourney parameters like aspect ratio, model version, stylize, chaos, quality, and negative prompts directly in the prompt string (e.g. \ +Generate images from a text prompt using Midjourney. Submits an imagine request and optionally waits for the result. Supports Midjourney parameters like model version, stylize, chaos, quality, and negative prompts directly in the prompt string, plus an aspect ratio request field. + +### Generate Video + +Generate short Midjourney videos from a text prompt and a starting image URL using APIFRAME. + +### Get Account Info + +Retrieve APIFRAME account details for the configured API key, including remaining credits and plan. + +### Get Seed + +Request the seed value for a completed Midjourney image task. + +### Inpaint Image + +Redraw a selected region of a previously upscaled Midjourney image using a mask and optional prompt. + +### Pan Image + +Expand a previously upscaled Midjourney image canvas in one direction and fill the new area. + +### Reroll Image + +Create a fresh 4-image grid from a previous Midjourney imagine task, optionally with a revised prompt. ### Upscale Image -Upscale a Midjourney image to higher resolution at 2x or 4x scale. Can upscale from a previous task by referencing its task ID, or upscale any image by providing its URL directly. +Upscale a Midjourney image. Supports 1x grid selection, subtle/creative upscales, and 2x/4x high-resolution upscales. + +### Zoom Out Image + +Outpaint a previously upscaled Midjourney image by enlarging the canvas and generating surrounding content. ## License diff --git a/integrations/midjourney/docs/SPEC.md b/integrations/midjourney/docs/SPEC.md index 93de3fb816..c9b6ae4920 100644 --- a/integrations/midjourney/docs/SPEC.md +++ b/integrations/midjourney/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -Midjourney is an independent research lab that produces a proprietary artificial intelligence program that creates images from textual descriptions, similar to OpenAI's DALL-E or Stable Diffusion. As of 2026, Midjourney has transitioned from a niche Discord-based tool to a comprehensive creative suite offering web interfaces, enterprise APIs, and multimodal capabilities including video and 3D asset generation. As of early 2026, Midjourney does not offer an official public developer API, REST endpoint, SDK, webhook interface, or documented API key system that you can obtain directly from Midjourney for programmatic use. +Midjourney is an independent research lab that produces a proprietary artificial intelligence program that creates images from textual descriptions, similar to OpenAI's DALL-E or Stable Diffusion. As of early 2026, Midjourney does not offer an official public developer API, REST endpoint, SDK, webhook interface, or documented API key system that you can obtain directly from Midjourney for programmatic use. This integration targets APIFRAME's documented Midjourney Original API by default (`https://api.apiframe.pro`) because the existing authentication and endpoint contract are APIFRAME-based. ## Authentication @@ -20,7 +20,7 @@ There are two general approaches for programmatic access through unofficial thir Using these unofficial Midjourney APIs comes with the risk of having your Midjourney account banned, as such usage violates Midjourney's terms of service. -**Note on Enterprise API:** Midjourney is considering launching an enterprise API, but the release date is still unknown. One source mentions enterprise access with SSO, seat management, and API access, with API keys generated from a Developer Dashboard, but this has not been broadly confirmed and may not be publicly available. +**Note on APIFRAME API families:** APIFRAME documents both a newer Pro Midjourney API and the Original API. The Original API is still documented as supported, but APIFRAME recommends the Pro API for faster Midjourney-only generation. This integration preserves the existing Original API default and exposes the high-value Original API actions. ## Features @@ -28,7 +28,7 @@ Since there is no official API, the features below reflect the capabilities of M ### Text-to-Image Generation -Generate up to 4 images from a natural language text prompt (equivalent to the `/imagine` command). Supports parameters such as: +Generate up to 4 images from a natural language text prompt (equivalent to the `/imagine` command). APIFRAME accepts a `prompt`, optional `aspect_ratio`, and webhook fields. Midjourney prompt parameters can still be included in the prompt, such as: - **Aspect Ratio (`--ar`):** Control the width-to-height ratio of generated images. - **Model Version (`--v`):** Select a specific Midjourney model version (e.g., V6, V6.1, V7). @@ -40,7 +40,7 @@ Generate up to 4 images from a natural language text prompt (equivalent to the ` ### Image Variations and Upscaling -After generating an initial image grid, create variations of individual images or upscale them to higher resolution. Supports parameters like `--iw` (image weight for image prompts). Variations (V1-V4) enable generating variations of an existing image. +After generating an initial image grid, create variations of individual images, reroll the full grid, select a single image with 1x upscale, run subtle/creative upscales from a selected image, or run 2x/4x high-resolution upscales from a task or direct image URL. ### Image Blending @@ -59,12 +59,16 @@ Use Describe to turn your own images into inspiring prompt ideas. Provide an ima ### Inpainting (Vary Region) -Selectively modify portions of a generated image by specifying a mask area and a new prompt, while keeping the rest of the image unchanged. +Selectively modify portions of a generated image by specifying a base64 mask area and a new prompt, while keeping the rest of the image unchanged. ### Pan and Zoom Expand the canvas of an image in any direction (pan) or zoom out to reveal more of the scene around an existing generation. +### Task and Account Operations + +APIFRAME tasks are asynchronous. Fetch a single task, fetch 2-20 tasks in one request, request the seed for a completed image task, and retrieve APIFRAME account details such as remaining credits, total images, and plan. + ### Personalization Create custom image styles with personalized profiles and moodboards using --p. @@ -75,7 +79,7 @@ V7 introduces Draft Mode, which enables faster, lower-cost image generation for ### Video Generation -Turn your images into captivating 5 second videos. +Turn your images into short videos and extend previously generated videos. ### Niji Mode diff --git a/integrations/midjourney/package.json b/integrations/midjourney/package.json index 69320d50c4..7899ccf63e 100644 --- a/integrations/midjourney/package.json +++ b/integrations/midjourney/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/midjourney/slate.json b/integrations/midjourney/slate.json index 20c98e19d9..cab0eb85c4 100644 --- a/integrations/midjourney/slate.json +++ b/integrations/midjourney/slate.json @@ -1,18 +1,21 @@ { "name": "@metorial/midjourney", - "description": "Generate images from text prompts with configurable parameters including aspect ratio, model version, stylization, chaos, quality, and negative prompts. Create variations of generated images and upscale them to higher resolution. Blend multiple images into new compositions. Describe images to generate text prompt suggestions. Use image references, style references, character references, and omni references to influence generation. Inpaint specific regions of images using masks and new prompts. Pan and zoom to expand image canvases. Generate short videos from images. Apply personalized style profiles and use specialized anime/illustration modes. Note: Midjourney does not offer an official public API; programmatic access requires unofficial third-party providers, which may violate Midjourney's terms of service.", + "description": "Generate images from text prompts through APIFRAME's unofficial Midjourney API. Create variations, rerolls, upscales, blends, inpaints, pans, zoom-outs, seed lookups, short image-to-video tasks, and video extensions; fetch one or many asynchronous tasks; and inspect APIFRAME account credits. Note: Midjourney does not offer an official public API; programmatic access requires unofficial third-party providers, which may violate Midjourney's terms of service.", "categories": ["apis-and-http-requests", "document-processing"], "skills": [ "generate images from text", "upscale image resolution", "create image variations", + "reroll image grids", "blend multiple images", "describe images as text", "inpaint image regions", "pan and zoom images", "generate video from images", - "apply style references", - "use character references" + "extend generated videos", + "fetch task statuses", + "get image seeds", + "check apiframe account credits" ], "logoUrl": "https://provider-logos.metorial-cdn.com/midjourney.png" } diff --git a/integrations/midjourney/src/index.ts b/integrations/midjourney/src/index.ts index 55bf7458b7..130f176278 100644 --- a/integrations/midjourney/src/index.ts +++ b/integrations/midjourney/src/index.ts @@ -4,9 +4,18 @@ import { blendImages, createVariations, describeImage, + extendVideo, + fetchManyTasks, fetchTask, generateImage, - upscaleImage + generateVideo, + getAccountInfo, + getSeed, + inpaintImage, + panImage, + rerollImage, + upscaleImage, + zoomOutImage } from './tools'; import { inboundWebhook } from './triggers/inbound-webhook'; @@ -19,7 +28,16 @@ export let provider = Slate.create({ upscaleImage, blendImages, describeImage, - fetchTask + fetchTask, + rerollImage, + panImage, + zoomOutImage, + inpaintImage, + getSeed, + fetchManyTasks, + getAccountInfo, + generateVideo, + extendVideo ], triggers: [inboundWebhook] }); diff --git a/integrations/midjourney/src/lib/client.ts b/integrations/midjourney/src/lib/client.ts index f1a9d56517..1d68038ff5 100644 --- a/integrations/midjourney/src/lib/client.ts +++ b/integrations/midjourney/src/lib/client.ts @@ -1,9 +1,19 @@ import { createAxios } from 'slates'; +import { midjourneyApiError, midjourneyServiceError } from './errors'; import type { + AccountInfoResponse, BlendRequest, DescribeRequest, + ExtendVideoRequest, + FetchManyResponse, FetchTaskResponse, ImagineRequest, + ImagineVideoRequest, + InpaintRequest, + OutpaintRequest, + PanRequest, + RerollRequest, + SeedRequest, TaskSubmitResponse, UpscaleRequest, VariationsRequest @@ -22,74 +32,243 @@ export class Client { }); } + private async post( + endpoint: string, + body: Record, + operation: string + ): Promise { + try { + let response = await this.http.post(endpoint, body); + return response.data; + } catch (error) { + throw midjourneyApiError(error, operation); + } + } + + private async get(endpoint: string, operation: string): Promise { + try { + let response = await this.http.get(endpoint); + return response.data; + } catch (error) { + throw midjourneyApiError(error, operation); + } + } + async imagine(req: ImagineRequest): Promise { - let response = await this.http.post('/imagine', { - prompt: req.prompt, - ...(req.aspectRatio ? { aspect_ratio: req.aspectRatio } : {}), - ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), - ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) - }); - return response.data; + return await this.post( + '/imagine', + { + prompt: req.prompt, + ...(req.aspectRatio ? { aspect_ratio: req.aspectRatio } : {}), + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'imagine' + ); } async variations(req: VariationsRequest): Promise { - let response = await this.http.post('/variations', { - parent_task_id: req.parentTaskId, - index: req.index, - ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), - ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) - }); - return response.data; + return await this.post( + '/variations', + { + parent_task_id: req.parentTaskId, + index: req.index, + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'create variations' + ); } async blend(req: BlendRequest): Promise { - let response = await this.http.post('/blend', { - image_urls: req.imageUrls, - ...(req.dimension ? { dimension: req.dimension } : {}), - ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), - ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) - }); - return response.data; + return await this.post( + '/blend', + { + image_urls: req.imageUrls, + ...(req.dimension ? { dimension: req.dimension } : {}), + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'blend images' + ); } async describe(req: DescribeRequest): Promise { - let response = await this.http.post('/describe', { - image_url: req.imageUrl, - ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), - ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) - }); - return response.data; + return await this.post( + '/describe', + { + image_url: req.imageUrl, + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'describe image' + ); } async upscale(req: UpscaleRequest): Promise { - let body: Record = { - type: req.type + let webhookBody = { + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) }; - if (req.parentTaskId) { - body.parent_task_id = req.parentTaskId; - } - if (req.imageUrl) { - body.image_url = req.imageUrl; - } - if (req.index) { - body.index = req.index; - } - if (req.webhookUrl) { - body.webhook_url = req.webhookUrl; + + if (req.type === '1x') { + return await this.post( + '/upscale-1x', + { + parent_task_id: req.parentTaskId, + index: req.index, + ...webhookBody + }, + 'upscale image 1x' + ); } - if (req.webhookSecret) { - body.webhook_secret = req.webhookSecret; + + if (req.type === 'subtle' || req.type === 'creative') { + return await this.post( + '/upscale-alt', + { + parent_task_id: req.parentTaskId, + type: req.type, + ...webhookBody + }, + `upscale image ${req.type}` + ); } - let response = await this.http.post('/upscale-highres', body); - return response.data; + return await this.post( + '/upscale-highres', + { + type: req.type, + ...(req.parentTaskId ? { parent_task_id: req.parentTaskId } : {}), + ...(req.imageUrl ? { image_url: req.imageUrl } : {}), + ...(req.index ? { index: req.index } : {}), + ...webhookBody + }, + `upscale image ${req.type}` + ); + } + + async reroll(req: RerollRequest): Promise { + return await this.post( + '/reroll', + { + parent_task_id: req.parentTaskId, + ...(req.prompt ? { prompt: req.prompt } : {}), + ...(req.aspectRatio ? { aspect_ratio: req.aspectRatio } : {}), + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'reroll image' + ); + } + + async pan(req: PanRequest): Promise { + return await this.post( + '/pan', + { + parent_task_id: req.parentTaskId, + direction: req.direction, + ...(req.prompt ? { prompt: req.prompt } : {}), + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'pan image' + ); + } + + async outpaint(req: OutpaintRequest): Promise { + return await this.post( + '/outpaint', + { + parent_task_id: req.parentTaskId, + zoom_ratio: req.zoomRatio, + ...(req.aspectRatio ? { aspect_ratio: req.aspectRatio } : {}), + ...(req.prompt ? { prompt: req.prompt } : {}), + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'zoom out image' + ); + } + + async inpaint(req: InpaintRequest): Promise { + return await this.post( + '/inpaint', + { + parent_task_id: req.parentTaskId, + mask: req.mask, + ...(req.prompt ? { prompt: req.prompt } : {}), + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'inpaint image' + ); + } + + async getSeed(req: SeedRequest): Promise { + return await this.post( + '/seed', + { + task_id: req.taskId, + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'get seed' + ); + } + + async imagineVideo(req: ImagineVideoRequest): Promise { + return await this.post( + '/imagine-video', + { + prompt: req.prompt, + image_url: req.imageUrl, + ...(req.motion ? { motion: req.motion } : {}), + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'generate video' + ); + } + + async extendVideo(req: ExtendVideoRequest): Promise { + return await this.post( + '/imagine-video-extend', + { + parent_task_id: req.parentTaskId, + index: req.index, + prompt: req.prompt, + ...(req.imageUrl ? { image_url: req.imageUrl } : {}), + ...(req.motion ? { motion: req.motion } : {}), + ...(req.webhookUrl ? { webhook_url: req.webhookUrl } : {}), + ...(req.webhookSecret ? { webhook_secret: req.webhookSecret } : {}) + }, + 'extend video' + ); } async fetchTask(taskId: string): Promise { - let response = await this.http.post('/fetch', { - task_id: taskId - }); - return response.data; + return await this.post( + '/fetch', + { + task_id: taskId + }, + 'fetch task' + ); + } + + async fetchMany(taskIds: string[]): Promise { + return await this.post( + '/fetch-many', + { + task_ids: taskIds + }, + 'fetch many tasks' + ); + } + + async getAccountInfo(): Promise { + return await this.get('/account', 'get account info'); } async pollUntilComplete( @@ -103,19 +282,25 @@ export class Client { if ( result.status === 'processing' || result.status === 'queued' || - result.status === 'pending' + result.status === 'pending' || + result.status === 'staged' || + result.status === 'starting' || + result.status === 'retry' || + result.status === 'retrying' ) { await new Promise(resolve => setTimeout(resolve, intervalMs)); continue; } if (result.status === 'failed' || result.status === 'error') { - throw new Error(`Task ${taskId} failed: ${result.error || 'Unknown error'}`); + throw midjourneyServiceError( + `Task ${taskId} failed: ${result.error || 'Unknown error'}` + ); } // If status is not a "pending" state, assume completed return result; } - throw new Error(`Task ${taskId} timed out after ${maxAttempts} attempts`); + throw midjourneyServiceError(`Task ${taskId} timed out after ${maxAttempts} attempts`); } } diff --git a/integrations/midjourney/src/lib/errors.ts b/integrations/midjourney/src/lib/errors.ts new file mode 100644 index 0000000000..c544fd60e6 --- /dev/null +++ b/integrations/midjourney/src/lib/errors.ts @@ -0,0 +1,86 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addMessage = (messages: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let message = String(value).trim(); + if (message && !messages.includes(message)) { + messages.push(message); + } +}; + +let collectMessages = (value: unknown, messages: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectMessages(item, messages); + } + return; + } + + if (!isRecord(value)) { + addMessage(messages, value); + return; + } + + for (let key of ['msg', 'message', 'detail', 'error', 'title', 'code']) { + addMessage(messages, value[key]); + } + + collectMessages(value.errors, messages); +}; + +let getErrorResponse = (error: unknown) => + isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + +let extractMessage = (error: unknown) => { + let messages: string[] = []; + collectMessages(getErrorResponse(error)?.data, messages); + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let midjourneyServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let midjourneyApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = getErrorResponse(error); + let statusLabel = + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + let serviceError = midjourneyServiceError( + `Midjourney API ${operation} failed: ${statusLabel}${extractMessage(error)}` + ); + + serviceError.data.reason = 'midjourney_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/midjourney/src/lib/types.ts b/integrations/midjourney/src/lib/types.ts index e2b9643f90..9ecfc83b86 100644 --- a/integrations/midjourney/src/lib/types.ts +++ b/integrations/midjourney/src/lib/types.ts @@ -28,12 +28,69 @@ export interface DescribeRequest { export interface UpscaleRequest { parentTaskId?: string; imageUrl?: string; - type: '2x' | '4x'; + type: '1x' | '2x' | '4x' | 'subtle' | 'creative'; index?: string; webhookUrl?: string; webhookSecret?: string; } +export interface RerollRequest { + parentTaskId: string; + prompt?: string; + aspectRatio?: string; + webhookUrl?: string; + webhookSecret?: string; +} + +export interface PanRequest { + parentTaskId: string; + direction: 'up' | 'down' | 'left' | 'right'; + prompt?: string; + webhookUrl?: string; + webhookSecret?: string; +} + +export interface OutpaintRequest { + parentTaskId: string; + zoomRatio: number; + aspectRatio?: string; + prompt?: string; + webhookUrl?: string; + webhookSecret?: string; +} + +export interface InpaintRequest { + parentTaskId: string; + mask: string; + prompt?: string; + webhookUrl?: string; + webhookSecret?: string; +} + +export interface SeedRequest { + taskId: string; + webhookUrl?: string; + webhookSecret?: string; +} + +export interface ImagineVideoRequest { + prompt: string; + imageUrl: string; + motion?: 'low' | 'high'; + webhookUrl?: string; + webhookSecret?: string; +} + +export interface ExtendVideoRequest { + parentTaskId: string; + index: string; + prompt: string; + imageUrl?: string; + motion?: 'low' | 'high'; + webhookUrl?: string; + webhookSecret?: string; +} + export interface TaskSubmitResponse { task_id: string; } @@ -83,12 +140,30 @@ export interface UpscaleTaskResult { image_url?: string; } +export interface VideoTaskResult { + task_id: string; + task_type: string; + status?: string; + percentage?: string; + video_urls?: string[]; +} + +export interface SeedTaskResult { + task_id: string; + task_type: 'seed'; + status?: string; + percentage?: string; + seed?: string; +} + export type TaskResult = | ImagineTaskResult | VariationsTaskResult | BlendTaskResult | DescribeTaskResult - | UpscaleTaskResult; + | UpscaleTaskResult + | VideoTaskResult + | SeedTaskResult; export interface FetchTaskResponse { task_id: string; @@ -98,7 +173,20 @@ export interface FetchTaskResponse { original_image_url?: string; image_urls?: string[]; image_url?: string; + video_urls?: string[]; content?: string[]; sref?: string; + seed?: string; error?: string; } + +export interface FetchManyResponse { + tasks: FetchTaskResponse[]; +} + +export interface AccountInfoResponse { + email?: string; + credits?: number; + total_images?: number; + plan?: string; +} diff --git a/integrations/midjourney/src/spec.ts b/integrations/midjourney/src/spec.ts index 7a3342c844..18a3702396 100644 --- a/integrations/midjourney/src/spec.ts +++ b/integrations/midjourney/src/spec.ts @@ -6,7 +6,7 @@ export let spec = SlateSpecification.create({ key: 'midjourney', name: 'Midjourney', description: - 'Generate images from text prompts, create variations, upscale, blend images, and describe images using Midjourney via unofficial third-party API providers.', + 'Generate and edit Midjourney images, create and extend short videos, fetch asynchronous tasks, and inspect APIFRAME account credits via APIFRAME.', metadata: {}, config, auth diff --git a/integrations/midjourney/src/tools.schema.test.ts b/integrations/midjourney/src/tools.schema.test.ts new file mode 100644 index 0000000000..316ed6aad8 --- /dev/null +++ b/integrations/midjourney/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Midjourney tool input schemas', provider.actions); diff --git a/integrations/midjourney/src/tools/extend-video.ts b/integrations/midjourney/src/tools/extend-video.ts new file mode 100644 index 0000000000..fbd41d3cdc --- /dev/null +++ b/integrations/midjourney/src/tools/extend-video.ts @@ -0,0 +1,91 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let extendVideo = SlateTool.create(spec, { + name: 'Extend Video', + key: 'extend_video', + description: 'Extend a previously generated Midjourney video using APIFRAME.', + instructions: [ + 'Provide the parent task ID of a prior video generation task.', + 'Use index "1" to "4" to select which video result to extend.', + 'Optionally provide an accessible image URL as the next start frame.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + parentTaskId: z.string().describe('Task ID of the original video task'), + index: z + .enum(['1', '2', '3', '4']) + .describe('Video index from the original video task to extend'), + prompt: z.string().describe('Text prompt describing the video extension'), + imageUrl: z + .string() + .optional() + .describe('Optional public image URL to use as the next start frame'), + motion: z + .enum(['low', 'high']) + .optional() + .default('low') + .describe('Video motion amount. Defaults to low.'), + waitForResult: z + .boolean() + .optional() + .default(false) + .describe( + 'If true, polls until the video extension task completes and returns video URLs' + ) + }) + ) + .output( + z.object({ + taskId: z.string().describe('Unique identifier for the video extension task'), + status: z.string().optional().describe('Current status of the task'), + videoUrls: z.array(z.string()).optional().describe('URLs of extended videos') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + ctx.progress('Submitting video extension request...'); + + let submitResult = await client.extendVideo({ + parentTaskId: ctx.input.parentTaskId, + index: ctx.input.index, + prompt: ctx.input.prompt, + imageUrl: ctx.input.imageUrl, + motion: ctx.input.motion + }); + + if (!ctx.input.waitForResult) { + return { + output: { + taskId: submitResult.task_id, + status: 'submitted' + }, + message: `Video extension task **${submitResult.task_id}** submitted. Use the **Fetch Task** tool to check its status.` + }; + } + + ctx.progress('Waiting for video extension to complete...'); + + let result = await client.pollUntilComplete(submitResult.task_id); + + return { + output: { + taskId: result.task_id, + status: result.status ?? 'completed', + videoUrls: result.video_urls + }, + message: `Video extension completed. ${result.video_urls?.length ?? 0} videos generated.` + }; + }) + .build(); diff --git a/integrations/midjourney/src/tools/fetch-many-tasks.ts b/integrations/midjourney/src/tools/fetch-many-tasks.ts new file mode 100644 index 0000000000..f8d401d06a --- /dev/null +++ b/integrations/midjourney/src/tools/fetch-many-tasks.ts @@ -0,0 +1,74 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let taskSchema = z.object({ + taskId: z.string().describe('Task identifier'), + taskType: z.string().optional().describe('Type of the task'), + status: z.string().optional().describe('Current task status'), + progress: z.string().optional().describe('Percentage completion if still processing'), + gridImageUrl: z.string().optional().describe('URL of a generated image grid'), + imageUrls: z.array(z.string()).optional().describe('URLs of generated images'), + imageUrl: z.string().optional().describe('URL of a single result image'), + videoUrls: z.array(z.string()).optional().describe('URLs of generated videos'), + prompts: z.array(z.string()).optional().describe('Prompt suggestions'), + seed: z.string().optional().describe('Seed value'), + styleReference: z.string().optional().describe('Style reference code if applicable'), + error: z.string().optional().describe('Provider error message when the task failed') +}); + +export let fetchManyTasks = SlateTool.create(spec, { + name: 'Fetch Many Tasks', + key: 'fetch_many_tasks', + description: + 'Check the statuses and results of multiple APIFRAME Midjourney tasks in one request.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + taskIds: z + .array(z.string()) + .min(2) + .max(20) + .describe('Task IDs to fetch. APIFRAME accepts 2 to 20 task IDs.') + }) + ) + .output( + z.object({ + tasks: z.array(taskSchema).describe('Task statuses and results') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + let result = await client.fetchMany(ctx.input.taskIds); + let tasks = result.tasks.map(task => ({ + taskId: task.task_id, + taskType: task.task_type, + status: task.status ?? 'completed', + progress: task.percentage, + gridImageUrl: task.original_image_url, + imageUrls: task.image_urls, + imageUrl: task.image_url, + videoUrls: task.video_urls, + prompts: task.content, + seed: task.seed, + styleReference: task.sref, + error: task.error + })); + + return { + output: { + tasks + }, + message: `Fetched ${tasks.length} Midjourney tasks.` + }; + }) + .build(); diff --git a/integrations/midjourney/src/tools/fetch-task.ts b/integrations/midjourney/src/tools/fetch-task.ts index a39500845a..fdd8a0859b 100644 --- a/integrations/midjourney/src/tools/fetch-task.ts +++ b/integrations/midjourney/src/tools/fetch-task.ts @@ -51,7 +51,10 @@ export let fetchTask = SlateTool.create(spec, { .array(z.string()) .optional() .describe('Prompt suggestions (for describe tasks)'), - styleReference: z.string().optional().describe('Style reference code if applicable') + videoUrls: z.array(z.string()).optional().describe('Generated video URLs'), + seed: z.string().optional().describe('Seed value returned by seed tasks'), + styleReference: z.string().optional().describe('Style reference code if applicable'), + error: z.string().optional().describe('Provider error message when the task failed') }) ) .handleInvocation(async ctx => { @@ -75,10 +78,13 @@ export let fetchTask = SlateTool.create(spec, { imageUrls: result.image_urls, imageUrl: result.image_url, prompts: result.content, - styleReference: result.sref + videoUrls: result.video_urls, + seed: result.seed, + styleReference: result.sref, + error: result.error }, message: isComplete - ? `Task **${result.task_id}** (${result.task_type}) is **completed**.${result.image_urls ? ` ${result.image_urls.length} images available.` : ''}${result.image_url ? ` Image: ${result.image_url}` : ''}${result.content ? ` ${result.content.length} prompts generated.` : ''}` + ? `Task **${result.task_id}** (${result.task_type}) is **${status}**.${result.image_urls ? ` ${result.image_urls.length} images available.` : ''}${result.image_url ? ` Image: ${result.image_url}` : ''}${result.video_urls ? ` ${result.video_urls.length} videos available.` : ''}${result.content ? ` ${result.content.length} prompts generated.` : ''}${result.seed ? ` Seed: ${result.seed}.` : ''}${result.error ? ` Error: ${result.error}` : ''}` : `Task **${result.task_id}** (${result.task_type}) is **${status}**${result.percentage ? ` (${result.percentage}%)` : ''}.` }; }) diff --git a/integrations/midjourney/src/tools/generate-image.ts b/integrations/midjourney/src/tools/generate-image.ts index a4468aaf98..6650a6fd0d 100644 --- a/integrations/midjourney/src/tools/generate-image.ts +++ b/integrations/midjourney/src/tools/generate-image.ts @@ -68,13 +68,12 @@ export let generateImage = SlateTool.create(spec, { }); let prompt = ctx.input.prompt; - if (ctx.input.aspectRatio && !prompt.includes('--ar')) { - prompt = `${prompt} --ar ${ctx.input.aspectRatio}`; - } + let aspectRatio = + ctx.input.aspectRatio && !prompt.includes('--ar') ? ctx.input.aspectRatio : undefined; ctx.progress('Submitting imagine request...'); - let submitResult = await client.imagine({ prompt }); + let submitResult = await client.imagine({ prompt, aspectRatio }); if (!ctx.input.waitForResult) { return { diff --git a/integrations/midjourney/src/tools/generate-video.ts b/integrations/midjourney/src/tools/generate-video.ts new file mode 100644 index 0000000000..0ed2367c23 --- /dev/null +++ b/integrations/midjourney/src/tools/generate-video.ts @@ -0,0 +1,82 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let generateVideo = SlateTool.create(spec, { + name: 'Generate Video', + key: 'generate_video', + description: + 'Generate short Midjourney videos from a text prompt and a starting image URL using APIFRAME.', + instructions: [ + 'Provide an accessible image URL to use as the start frame.', + 'Use motion "high" for more movement or "low" for subtler movement.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + prompt: z.string().describe('Text prompt describing the desired video'), + imageUrl: z + .string() + .describe('Public URL of the starting image frame. Can come from an imagine result.'), + motion: z + .enum(['low', 'high']) + .optional() + .default('low') + .describe('Video motion amount. Defaults to low.'), + waitForResult: z + .boolean() + .optional() + .default(false) + .describe('If true, polls until the video task completes and returns video URLs') + }) + ) + .output( + z.object({ + taskId: z.string().describe('Unique identifier for the video generation task'), + status: z.string().optional().describe('Current status of the task'), + videoUrls: z.array(z.string()).optional().describe('URLs of generated videos') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + ctx.progress('Submitting video generation request...'); + + let submitResult = await client.imagineVideo({ + prompt: ctx.input.prompt, + imageUrl: ctx.input.imageUrl, + motion: ctx.input.motion + }); + + if (!ctx.input.waitForResult) { + return { + output: { + taskId: submitResult.task_id, + status: 'submitted' + }, + message: `Video generation task **${submitResult.task_id}** submitted. Use the **Fetch Task** tool to check its status.` + }; + } + + ctx.progress('Waiting for video generation to complete...'); + + let result = await client.pollUntilComplete(submitResult.task_id); + + return { + output: { + taskId: result.task_id, + status: result.status ?? 'completed', + videoUrls: result.video_urls + }, + message: `Video generation completed. ${result.video_urls?.length ?? 0} videos generated.` + }; + }) + .build(); diff --git a/integrations/midjourney/src/tools/get-account-info.ts b/integrations/midjourney/src/tools/get-account-info.ts new file mode 100644 index 0000000000..d3d77ec24a --- /dev/null +++ b/integrations/midjourney/src/tools/get-account-info.ts @@ -0,0 +1,43 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getAccountInfo = SlateTool.create(spec, { + name: 'Get Account Info', + key: 'get_account_info', + description: + 'Retrieve APIFRAME account details for the configured API key, including remaining credits and plan.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + email: z.string().optional().describe('Account email address'), + credits: z.number().optional().describe('Remaining APIFRAME credits'), + totalImages: z.number().optional().describe('Total images generated by the account'), + plan: z.string().optional().describe('Current APIFRAME plan') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + let account = await client.getAccountInfo(); + + return { + output: { + email: account.email, + credits: account.credits, + totalImages: account.total_images, + plan: account.plan + }, + message: `APIFRAME account${account.plan ? ` on ${account.plan} plan` : ''}${account.credits !== undefined ? ` has ${account.credits} credits remaining` : ''}.` + }; + }) + .build(); diff --git a/integrations/midjourney/src/tools/get-seed.ts b/integrations/midjourney/src/tools/get-seed.ts new file mode 100644 index 0000000000..4821de3dd0 --- /dev/null +++ b/integrations/midjourney/src/tools/get-seed.ts @@ -0,0 +1,74 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getSeed = SlateTool.create(spec, { + name: 'Get Seed', + key: 'get_seed', + description: + 'Request the seed value for a completed Midjourney image task. The seed request is asynchronous.', + instructions: [ + 'Pass the task ID of the generated image whose seed you need.', + 'Set waitForResult to true when you want the tool to poll until APIFRAME returns the seed value.' + ], + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + taskId: z.string().describe('Task ID of the image task whose seed should be fetched'), + waitForResult: z + .boolean() + .optional() + .default(false) + .describe('If true, polls until the seed lookup completes and returns the seed') + }) + ) + .output( + z.object({ + taskId: z.string().describe('Unique identifier for the seed lookup task'), + status: z.string().optional().describe('Current status of the seed lookup task'), + seed: z.string().optional().describe('Seed value returned by Midjourney') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + ctx.progress('Submitting seed lookup request...'); + + let submitResult = await client.getSeed({ + taskId: ctx.input.taskId + }); + + if (!ctx.input.waitForResult) { + return { + output: { + taskId: submitResult.task_id, + status: 'submitted' + }, + message: `Seed lookup task **${submitResult.task_id}** submitted. Use the **Fetch Task** tool to check its status.` + }; + } + + ctx.progress('Waiting for seed lookup to complete...'); + + let result = await client.pollUntilComplete(submitResult.task_id); + + return { + output: { + taskId: result.task_id, + status: result.status ?? 'completed', + seed: result.seed + }, + message: result.seed + ? `Seed lookup completed. Seed: ${result.seed}.` + : 'Seed lookup completed, but no seed value was returned.' + }; + }) + .build(); diff --git a/integrations/midjourney/src/tools/index.ts b/integrations/midjourney/src/tools/index.ts index 78c3f15c60..52ce2f27e9 100644 --- a/integrations/midjourney/src/tools/index.ts +++ b/integrations/midjourney/src/tools/index.ts @@ -1,6 +1,15 @@ export { blendImages } from './blend-images'; export { createVariations } from './create-variations'; export { describeImage } from './describe-image'; +export { extendVideo } from './extend-video'; +export { fetchManyTasks } from './fetch-many-tasks'; export { fetchTask } from './fetch-task'; export { generateImage } from './generate-image'; +export { generateVideo } from './generate-video'; +export { getAccountInfo } from './get-account-info'; +export { getSeed } from './get-seed'; +export { inpaintImage } from './inpaint-image'; +export { panImage } from './pan-image'; +export { rerollImage } from './reroll-image'; export { upscaleImage } from './upscale-image'; +export { zoomOutImage } from './zoom-out-image'; diff --git a/integrations/midjourney/src/tools/inpaint-image.ts b/integrations/midjourney/src/tools/inpaint-image.ts new file mode 100644 index 0000000000..173f79a07d --- /dev/null +++ b/integrations/midjourney/src/tools/inpaint-image.ts @@ -0,0 +1,102 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { midjourneyServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let normalizeMask = (maskBase64: string, mimeType: string) => { + let trimmed = maskBase64.trim(); + if (!trimmed) { + throw midjourneyServiceError('maskBase64 must contain a base64-encoded mask image.'); + } + + if (trimmed.startsWith('data:')) { + return trimmed; + } + + return `data:${mimeType};base64,${trimmed}`; +}; + +export let inpaintImage = SlateTool.create(spec, { + name: 'Inpaint Image', + key: 'inpaint_image', + description: + 'Redraw a selected region of a previously upscaled Midjourney image using a mask and optional prompt.', + instructions: [ + 'Use a parentTaskId from a prior 1x upscale task.', + 'Provide maskBase64 as either raw base64 or a data:image/...;base64 URI matching the selected region mask.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + parentTaskId: z.string().describe('Task ID of the upscaled image to inpaint'), + maskBase64: z + .string() + .describe('Base64-encoded mask image, either raw base64 or a data URI'), + maskMimeType: z + .string() + .optional() + .default('image/png') + .describe('MIME type to use when maskBase64 is raw base64. Defaults to image/png.'), + prompt: z + .string() + .optional() + .describe('Optional prompt describing what to draw in the selected region'), + waitForResult: z + .boolean() + .optional() + .default(false) + .describe('If true, polls until the inpaint task completes and returns image URLs') + }) + ) + .output( + z.object({ + taskId: z.string().describe('Unique identifier for the inpaint task'), + status: z.string().optional().describe('Current status of the task'), + gridImageUrl: z.string().optional().describe('URL of the inpainted grid image'), + imageUrls: z.array(z.string()).optional().describe('URLs of the inpainted images') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + ctx.progress('Submitting inpaint request...'); + + let submitResult = await client.inpaint({ + parentTaskId: ctx.input.parentTaskId, + mask: normalizeMask(ctx.input.maskBase64, ctx.input.maskMimeType), + prompt: ctx.input.prompt + }); + + if (!ctx.input.waitForResult) { + return { + output: { + taskId: submitResult.task_id, + status: 'submitted' + }, + message: `Inpaint task **${submitResult.task_id}** submitted. Use the **Fetch Task** tool to check its status.` + }; + } + + ctx.progress('Waiting for inpaint to complete...'); + + let result = await client.pollUntilComplete(submitResult.task_id); + + return { + output: { + taskId: result.task_id, + status: result.status ?? 'completed', + gridImageUrl: result.original_image_url, + imageUrls: result.image_urls + }, + message: `Inpaint completed. ${result.image_urls?.length ?? 0} images generated.` + }; + }) + .build(); diff --git a/integrations/midjourney/src/tools/pan-image.ts b/integrations/midjourney/src/tools/pan-image.ts new file mode 100644 index 0000000000..7338bfdde8 --- /dev/null +++ b/integrations/midjourney/src/tools/pan-image.ts @@ -0,0 +1,80 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let panImage = SlateTool.create(spec, { + name: 'Pan Image', + key: 'pan_image', + description: + 'Expand a previously upscaled Midjourney image canvas in one direction and fill the new area.', + instructions: [ + 'Use a parentTaskId from a prior 1x upscale task.', + 'Use prompt when you want to guide what Midjourney adds in the expanded area.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + parentTaskId: z.string().describe('Task ID of the upscaled image to pan'), + direction: z + .enum(['up', 'down', 'left', 'right']) + .describe('Direction to expand the image canvas'), + prompt: z.string().optional().describe('Optional prompt to guide the generated area'), + waitForResult: z + .boolean() + .optional() + .default(false) + .describe('If true, polls until the pan task completes and returns image URLs') + }) + ) + .output( + z.object({ + taskId: z.string().describe('Unique identifier for the pan task'), + status: z.string().optional().describe('Current status of the task'), + gridImageUrl: z.string().optional().describe('URL of the panned grid image'), + imageUrls: z.array(z.string()).optional().describe('URLs of the panned images') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + ctx.progress('Submitting pan request...'); + + let submitResult = await client.pan({ + parentTaskId: ctx.input.parentTaskId, + direction: ctx.input.direction, + prompt: ctx.input.prompt + }); + + if (!ctx.input.waitForResult) { + return { + output: { + taskId: submitResult.task_id, + status: 'submitted' + }, + message: `Pan task **${submitResult.task_id}** submitted. Use the **Fetch Task** tool to check its status.` + }; + } + + ctx.progress('Waiting for pan to complete...'); + + let result = await client.pollUntilComplete(submitResult.task_id); + + return { + output: { + taskId: result.task_id, + status: result.status ?? 'completed', + gridImageUrl: result.original_image_url, + imageUrls: result.image_urls + }, + message: `Pan completed. ${result.image_urls?.length ?? 0} images generated.` + }; + }) + .build(); diff --git a/integrations/midjourney/src/tools/reroll-image.ts b/integrations/midjourney/src/tools/reroll-image.ts new file mode 100644 index 0000000000..434904defa --- /dev/null +++ b/integrations/midjourney/src/tools/reroll-image.ts @@ -0,0 +1,89 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let rerollImage = SlateTool.create(spec, { + name: 'Reroll Image', + key: 'reroll_image', + description: + 'Create a fresh 4-image grid from a previous Midjourney imagine task, optionally with a revised prompt.', + instructions: [ + 'Use this when the original concept is useful but the full 4-image grid should be regenerated.', + 'Provide prompt only when you want to redraw with different wording; otherwise APIFRAME uses the original prompt.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + parentTaskId: z.string().describe('Task ID of the original imagine task to reroll'), + prompt: z + .string() + .optional() + .describe( + 'Optional replacement prompt for the reroll. Defaults to the original prompt.' + ), + aspectRatio: z + .string() + .optional() + .describe('Optional aspect ratio for the reroll, for example "16:9" or "1:1".'), + waitForResult: z + .boolean() + .optional() + .default(false) + .describe('If true, polls until the reroll completes and returns image URLs') + }) + ) + .output( + z.object({ + taskId: z.string().describe('Unique identifier for the reroll task'), + status: z.string().optional().describe('Current status of the task'), + gridImageUrl: z.string().optional().describe('URL of the rerolled grid image'), + imageUrls: z + .array(z.string()) + .optional() + .describe('URLs of the individual rerolled images') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + ctx.progress('Submitting reroll request...'); + + let submitResult = await client.reroll({ + parentTaskId: ctx.input.parentTaskId, + prompt: ctx.input.prompt, + aspectRatio: ctx.input.aspectRatio + }); + + if (!ctx.input.waitForResult) { + return { + output: { + taskId: submitResult.task_id, + status: 'submitted' + }, + message: `Reroll task **${submitResult.task_id}** submitted. Use the **Fetch Task** tool to check its status.` + }; + } + + ctx.progress('Waiting for reroll to complete...'); + + let result = await client.pollUntilComplete(submitResult.task_id); + + return { + output: { + taskId: result.task_id, + status: result.status ?? 'completed', + gridImageUrl: result.original_image_url, + imageUrls: result.image_urls + }, + message: `Reroll completed. ${result.image_urls?.length ?? 0} images generated.` + }; + }) + .build(); diff --git a/integrations/midjourney/src/tools/upscale-image.ts b/integrations/midjourney/src/tools/upscale-image.ts index 3dd2afdc96..946f01bd29 100644 --- a/integrations/midjourney/src/tools/upscale-image.ts +++ b/integrations/midjourney/src/tools/upscale-image.ts @@ -1,17 +1,21 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { midjourneyServiceError } from '../lib/errors'; import { spec } from '../spec'; export let upscaleImage = SlateTool.create(spec, { name: 'Upscale Image', key: 'upscale_image', - description: `Upscale a Midjourney image to higher resolution at 2x or 4x scale. Can upscale from a previous task by referencing its task ID, or upscale any image by providing its URL directly.`, + description: `Upscale a Midjourney image. Supports APIFRAME's 1x grid selection, subtle/creative upscales from a selected image, and 2x/4x high-resolution upscales from a task or image URL.`, instructions: [ - 'Provide either a parentTaskId (to upscale from a previous generation) or an imageUrl (to upscale any image), but not both.', - 'When upscaling from a 4-image grid task, specify the index (1-4) to select which image to upscale.' + 'Use scale "1x" with parentTaskId and index to select one image from a 4-image Midjourney grid.', + 'Use scale "subtle" or "creative" with the parentTaskId returned by a prior 1x upscale.', + 'Use scale "2x" or "4x" with exactly one of parentTaskId or imageUrl. Add index only when the parent task is a 4-image grid.' + ], + constraints: [ + 'When using imageUrl directly for 2x/4x upscales, the image must not be larger than 2048x2048.' ], - constraints: ['When using imageUrl directly, the image must not be larger than 2048x2048.'], tags: { destructive: false, readOnly: false @@ -27,7 +31,11 @@ export let upscaleImage = SlateTool.create(spec, { .string() .optional() .describe('Direct URL of an image to upscale (alternative to parentTaskId)'), - scale: z.enum(['2x', '4x']).describe('Upscale factor: "2x" or "4x"'), + scale: z + .enum(['1x', '2x', '4x', 'subtle', 'creative']) + .describe( + 'Upscale mode: "1x" selects from an image grid, "subtle"/"creative" upscale a 1x image, and "2x"/"4x" run high-resolution upscale.' + ), index: z .enum(['1', '2', '3', '4']) .optional() @@ -52,8 +60,35 @@ export let upscaleImage = SlateTool.create(spec, { baseUrl: ctx.config.baseUrl }); - if (!ctx.input.parentTaskId && !ctx.input.imageUrl) { - throw new Error('Either parentTaskId or imageUrl must be provided'); + if (ctx.input.scale === '1x') { + if (!ctx.input.parentTaskId || !ctx.input.index) { + throw midjourneyServiceError( + 'scale "1x" requires parentTaskId and index from a 4-image grid task.' + ); + } + if (ctx.input.imageUrl) { + throw midjourneyServiceError('scale "1x" does not support imageUrl.'); + } + } else if (ctx.input.scale === 'subtle' || ctx.input.scale === 'creative') { + if (!ctx.input.parentTaskId) { + throw midjourneyServiceError( + `scale "${ctx.input.scale}" requires parentTaskId from a prior 1x upscale task.` + ); + } + if (ctx.input.imageUrl || ctx.input.index) { + throw midjourneyServiceError( + `scale "${ctx.input.scale}" does not support imageUrl or index.` + ); + } + } else { + if (Boolean(ctx.input.parentTaskId) === Boolean(ctx.input.imageUrl)) { + throw midjourneyServiceError( + 'scale "2x" and "4x" require exactly one of parentTaskId or imageUrl.' + ); + } + if (ctx.input.imageUrl && ctx.input.index) { + throw midjourneyServiceError('index is only supported with parentTaskId.'); + } } ctx.progress('Submitting upscale request...'); diff --git a/integrations/midjourney/src/tools/zoom-out-image.ts b/integrations/midjourney/src/tools/zoom-out-image.ts new file mode 100644 index 0000000000..6577402c69 --- /dev/null +++ b/integrations/midjourney/src/tools/zoom-out-image.ts @@ -0,0 +1,92 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let zoomOutImage = SlateTool.create(spec, { + name: 'Zoom Out Image', + key: 'zoom_out_image', + description: + 'Outpaint a previously upscaled Midjourney image by enlarging the canvas and generating surrounding content.', + instructions: [ + 'Use a parentTaskId from a prior 1x upscale task.', + 'Use zoomRatio 1 to make square, 1.5 for 1.5x zoom out, 2 for 2x zoom out, or another value greater than 1 and up to 2 for custom zoom.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + parentTaskId: z.string().describe('Task ID of the upscaled image to zoom out'), + zoomRatio: z + .number() + .min(1) + .max(2) + .describe( + 'Zoom out ratio. Use 1 to make square, or a value greater than 1 and up to 2.' + ), + aspectRatio: z + .string() + .optional() + .describe('Optional aspect ratio for the outpainted image, for example "16:9".'), + prompt: z + .string() + .optional() + .describe('Optional prompt to guide generated surrounding areas'), + waitForResult: z + .boolean() + .optional() + .default(false) + .describe('If true, polls until the zoom-out task completes and returns image URLs') + }) + ) + .output( + z.object({ + taskId: z.string().describe('Unique identifier for the zoom-out task'), + status: z.string().optional().describe('Current status of the task'), + gridImageUrl: z.string().optional().describe('URL of the outpainted grid image'), + imageUrls: z.array(z.string()).optional().describe('URLs of the outpainted images') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + baseUrl: ctx.config.baseUrl + }); + + ctx.progress('Submitting zoom-out request...'); + + let submitResult = await client.outpaint({ + parentTaskId: ctx.input.parentTaskId, + zoomRatio: ctx.input.zoomRatio, + aspectRatio: ctx.input.aspectRatio, + prompt: ctx.input.prompt + }); + + if (!ctx.input.waitForResult) { + return { + output: { + taskId: submitResult.task_id, + status: 'submitted' + }, + message: `Zoom-out task **${submitResult.task_id}** submitted. Use the **Fetch Task** tool to check its status.` + }; + } + + ctx.progress('Waiting for zoom-out to complete...'); + + let result = await client.pollUntilComplete(submitResult.task_id); + + return { + output: { + taskId: result.task_id, + status: result.status ?? 'completed', + gridImageUrl: result.original_image_url, + imageUrls: result.image_urls + }, + message: `Zoom-out completed. ${result.image_urls?.length ?? 0} images generated.` + }; + }) + .build(); diff --git a/integrations/midjourney/vitest.config.ts b/integrations/midjourney/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/midjourney/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/mistral-ai/README.md b/integrations/mistral-ai/README.md index 0d8809fce1..a0b0e65b70 100644 --- a/integrations/mistral-ai/README.md +++ b/integrations/mistral-ai/README.md @@ -1,6 +1,6 @@ -# Mistral Ai +# Mistral AI -Generate chat completions, embeddings, and structured outputs using Mistral AI's language models. Process documents with OCR to extract text, tables, and images from PDFs. Transcribe audio with speaker diarization. Generate code with specialized coding models. Create and manage AI agents with built-in connectors for code execution, web search, image generation, and document retrieval. Moderate content for safety across multiple languages. Fine-tune models by uploading training data and managing fine-tuning jobs. Generate text embeddings for semantic search and RAG. Perform batch inference for async bulk processing. Manage files and list available models. +Generate chat completions, embeddings, and structured outputs using Mistral AI's language models. Process documents with OCR to extract text, tables, and images from PDFs. Transcribe audio with speaker diarization and generate speech audio returned as Slate attachments. Generate code with specialized coding models. Moderate standalone text and chat messages for safety across multiple languages. Upload, list, retrieve, download, and delete files. Generate text embeddings for semantic search and RAG. Perform batch inference for async bulk processing. List and retrieve available models. ## License diff --git a/integrations/mistral-ai/docs/SPEC.md b/integrations/mistral-ai/docs/SPEC.md index 74f1f9f523..bcb805fbf0 100644 --- a/integrations/mistral-ai/docs/SPEC.md +++ b/integrations/mistral-ai/docs/SPEC.md @@ -52,9 +52,9 @@ Mistral Embed is a model that generates dense vector representations of text, us Mistral OCR is an Optical Character Recognition API. It comprehends each element of documents—media, text, tables, equations. It takes images and PDFs as input and extracts content in ordered interleaved text and images. Supports configurable table format output (markdown or HTML), and options for extracting headers, footers, and hyperlinks. -### Audio Transcription +### Audio Transcription and Speech -Mistral offers audio input models fine-tuned and optimized for live transcription purposes. Supports speaker diarization for clear attribution between speakers. +Mistral offers audio input models fine-tuned and optimized for transcription. Supports speaker diarization for clear attribution between speakers. The API also supports speech generation from text using a saved voice or a reference audio clip; generated audio must be returned through Slate attachments rather than inline base64. ### Content Moderation @@ -66,7 +66,7 @@ Mistral offers specialized models for code generation through models like Codest ### Agents -The Agents API combines Mistral's language models with built-in connectors for code execution, web search, image generation, and MCP tools, persistent memory across conversations, and agentic orchestration capabilities. Key capabilities include: +The current API reference exposes new beta Agents and Conversations endpoints. The legacy `/v1/agents/completions` endpoint is marked deprecated by Mistral; this integration keeps the existing agent completion tool for compatibility but does not expand deprecated agent-completion behavior. Key beta capabilities include: - Code execution connector for running Python code in a secure sandboxed environment, enabling mathematical calculations, data visualization, and scientific computing. - Image generation connector powered by Black Forest Lab FLUX1.1. @@ -78,11 +78,11 @@ The Agents API combines Mistral's language models with built-in connectors for c ### Fine-Tuning -Mistral AI provides a fine-tuning API through La Plateforme, making it easy to fine-tune open-source and commercial models. Users upload training data (in JSONL format), create fine-tuning jobs with configurable hyperparameters (training steps, learning rate), and can monitor jobs and retrieve the resulting fine-tuned model. Mistral uses LoRA adapters under the hood. Supports integration with Weights & Biases for monitoring training metrics. +Mistral AI still exposes fine-tuning job endpoints in the API reference, but they are currently tagged deprecated. Existing tools remain available for compatibility with current workspaces; new work should prefer non-deprecated model and file workflows unless Mistral publishes a replacement fine-tuning surface. ### File Management -Files can be uploaded to Mistral's platform for use with fine-tuning (training/validation datasets) and other services. Files can be listed, retrieved, and deleted. +Files can be uploaded to Mistral's platform for use with OCR, batch inference, fine-tuning datasets, and transcription. Files can be listed, retrieved, downloaded through Slate attachments, signed with temporary URLs, and deleted. ### Model Listing diff --git a/integrations/mistral-ai/package.json b/integrations/mistral-ai/package.json index f8bdd14bde..de1d026d5c 100644 --- a/integrations/mistral-ai/package.json +++ b/integrations/mistral-ai/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/mistral-ai/src/auth.ts b/integrations/mistral-ai/src/auth.ts index 8c32891a35..9d213c5ed5 100644 --- a/integrations/mistral-ai/src/auth.ts +++ b/integrations/mistral-ai/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { mistralApiError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -26,11 +27,15 @@ export let auth = SlateAuth.create() baseURL: 'https://api.mistral.ai/v1' }); - await axios.get('/models', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + try { + await axios.get('/models', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); + } catch (error) { + throw mistralApiError(error, 'profile validation'); + } return { profile: { diff --git a/integrations/mistral-ai/src/index.ts b/integrations/mistral-ai/src/index.ts index 65e8ff9fdc..d743b4983a 100644 --- a/integrations/mistral-ai/src/index.ts +++ b/integrations/mistral-ai/src/index.ts @@ -11,15 +11,21 @@ import { createFineTuningJobTool, deleteFileTool, deleteModelTool, + downloadFileTool, extractDocumentTool, + generateSpeechTool, getBatchJobTool, getFileTool, getFineTuningJobTool, + getModelTool, listBatchJobsTool, listFilesTool, listFineTuningJobsTool, listModelsTool, - moderateContentTool + listVoicesTool, + moderateContentTool, + transcribeAudioTool, + uploadFileTool } from './tools'; import { batchJobStatusTrigger, fineTuningJobStatusTrigger, inboundWebhook } from './triggers'; @@ -31,9 +37,15 @@ export let provider = Slate.create({ moderateContentTool, extractDocumentTool, listModelsTool, + getModelTool, + uploadFileTool, listFilesTool, getFileTool, + downloadFileTool, deleteFileTool, + transcribeAudioTool, + generateSpeechTool, + listVoicesTool, createFineTuningJobTool, getFineTuningJobTool, listFineTuningJobsTool, diff --git a/integrations/mistral-ai/src/lib/client.ts b/integrations/mistral-ai/src/lib/client.ts index ded98addd9..a4f59b6922 100644 --- a/integrations/mistral-ai/src/lib/client.ts +++ b/integrations/mistral-ai/src/lib/client.ts @@ -1,17 +1,49 @@ +import { Buffer } from 'node:buffer'; import { createAxios } from 'slates'; +import { mistralApiError } from './errors'; + +let appendFormField = ( + formData: FormData, + key: string, + value: string | number | boolean | string[] | undefined +) => { + if (value === undefined) { + return; + } + + if (Array.isArray(value)) { + for (let item of value) { + formData.append(key, item); + } + return; + } + + formData.append(key, String(value)); +}; export class MistralClient { - private axios; + private axios: ReturnType; constructor(private token: string) { this.axios = createAxios({ baseURL: 'https://api.mistral.ai/v1' }); + + this.axios.interceptors.response.use( + response => response, + error => Promise.reject(mistralApiError(error)) + ); + } + + private get authHeaders() { + return { + Authorization: `Bearer ${this.token}` + }; } - private get headers() { + private get jsonHeaders() { return { - Authorization: `Bearer ${this.token}`, + ...this.authHeaders, 'Content-Type': 'application/json' }; } @@ -41,6 +73,11 @@ export class MistralClient { toolChoice?: string | any; parallelToolCalls?: boolean; safePrompt?: boolean; + metadata?: Record; + prediction?: Record; + reasoningEffort?: 'high' | 'none'; + guardrails?: Record[]; + promptCacheKey?: string; }) { let body: any = { model: params.model, @@ -70,9 +107,14 @@ export class MistralClient { if (params.parallelToolCalls !== undefined) body.parallel_tool_calls = params.parallelToolCalls; if (params.safePrompt !== undefined) body.safe_prompt = params.safePrompt; + if (params.metadata !== undefined) body.metadata = params.metadata; + if (params.prediction !== undefined) body.prediction = params.prediction; + if (params.reasoningEffort !== undefined) body.reasoning_effort = params.reasoningEffort; + if (params.guardrails !== undefined) body.guardrails = params.guardrails; + if (params.promptCacheKey !== undefined) body.prompt_cache_key = params.promptCacheKey; let response = await this.axios.post('/chat/completions', body, { - headers: this.headers + headers: this.jsonHeaders }); return response.data; @@ -90,6 +132,8 @@ export class MistralClient { topP?: number; stop?: string | string[]; randomSeed?: number; + metadata?: Record; + promptCacheKey?: string; }) { let body: any = { model: params.model, @@ -104,9 +148,11 @@ export class MistralClient { if (params.topP !== undefined) body.top_p = params.topP; if (params.stop !== undefined) body.stop = params.stop; if (params.randomSeed !== undefined) body.random_seed = params.randomSeed; + if (params.metadata !== undefined) body.metadata = params.metadata; + if (params.promptCacheKey !== undefined) body.prompt_cache_key = params.promptCacheKey; let response = await this.axios.post('/fim/completions', body, { - headers: this.headers + headers: this.jsonHeaders }); return response.data; @@ -149,7 +195,7 @@ export class MistralClient { body.frequency_penalty = params.frequencyPenalty; let response = await this.axios.post('/agents/completions', body, { - headers: this.headers + headers: this.jsonHeaders }); return response.data; @@ -163,6 +209,7 @@ export class MistralClient { encodingFormat?: string; outputDimension?: number; outputDtype?: string; + metadata?: Record; }) { let body: any = { model: params.model, @@ -172,9 +219,10 @@ export class MistralClient { if (params.encodingFormat !== undefined) body.encoding_format = params.encodingFormat; if (params.outputDimension !== undefined) body.output_dimension = params.outputDimension; if (params.outputDtype !== undefined) body.output_dtype = params.outputDtype; + if (params.metadata !== undefined) body.metadata = params.metadata; let response = await this.axios.post('/embeddings', body, { - headers: this.headers + headers: this.jsonHeaders }); return response.data; @@ -182,14 +230,20 @@ export class MistralClient { // ── Moderation ── - async moderate(params: { model: string; input: string | string[] }) { - let body = { + async moderate(params: { + model: string; + input: string | string[]; + metadata?: Record; + }) { + let body: any = { model: params.model, input: params.input }; + if (params.metadata !== undefined) body.metadata = params.metadata; + let response = await this.axios.post('/moderations', body, { - headers: this.headers + headers: this.jsonHeaders }); return response.data; @@ -198,14 +252,17 @@ export class MistralClient { async moderateChat(params: { model: string; input: Array<{ role: string; content: string }>; + metadata?: Record; }) { - let body = { + let body: any = { model: params.model, input: params.input }; + if (params.metadata !== undefined) body.metadata = params.metadata; + let response = await this.axios.post('/chat/moderations', body, { - headers: this.headers + headers: this.jsonHeaders }); return response.data; @@ -223,6 +280,10 @@ export class MistralClient { tableFormat?: string; extractHeader?: boolean; extractFooter?: boolean; + bboxAnnotationFormat?: Record; + documentAnnotationFormat?: Record; + documentAnnotationPrompt?: string; + confidenceScoresGranularity?: 'word' | 'page'; }) { let body: any = { model: params.model, @@ -237,9 +298,17 @@ export class MistralClient { if (params.tableFormat !== undefined) body.table_format = params.tableFormat; if (params.extractHeader !== undefined) body.extract_header = params.extractHeader; if (params.extractFooter !== undefined) body.extract_footer = params.extractFooter; + if (params.bboxAnnotationFormat !== undefined) + body.bbox_annotation_format = params.bboxAnnotationFormat; + if (params.documentAnnotationFormat !== undefined) + body.document_annotation_format = params.documentAnnotationFormat; + if (params.documentAnnotationPrompt !== undefined) + body.document_annotation_prompt = params.documentAnnotationPrompt; + if (params.confidenceScoresGranularity !== undefined) + body.confidence_scores_granularity = params.confidenceScoresGranularity; let response = await this.axios.post('/ocr', body, { - headers: this.headers + headers: this.jsonHeaders }); return response.data; @@ -247,9 +316,14 @@ export class MistralClient { // ── Models ── - async listModels() { + async listModels(params?: { provider?: string; model?: string }) { + let queryParams: any = {}; + if (params?.provider !== undefined) queryParams.provider = params.provider; + if (params?.model !== undefined) queryParams.model = params.model; + let response = await this.axios.get('/models', { - headers: this.headers + headers: this.authHeaders, + params: queryParams }); return response.data; @@ -257,7 +331,7 @@ export class MistralClient { async getModel(modelId: string) { let response = await this.axios.get(`/models/${modelId}`, { - headers: this.headers + headers: this.authHeaders }); return response.data; @@ -265,7 +339,7 @@ export class MistralClient { async deleteModel(modelId: string) { let response = await this.axios.delete(`/models/${modelId}`, { - headers: this.headers + headers: this.authHeaders }); return response.data; @@ -273,13 +347,54 @@ export class MistralClient { // ── Files ── - async listFiles(params?: { page?: number; pageSize?: number }) { + async uploadFile(params: { + filename: string; + contentBase64: string; + mimeType?: string; + purpose?: 'fine-tune' | 'batch' | 'ocr'; + visibility?: 'workspace' | 'user'; + expiry?: number; + }) { + let fileBytes = Buffer.from(params.contentBase64, 'base64'); + let formData = new FormData(); + let blob = new Blob([fileBytes], { + type: params.mimeType ?? 'application/octet-stream' + }); + + formData.append('file', blob, params.filename); + appendFormField(formData, 'purpose', params.purpose); + appendFormField(formData, 'visibility', params.visibility); + appendFormField(formData, 'expiry', params.expiry); + + let response = await this.axios.post('/files', formData, { + headers: this.authHeaders + }); + + return response.data; + } + + async listFiles(params?: { + page?: number; + pageSize?: number; + includeTotal?: boolean; + sampleType?: string[]; + source?: string[]; + search?: string; + purpose?: 'fine-tune' | 'batch' | 'ocr'; + mimetypes?: string[]; + }) { let queryParams: any = {}; if (params?.page !== undefined) queryParams.page = params.page; if (params?.pageSize !== undefined) queryParams.page_size = params.pageSize; + if (params?.includeTotal !== undefined) queryParams.include_total = params.includeTotal; + if (params?.sampleType !== undefined) queryParams.sample_type = params.sampleType; + if (params?.source !== undefined) queryParams.source = params.source; + if (params?.search !== undefined) queryParams.search = params.search; + if (params?.purpose !== undefined) queryParams.purpose = params.purpose; + if (params?.mimetypes !== undefined) queryParams.mimetypes = params.mimetypes; let response = await this.axios.get('/files', { - headers: this.headers, + headers: this.authHeaders, params: queryParams }); @@ -288,7 +403,7 @@ export class MistralClient { async getFile(fileId: string) { let response = await this.axios.get(`/files/${fileId}`, { - headers: this.headers + headers: this.authHeaders }); return response.data; @@ -296,18 +411,113 @@ export class MistralClient { async deleteFile(fileId: string) { let response = await this.axios.delete(`/files/${fileId}`, { - headers: this.headers + headers: this.authHeaders }); return response.data; } + async downloadFile(fileId: string) { + let response = await this.axios.get(`/files/${fileId}/content`, { + headers: this.authHeaders, + responseType: 'arraybuffer' + }); + let content = Buffer.from(response.data as ArrayBuffer); + let contentType = response.headers['content-type']; + + return { + contentBase64: content.toString('base64'), + contentType: typeof contentType === 'string' ? contentType : undefined, + byteLength: content.byteLength + }; + } + async getFileUrl(fileId: string, expiry?: number) { let queryParams: any = {}; if (expiry !== undefined) queryParams.expiry = expiry; let response = await this.axios.get(`/files/${fileId}/url`, { - headers: this.headers, + headers: this.authHeaders, + params: queryParams + }); + + return response.data; + } + + // ── Audio ── + + async transcribeAudio(params: { + model: string; + sourceType: 'file_url' | 'file_id' | 'file_content'; + fileUrl?: string; + fileId?: string; + filename?: string; + contentBase64?: string; + mimeType?: string; + language?: string; + temperature?: number; + diarize?: boolean; + contextBias?: string[]; + timestampGranularities?: Array<'segment' | 'word'>; + }) { + let formData = new FormData(); + appendFormField(formData, 'model', params.model); + appendFormField(formData, 'language', params.language); + appendFormField(formData, 'temperature', params.temperature); + appendFormField(formData, 'diarize', params.diarize); + appendFormField(formData, 'context_bias', params.contextBias); + appendFormField(formData, 'timestamp_granularities', params.timestampGranularities); + + if (params.sourceType === 'file_url') { + appendFormField(formData, 'file_url', params.fileUrl); + } else if (params.sourceType === 'file_id') { + appendFormField(formData, 'file_id', params.fileId); + } else { + let fileBytes = Buffer.from(params.contentBase64 ?? '', 'base64'); + let blob = new Blob([fileBytes], { + type: params.mimeType ?? 'application/octet-stream' + }); + formData.append('file', blob, params.filename ?? 'audio'); + } + + let response = await this.axios.post('/audio/transcriptions', formData, { + headers: this.authHeaders + }); + + return response.data; + } + + async generateSpeech(params: { + input: string; + model?: string; + voiceId?: string; + refAudio?: string; + responseFormat?: 'pcm' | 'wav' | 'mp3' | 'flac' | 'opus'; + }) { + let body: any = { + input: params.input, + stream: false + }; + + if (params.model !== undefined) body.model = params.model; + if (params.voiceId !== undefined) body.voice_id = params.voiceId; + if (params.refAudio !== undefined) body.ref_audio = params.refAudio; + if (params.responseFormat !== undefined) body.response_format = params.responseFormat; + + let response = await this.axios.post('/audio/speech', body, { + headers: this.jsonHeaders + }); + + return response.data; + } + + async listVoices(params?: { limit?: number; offset?: number }) { + let queryParams: any = {}; + if (params?.limit !== undefined) queryParams.limit = params.limit; + if (params?.offset !== undefined) queryParams.offset = params.offset; + + let response = await this.axios.get('/audio/voices', { + headers: this.authHeaders, params: queryParams }); @@ -343,8 +553,7 @@ export class MistralClient { if (params.validationFiles !== undefined) body.validation_files = params.validationFiles; if (params.suffix !== undefined) body.suffix = params.suffix; - if (params.dryRun !== undefined) body.dry_run = params.dryRun; - if (params.autoStart !== undefined) body.auto_start = params.autoStart; + body.auto_start = params.autoStart ?? false; if (params.hyperparameters) { let hp: any = {}; @@ -365,8 +574,12 @@ export class MistralClient { body.hyperparameters = hp; } + let queryParams: any = {}; + if (params.dryRun !== undefined) queryParams.dry_run = params.dryRun; + let response = await this.axios.post('/fine_tuning/jobs', body, { - headers: this.headers + headers: this.jsonHeaders, + params: queryParams }); return response.data; @@ -378,7 +591,7 @@ export class MistralClient { if (params?.pageSize !== undefined) queryParams.page_size = params.pageSize; let response = await this.axios.get('/fine_tuning/jobs', { - headers: this.headers, + headers: this.authHeaders, params: queryParams }); @@ -387,7 +600,7 @@ export class MistralClient { async getFineTuningJob(jobId: string) { let response = await this.axios.get(`/fine_tuning/jobs/${jobId}`, { - headers: this.headers + headers: this.authHeaders }); return response.data; @@ -398,7 +611,7 @@ export class MistralClient { `/fine_tuning/jobs/${jobId}/cancel`, {}, { - headers: this.headers + headers: this.jsonHeaders } ); @@ -410,7 +623,7 @@ export class MistralClient { `/fine_tuning/jobs/${jobId}/start`, {}, { - headers: this.headers + headers: this.jsonHeaders } ); @@ -436,7 +649,7 @@ export class MistralClient { if (params.metadata !== undefined) body.metadata = params.metadata; let response = await this.axios.post('/batch/jobs', body, { - headers: this.headers + headers: this.jsonHeaders }); return response.data; @@ -449,7 +662,7 @@ export class MistralClient { if (params?.pageSize !== undefined) queryParams.page_size = params.pageSize; let response = await this.axios.get('/batch/jobs', { - headers: this.headers, + headers: this.authHeaders, params: queryParams }); @@ -458,7 +671,7 @@ export class MistralClient { async getBatchJob(jobId: string) { let response = await this.axios.get(`/batch/jobs/${jobId}`, { - headers: this.headers + headers: this.authHeaders }); return response.data; @@ -469,7 +682,7 @@ export class MistralClient { `/batch/jobs/${jobId}/cancel`, {}, { - headers: this.headers + headers: this.jsonHeaders } ); diff --git a/integrations/mistral-ai/src/lib/errors.ts b/integrations/mistral-ai/src/lib/errors.ts new file mode 100644 index 0000000000..29124bca8f --- /dev/null +++ b/integrations/mistral-ai/src/lib/errors.ts @@ -0,0 +1,92 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.type); + addDetail(details, value.code); + addDetail(details, value.detail); + collectDetails(value.details, details); + collectDetails(value.error, details); +}; + +let extractMistralMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + if (isRecord(response?.data)) { + collectDetails(response.data.detail, details); + collectDetails(response.data.message, details); + collectDetails(response.data.error, details); + } else { + collectDetails(response?.data, details); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let mistralServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let mistralApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = mistralServiceError( + `Mistral AI API ${operation} failed: ${statusLabelFor(response)}${extractMistralMessage(error)}` + ); + serviceError.data.reason = 'mistral_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/mistral-ai/src/spec.ts b/integrations/mistral-ai/src/spec.ts index 13112340ed..4dc775be85 100644 --- a/integrations/mistral-ai/src/spec.ts +++ b/integrations/mistral-ai/src/spec.ts @@ -4,7 +4,7 @@ import { config } from './config'; export let spec = SlateSpecification.create({ key: 'mistral-ai', - name: 'Mistral Ai', + name: 'Mistral AI', description: undefined, metadata: {}, config, diff --git a/integrations/mistral-ai/src/tools.schema.test.ts b/integrations/mistral-ai/src/tools.schema.test.ts new file mode 100644 index 0000000000..7c00e4d0fe --- /dev/null +++ b/integrations/mistral-ai/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Mistral AI tool input schemas', provider.actions); diff --git a/integrations/mistral-ai/src/tools/audio.ts b/integrations/mistral-ai/src/tools/audio.ts new file mode 100644 index 0000000000..8a9bf012c4 --- /dev/null +++ b/integrations/mistral-ai/src/tools/audio.ts @@ -0,0 +1,311 @@ +import { Buffer } from 'node:buffer'; +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { MistralClient } from '../lib/client'; +import { mistralServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let usageSchema = z.object({ + promptAudioSeconds: z.number().optional().describe('Audio seconds processed'), + promptTokens: z.number().optional().describe('Prompt tokens processed'), + completionTokens: z.number().optional().describe('Completion tokens generated'), + totalTokens: z.number().optional().describe('Total tokens used') +}); + +let transcriptionSegmentSchema = z.object({ + text: z.string().optional().describe('Segment text'), + start: z.number().optional().describe('Segment start time in seconds'), + end: z.number().optional().describe('Segment end time in seconds'), + speakerId: z + .string() + .nullable() + .optional() + .describe('Speaker ID when diarization is enabled') +}); + +let voiceSchema = z.object({ + voiceId: z.string().describe('Voice ID'), + name: z.string().describe('Voice name'), + slug: z.string().nullable().optional().describe('Voice slug'), + languages: z.array(z.string()).optional().describe('Supported languages'), + gender: z.string().nullable().optional().describe('Voice gender metadata'), + age: z.number().nullable().optional().describe('Voice age metadata'), + tags: z.array(z.string()).nullable().optional().describe('Voice tags'), + color: z.string().nullable().optional().describe('Voice color metadata'), + createdAt: z.string().optional().describe('Creation timestamp'), + userId: z.string().nullable().optional().describe('Owning user ID') +}); + +let decodeBase64 = (label: string, contentBase64: string) => { + let normalized = contentBase64.replace(/\s+/g, ''); + let buffer = Buffer.from(normalized, 'base64'); + let encoded = buffer.toString('base64').replace(/=+$/u, ''); + let input = normalized.replace(/=+$/u, ''); + + if (!normalized || encoded !== input) { + throw mistralServiceError(`${label} must be valid non-empty base64 data`); + } + + return buffer; +}; + +let speechMimeType = (format: string | undefined) => { + switch (format) { + case 'wav': + return 'audio/wav'; + case 'flac': + return 'audio/flac'; + case 'opus': + return 'audio/opus'; + case 'pcm': + return 'audio/L16'; + default: + return 'audio/mpeg'; + } +}; + +let mapVoice = (voice: any) => ({ + voiceId: voice.id, + name: voice.name, + slug: voice.slug, + languages: voice.languages, + gender: voice.gender, + age: voice.age, + tags: voice.tags, + color: voice.color, + createdAt: voice.created_at, + userId: voice.user_id +}); + +export let transcribeAudioTool = SlateTool.create(spec, { + name: 'Transcribe Audio', + key: 'transcribe_audio', + description: `Transcribe an audio file with Mistral AI's audio transcription API. Supports public file URLs, Mistral file IDs, or inline base64 audio, with optional language hints, diarization, context bias, and timestamps.`, + instructions: [ + 'Set sourceType to "file_url", "file_id", or "file_content".', + 'For file_url, provide fileUrl. For file_id, provide fileId. For file_content, provide filename and contentBase64.', + 'Use diarize=true when speaker labels are needed.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + sourceType: z + .enum(['file_url', 'file_id', 'file_content']) + .describe('Audio source type'), + fileUrl: z.string().optional().describe('Public audio file URL for file_url source'), + fileId: z.string().optional().describe('Mistral file ID for file_id source'), + filename: z.string().optional().describe('Filename for file_content source'), + contentBase64: z + .string() + .optional() + .describe('Base64-encoded audio bytes for file_content source'), + mimeType: z + .string() + .optional() + .describe('MIME type for file_content source, e.g. audio/mpeg'), + model: z.string().default('voxtral-mini-latest').describe('Transcription model ID'), + language: z.string().optional().describe('Two-letter language hint, e.g. "en"'), + temperature: z.number().optional().describe('Sampling temperature'), + diarize: z.boolean().optional().describe('Enable speaker diarization'), + contextBias: z + .array(z.string()) + .optional() + .describe('Words or terms to bias transcription toward'), + timestampGranularities: z + .array(z.enum(['segment', 'word'])) + .optional() + .describe('Timestamp granularities to include') + }) + ) + .output( + z.object({ + model: z.string().describe('Transcription model used'), + text: z.string().describe('Transcribed text'), + language: z.string().nullable().optional().describe('Detected or configured language'), + segments: z + .array(transcriptionSegmentSchema) + .optional() + .describe('Timestamped segments when requested or returned'), + usage: usageSchema.optional().describe('Token and audio usage') + }) + ) + .handleInvocation(async ctx => { + if (ctx.input.sourceType === 'file_url' && !ctx.input.fileUrl) { + throw mistralServiceError('fileUrl is required when sourceType is "file_url"'); + } + if (ctx.input.sourceType === 'file_id' && !ctx.input.fileId) { + throw mistralServiceError('fileId is required when sourceType is "file_id"'); + } + if (ctx.input.sourceType === 'file_content') { + if (!ctx.input.filename) { + throw mistralServiceError('filename is required when sourceType is "file_content"'); + } + if (!ctx.input.contentBase64) { + throw mistralServiceError( + 'contentBase64 is required when sourceType is "file_content"' + ); + } + decodeBase64('contentBase64', ctx.input.contentBase64); + } + + let client = new MistralClient(ctx.auth.token); + let result = await client.transcribeAudio({ + sourceType: ctx.input.sourceType, + fileUrl: ctx.input.fileUrl, + fileId: ctx.input.fileId, + filename: ctx.input.filename, + contentBase64: ctx.input.contentBase64, + mimeType: ctx.input.mimeType, + model: ctx.input.model, + language: ctx.input.language, + temperature: ctx.input.temperature, + diarize: ctx.input.diarize, + contextBias: ctx.input.contextBias, + timestampGranularities: ctx.input.timestampGranularities + }); + + let output = { + model: result.model, + text: result.text ?? '', + language: result.language, + segments: result.segments?.map((segment: any) => ({ + text: segment.text, + start: segment.start, + end: segment.end, + speakerId: segment.speaker_id + })), + usage: result.usage + ? { + promptAudioSeconds: result.usage.prompt_audio_seconds, + promptTokens: result.usage.prompt_tokens, + completionTokens: result.usage.completion_tokens, + totalTokens: result.usage.total_tokens + } + : undefined + }; + + return { + output, + message: `Transcribed audio with **${output.model}** (${output.text.length} characters).` + }; + }) + .build(); + +export let generateSpeechTool = SlateTool.create(spec, { + name: 'Generate Speech', + key: 'generate_speech', + description: `Generate speech audio from text using Mistral AI. Returns the generated audio as a Slate attachment instead of inline base64.`, + instructions: [ + 'Provide voiceId for a preset or custom voice when required by the account.', + 'Alternatively provide refAudio as base64-encoded reference audio for zero-shot voice cloning.' + ], + tags: { + readOnly: false, + destructive: false + } +}) + .input( + z.object({ + input: z.string().describe('Text to synthesize'), + model: z.string().optional().describe('Speech model ID'), + voiceId: z.string().optional().describe('Preset or custom voice ID'), + refAudio: z + .string() + .optional() + .describe('Base64-encoded reference audio for zero-shot voice cloning'), + responseFormat: z + .enum(['pcm', 'wav', 'mp3', 'flac', 'opus']) + .optional() + .default('mp3') + .describe('Output audio format') + }) + ) + .output( + z.object({ + responseFormat: z.string().describe('Generated audio format'), + mimeType: z.string().describe('Attachment MIME type'), + byteLength: z.number().describe('Decoded audio byte length'), + attachmentCount: z.number().describe('Number of audio attachments returned') + }) + ) + .handleInvocation(async ctx => { + if (ctx.input.refAudio) { + decodeBase64('refAudio', ctx.input.refAudio); + } + + let client = new MistralClient(ctx.auth.token); + let result = await client.generateSpeech({ + input: ctx.input.input, + model: ctx.input.model, + voiceId: ctx.input.voiceId, + refAudio: ctx.input.refAudio, + responseFormat: ctx.input.responseFormat + }); + + if (typeof result.audio_data !== 'string' || result.audio_data.length === 0) { + throw mistralServiceError('Mistral AI speech response did not include audio_data'); + } + + let responseFormat = ctx.input.responseFormat ?? 'mp3'; + let byteLength = Buffer.from(result.audio_data, 'base64').byteLength; + let mimeType = speechMimeType(responseFormat); + + return { + output: { + responseFormat, + mimeType, + byteLength, + attachmentCount: 1 + }, + attachments: [createBase64Attachment(result.audio_data, mimeType)], + message: `Generated speech audio (${byteLength} bytes, ${responseFormat}).` + }; + }) + .build(); + +export let listVoicesTool = SlateTool.create(spec, { + name: 'List Voices', + key: 'list_voices', + description: `List available Mistral AI speech voices, excluding sample audio data. Use a returned voiceId with Generate Speech when a voice is required.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + limit: z.number().optional().describe('Maximum voices to return'), + offset: z.number().optional().describe('Pagination offset') + }) + ) + .output( + z.object({ + voices: z.array(voiceSchema).describe('Available voices'), + total: z.number().optional().describe('Total voice count'), + page: z.number().optional().describe('Current page'), + pageSize: z.number().optional().describe('Page size'), + totalPages: z.number().optional().describe('Total pages') + }) + ) + .handleInvocation(async ctx => { + let client = new MistralClient(ctx.auth.token); + let result = await client.listVoices({ + limit: ctx.input.limit, + offset: ctx.input.offset + }); + let voices = (result.items ?? []).map(mapVoice); + + return { + output: { + voices, + total: result.total, + page: result.page, + pageSize: result.page_size, + totalPages: result.total_pages + }, + message: `Found **${voices.length}** voice(s).` + }; + }) + .build(); diff --git a/integrations/mistral-ai/src/tools/chat-completion.ts b/integrations/mistral-ai/src/tools/chat-completion.ts index 4e30a633b0..e778438d33 100644 --- a/integrations/mistral-ai/src/tools/chat-completion.ts +++ b/integrations/mistral-ai/src/tools/chat-completion.ts @@ -54,6 +54,7 @@ export let chatCompletionTool = SlateTool.create(spec, { instructions: [ 'Use models like "mistral-large-latest", "mistral-small-latest", or "mistral-medium-latest" for general chat.', 'For reasoning tasks, use "magistral-medium-latest" or "magistral-small-latest".', + 'Use reasoningEffort instead of the deprecated prompt_mode parameter for reasoning models.', 'Set responseFormat to {"type":"json_object"} for JSON output, or {"type":"json_schema","json_schema":{"name":"...","schema":{...}}} for structured output.' ], tags: { @@ -69,9 +70,9 @@ export let chatCompletionTool = SlateTool.create(spec, { temperature: z .number() .min(0) - .max(2) + .max(1.5) .optional() - .describe('Sampling temperature (0.0-2.0)'), + .describe('Sampling temperature (0.0-1.5)'), topP: z .number() .min(0) @@ -79,6 +80,7 @@ export let chatCompletionTool = SlateTool.create(spec, { .optional() .describe('Nucleus sampling threshold (0.0-1.0)'), maxTokens: z.number().optional().describe('Maximum tokens to generate'), + minTokens: z.number().optional().describe('Minimum tokens to generate'), stop: z .union([z.string(), z.array(z.string())]) .optional() @@ -96,6 +98,7 @@ export let chatCompletionTool = SlateTool.create(spec, { .max(2) .optional() .describe('Frequency penalty (-2.0 to 2.0)'), + n: z.number().min(1).optional().describe('Number of completions to return'), responseFormat: z .any() .optional() @@ -110,7 +113,25 @@ export let chatCompletionTool = SlateTool.create(spec, { .union([z.enum(['none', 'auto', 'any', 'required']), z.any()]) .optional() .describe('Tool selection strategy'), - safePrompt: z.boolean().optional().describe('Inject safety system prompt') + parallelToolCalls: z + .boolean() + .optional() + .describe('Whether the model may call multiple tools in parallel'), + safePrompt: z.boolean().optional().describe('Inject safety system prompt'), + metadata: z.record(z.string(), z.any()).optional().describe('Request metadata'), + prediction: z + .record(z.string(), z.any()) + .optional() + .describe('Expected output hint for low-latency document/code edits'), + reasoningEffort: z + .enum(['high', 'none']) + .optional() + .describe('Reasoning effort for reasoning models'), + guardrails: z + .array(z.record(z.string(), z.any())) + .optional() + .describe('Guardrail configurations to apply to this request'), + promptCacheKey: z.string().optional().describe('Cache key for reusable prompt prefixes') }) ) .output( @@ -130,14 +151,22 @@ export let chatCompletionTool = SlateTool.create(spec, { temperature: ctx.input.temperature, topP: ctx.input.topP, maxTokens: ctx.input.maxTokens, + minTokens: ctx.input.minTokens, stop: ctx.input.stop, randomSeed: ctx.input.randomSeed, presencePenalty: ctx.input.presencePenalty, frequencyPenalty: ctx.input.frequencyPenalty, + n: ctx.input.n, responseFormat: ctx.input.responseFormat, tools: ctx.input.tools, toolChoice: ctx.input.toolChoice, - safePrompt: ctx.input.safePrompt + parallelToolCalls: ctx.input.parallelToolCalls, + safePrompt: ctx.input.safePrompt, + metadata: ctx.input.metadata, + prediction: ctx.input.prediction, + reasoningEffort: ctx.input.reasoningEffort, + guardrails: ctx.input.guardrails, + promptCacheKey: ctx.input.promptCacheKey }); let choices = (result.choices || []).map((c: any) => ({ diff --git a/integrations/mistral-ai/src/tools/code-completion.ts b/integrations/mistral-ai/src/tools/code-completion.ts index 6b9e8dc0c5..1ee4ae8d01 100644 --- a/integrations/mistral-ai/src/tools/code-completion.ts +++ b/integrations/mistral-ai/src/tools/code-completion.ts @@ -25,13 +25,16 @@ export let codeCompletionTool = SlateTool.create(spec, { prompt: z.string().describe('Code/text before the completion point'), suffix: z.string().optional().describe('Code/text after the completion point'), maxTokens: z.number().optional().describe('Maximum tokens to generate'), - temperature: z.number().min(0).max(2).optional().describe('Sampling temperature'), + temperature: z.number().min(0).max(1.5).optional().describe('Sampling temperature'), topP: z.number().min(0).max(1).optional().describe('Nucleus sampling threshold'), + minTokens: z.number().optional().describe('Minimum tokens to generate'), stop: z .union([z.string(), z.array(z.string())]) .optional() .describe('Stop sequence(s)'), - randomSeed: z.number().optional().describe('Seed for deterministic output') + randomSeed: z.number().optional().describe('Seed for deterministic output'), + metadata: z.record(z.string(), z.any()).optional().describe('Request metadata'), + promptCacheKey: z.string().optional().describe('Cache key for reusable prompt prefixes') }) ) .output( @@ -55,10 +58,13 @@ export let codeCompletionTool = SlateTool.create(spec, { prompt: ctx.input.prompt, suffix: ctx.input.suffix, maxTokens: ctx.input.maxTokens, + minTokens: ctx.input.minTokens, temperature: ctx.input.temperature, topP: ctx.input.topP, stop: ctx.input.stop, - randomSeed: ctx.input.randomSeed + randomSeed: ctx.input.randomSeed, + metadata: ctx.input.metadata, + promptCacheKey: ctx.input.promptCacheKey }); let choice = result.choices?.[0]; diff --git a/integrations/mistral-ai/src/tools/embeddings.ts b/integrations/mistral-ai/src/tools/embeddings.ts index d1c9249ada..61ca6d9d1c 100644 --- a/integrations/mistral-ai/src/tools/embeddings.ts +++ b/integrations/mistral-ai/src/tools/embeddings.ts @@ -5,7 +5,9 @@ import { spec } from '../spec'; let embeddingDataSchema = z.object({ index: z.number().describe('Index of the embedding in the input array'), - embedding: z.array(z.number()).describe('Dense vector representation') + embedding: z + .any() + .describe('Dense vector representation, or base64-encoded embedding when requested') }); export let createEmbeddingsTool = SlateTool.create(spec, { @@ -37,7 +39,12 @@ export let createEmbeddingsTool = SlateTool.create(spec, { outputDimension: z .number() .optional() - .describe('Custom embedding dimensionality (if supported by model)') + .describe('Custom embedding dimensionality (if supported by model)'), + outputDtype: z + .enum(['float', 'int8', 'uint8', 'binary', 'ubinary']) + .optional() + .describe('Embedding data type when supported by the model'), + metadata: z.record(z.string(), z.any()).optional().describe('Request metadata') }) ) .output( @@ -57,7 +64,9 @@ export let createEmbeddingsTool = SlateTool.create(spec, { model: ctx.input.model, input: ctx.input.input, encodingFormat: ctx.input.encodingFormat, - outputDimension: ctx.input.outputDimension + outputDimension: ctx.input.outputDimension, + outputDtype: ctx.input.outputDtype, + metadata: ctx.input.metadata }); let embeddings = (result.data || []).map((d: any) => ({ diff --git a/integrations/mistral-ai/src/tools/fine-tuning.ts b/integrations/mistral-ai/src/tools/fine-tuning.ts index 9269f3037b..686d93f2fe 100644 --- a/integrations/mistral-ai/src/tools/fine-tuning.ts +++ b/integrations/mistral-ai/src/tools/fine-tuning.ts @@ -74,7 +74,11 @@ export let createFineTuningJobTool = SlateTool.create(spec, { .boolean() .optional() .describe('Validate configuration without starting training'), - autoStart: z.boolean().optional().describe('Automatically start the job when created') + autoStart: z + .boolean() + .optional() + .default(false) + .describe('Automatically start the job when created') }) ) .output(jobSchema) diff --git a/integrations/mistral-ai/src/tools/index.ts b/integrations/mistral-ai/src/tools/index.ts index 12229f7721..7c36048f9d 100644 --- a/integrations/mistral-ai/src/tools/index.ts +++ b/integrations/mistral-ai/src/tools/index.ts @@ -1,4 +1,5 @@ export * from './agent-completion'; +export * from './audio'; export * from './batch-jobs'; export * from './chat-completion'; export * from './code-completion'; diff --git a/integrations/mistral-ai/src/tools/list-models.ts b/integrations/mistral-ai/src/tools/list-models.ts index eed2b122ff..337fe1aedb 100644 --- a/integrations/mistral-ai/src/tools/list-models.ts +++ b/integrations/mistral-ai/src/tools/list-models.ts @@ -18,12 +18,17 @@ let modelSchema = z.object({ export let listModelsTool = SlateTool.create(spec, { name: 'List Models', key: 'list_models', - description: `List all available Mistral AI models including both Mistral-provided and user fine-tuned models. Returns model details such as capabilities, context length, and aliases.`, + description: `List available Mistral AI models including both Mistral-provided and user fine-tuned models. Returns model details such as capabilities, context length, aliases, and ownership.`, tags: { readOnly: true } }) - .input(z.object({})) + .input( + z.object({ + provider: z.string().optional().describe('Optional provider filter'), + model: z.string().optional().describe('Optional model ID/name filter') + }) + ) .output( z.object({ models: z.array(modelSchema).describe('Available models') @@ -32,7 +37,10 @@ export let listModelsTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new MistralClient(ctx.auth.token); - let result = await client.listModels(); + let result = await client.listModels({ + provider: ctx.input.provider, + model: ctx.input.model + }); let models = (result.data || []).map((m: any) => ({ modelId: m.id, @@ -52,3 +60,40 @@ export let listModelsTool = SlateTool.create(spec, { }; }) .build(); + +export let getModelTool = SlateTool.create(spec, { + name: 'Get Model', + key: 'get_model', + description: `Retrieve metadata for a specific Mistral AI model, including capabilities, context length, aliases, ownership, and fine-tuned model details when available.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + modelId: z.string().describe('Model ID to retrieve') + }) + ) + .output(modelSchema) + .handleInvocation(async ctx => { + let client = new MistralClient(ctx.auth.token); + + let model = await client.getModel(ctx.input.modelId); + let output = { + modelId: model.id, + ownedBy: model.owned_by, + name: model.name, + description: model.description, + maxContextLength: model.max_context_length, + aliases: model.aliases, + capabilities: model.capabilities, + type: model.type ?? model.TYPE, + createdAt: model.created + }; + + return { + output, + message: `Retrieved model **${output.modelId}**.` + }; + }) + .build(); diff --git a/integrations/mistral-ai/src/tools/manage-files.ts b/integrations/mistral-ai/src/tools/manage-files.ts index 1b698872e8..5e2c0673a1 100644 --- a/integrations/mistral-ai/src/tools/manage-files.ts +++ b/integrations/mistral-ai/src/tools/manage-files.ts @@ -1,6 +1,8 @@ -import { SlateTool } from 'slates'; +import { Buffer } from 'node:buffer'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { MistralClient } from '../lib/client'; +import { mistralServiceError } from '../lib/errors'; import { spec } from '../spec'; let fileSchema = z.object({ @@ -9,9 +11,111 @@ let fileSchema = z.object({ bytes: z.number().optional().describe('File size in bytes'), purpose: z.string().optional().describe('File purpose (fine-tune, batch, ocr)'), createdAt: z.number().optional().describe('Creation timestamp'), - mimetype: z.string().optional().describe('File MIME type') + mimetype: z.string().optional().describe('File MIME type'), + sampleType: z.string().optional().describe('Sample type'), + source: z.string().optional().describe('File source'), + numLines: z.number().nullable().optional().describe('Number of lines for JSONL files'), + expiresAt: z.number().nullable().optional().describe('Expiration timestamp'), + visibility: z.string().nullable().optional().describe('File visibility') }); +let mapFile = (f: any) => ({ + fileId: f.id, + filename: f.filename, + bytes: f.bytes, + purpose: f.purpose, + createdAt: f.created_at, + mimetype: f.mimetype, + sampleType: f.sample_type, + source: f.source, + numLines: f.num_lines, + expiresAt: f.expires_at, + visibility: f.visibility +}); + +let decodeBase64 = (contentBase64: string) => { + let normalized = contentBase64.replace(/\s+/g, ''); + let buffer = Buffer.from(normalized, 'base64'); + let encoded = buffer.toString('base64').replace(/=+$/u, ''); + let input = normalized.replace(/=+$/u, ''); + + if (!normalized || encoded !== input) { + throw mistralServiceError('contentBase64 must be valid non-empty base64 data'); + } + + return buffer; +}; + +let uploadContentBase64 = (input: { content?: string; contentBase64?: string }) => { + if (input.content && input.contentBase64) { + throw mistralServiceError('Provide only one of content or contentBase64'); + } + + if (input.content !== undefined) { + return Buffer.from(input.content, 'utf8').toString('base64'); + } + + if (input.contentBase64 !== undefined) { + decodeBase64(input.contentBase64); + return input.contentBase64; + } + + throw mistralServiceError('Provide content or contentBase64'); +}; + +export let uploadFileTool = SlateTool.create(spec, { + name: 'Upload File', + key: 'upload_file', + description: `Upload a file to Mistral AI for OCR, batch inference, fine-tuning datasets, or later transcription by file ID. Accepts plain text content or base64-encoded bytes.`, + instructions: [ + 'Provide exactly one of content or contentBase64.', + 'purpose is optional; use "batch" for batch JSONL files, "ocr" for OCR documents, and "fine-tune" for fine-tuning JSONL datasets.' + ], + tags: { + readOnly: false, + destructive: false + } +}) + .input( + z.object({ + filename: z.string().describe('Filename to assign in Mistral AI'), + content: z.string().optional().describe('Text file content to upload'), + contentBase64: z.string().optional().describe('Base64-encoded file bytes to upload'), + mimeType: z + .string() + .optional() + .describe('MIME type for the uploaded file, defaults to application/octet-stream'), + purpose: z + .enum(['fine-tune', 'batch', 'ocr']) + .optional() + .describe('Intended file purpose'), + visibility: z + .enum(['workspace', 'user']) + .optional() + .describe('File visibility, defaults to workspace'), + expiry: z.number().optional().describe('Optional expiration in hours') + }) + ) + .output(fileSchema) + .handleInvocation(async ctx => { + let client = new MistralClient(ctx.auth.token); + let result = await client.uploadFile({ + filename: ctx.input.filename, + contentBase64: uploadContentBase64(ctx.input), + mimeType: ctx.input.mimeType, + purpose: ctx.input.purpose, + visibility: ctx.input.visibility, + expiry: ctx.input.expiry + }); + let file = mapFile(result); + + return { + output: file, + message: `Uploaded file **${file.filename ?? ctx.input.filename}** (${file.fileId}).` + }; + }) + .build(); + export let listFilesTool = SlateTool.create(spec, { name: 'List Files', key: 'list_files', @@ -23,7 +127,24 @@ export let listFilesTool = SlateTool.create(spec, { .input( z.object({ page: z.number().optional().describe('Page number (0-based)'), - pageSize: z.number().optional().describe('Number of files per page') + pageSize: z.number().optional().describe('Number of files per page'), + includeTotal: z.boolean().optional().describe('Whether to include total count'), + sampleType: z + .array( + z.enum(['pretrain', 'instruct', 'batch_request', 'batch_result', 'batch_error']) + ) + .optional() + .describe('Filter by sample type'), + source: z + .array(z.enum(['upload', 'repository', 'mistral'])) + .optional() + .describe('Filter by file source'), + search: z.string().optional().describe('Search by filename'), + purpose: z + .enum(['fine-tune', 'batch', 'ocr']) + .optional() + .describe('Filter by file purpose'), + mimetypes: z.array(z.string()).optional().describe('Filter by MIME types') }) ) .output( @@ -37,17 +158,16 @@ export let listFilesTool = SlateTool.create(spec, { let result = await client.listFiles({ page: ctx.input.page, - pageSize: ctx.input.pageSize + pageSize: ctx.input.pageSize, + includeTotal: ctx.input.includeTotal, + sampleType: ctx.input.sampleType, + source: ctx.input.source, + search: ctx.input.search, + purpose: ctx.input.purpose, + mimetypes: ctx.input.mimetypes }); - let files = (result.data || []).map((f: any) => ({ - fileId: f.id, - filename: f.filename, - bytes: f.bytes, - purpose: f.purpose, - createdAt: f.created_at, - mimetype: f.mimetype - })); + let files = (result.data || []).map(mapFile); return { output: { @@ -73,13 +193,7 @@ export let getFileTool = SlateTool.create(spec, { }) ) .output( - z.object({ - fileId: z.string().describe('File identifier'), - filename: z.string().optional().describe('Original filename'), - bytes: z.number().optional().describe('File size in bytes'), - purpose: z.string().optional().describe('File purpose'), - createdAt: z.number().optional().describe('Creation timestamp'), - mimetype: z.string().optional().describe('File MIME type'), + fileSchema.extend({ downloadUrl: z.string().optional().describe('Signed download URL (valid for 24 hours)') }) ) @@ -91,12 +205,7 @@ export let getFileTool = SlateTool.create(spec, { return { output: { - fileId: file.id, - filename: file.filename, - bytes: file.bytes, - purpose: file.purpose, - createdAt: file.created_at, - mimetype: file.mimetype, + ...mapFile(file), downloadUrl: urlResult?.url }, message: `Retrieved file **${file.filename || file.id}** (${file.bytes ? `${Math.round(file.bytes / 1024)} KB` : 'unknown size'}).` @@ -104,6 +213,45 @@ export let getFileTool = SlateTool.create(spec, { }) .build(); +export let downloadFileTool = SlateTool.create(spec, { + name: 'Download File', + key: 'download_file', + description: `Download file content from Mistral AI and return it as a Slate attachment. Use this for batch output/error files, uploaded documents, or other downloadable files.`, + instructions: [ + 'The file bytes are returned in response attachments, not inline output fields.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + fileId: z.string().describe('ID of the file to download') + }) + ) + .output( + z.object({ + fileId: z.string().describe('ID of the downloaded file'), + byteLength: z.number().describe('Byte length of the downloaded file'), + mimeType: z.string().optional().describe('Content-Type from Mistral AI') + }) + ) + .handleInvocation(async ctx => { + let client = new MistralClient(ctx.auth.token); + let result = await client.downloadFile(ctx.input.fileId); + + return { + output: { + fileId: ctx.input.fileId, + byteLength: result.byteLength, + mimeType: result.contentType + }, + attachments: [createBase64Attachment(result.contentBase64, result.contentType)], + message: `Downloaded file **${ctx.input.fileId}** (${result.byteLength} bytes).` + }; + }) + .build(); + export let deleteFileTool = SlateTool.create(spec, { name: 'Delete File', key: 'delete_file', diff --git a/integrations/mistral-ai/src/tools/moderation.ts b/integrations/mistral-ai/src/tools/moderation.ts index e033733509..f13da08c98 100644 --- a/integrations/mistral-ai/src/tools/moderation.ts +++ b/integrations/mistral-ai/src/tools/moderation.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MistralClient } from '../lib/client'; +import { mistralServiceError } from '../lib/errors'; import { spec } from '../spec'; let categoryResultSchema = z.object({ @@ -30,20 +31,40 @@ let categoryResultSchema = z.object({ }) }); +let chatModerationMessageSchema = z.object({ + role: z.enum(['system', 'user', 'assistant', 'tool']).describe('Message role'), + content: z.string().describe('Message content') +}); + export let moderateContentTool = SlateTool.create(spec, { name: 'Moderate Content', key: 'moderate_content', - description: `Analyze text for harmful content across multiple safety categories. Returns boolean flags and confidence scores for categories including sexual content, hate/discrimination, violence, dangerous content, self-harm, health/financial/legal misinformation, and PII detection.`, + description: `Analyze plain text or chat messages for harmful content across multiple safety categories. Returns boolean flags and confidence scores for categories including sexual content, hate/discrimination, violence, dangerous content, self-harm, health/financial/legal misinformation, and PII detection.`, + instructions: [ + 'Use mode "text" with input for standalone strings.', + 'Use mode "chat" with messages to moderate a conversation.' + ], tags: { readOnly: true } }) .input( z.object({ + mode: z + .enum(['text', 'chat']) + .optional() + .default('text') + .describe('Whether to moderate standalone text input or chat messages'), input: z .union([z.string(), z.array(z.string())]) - .describe('Text or array of texts to moderate'), - model: z.string().default('mistral-moderation-latest').describe('Moderation model ID') + .optional() + .describe('Text or array of texts to moderate when mode is "text"'), + messages: z + .array(chatModerationMessageSchema) + .optional() + .describe('Chat messages to moderate when mode is "chat"'), + model: z.string().default('mistral-moderation-latest').describe('Moderation model ID'), + metadata: z.record(z.string(), z.any()).optional().describe('Request metadata') }) ) .output( @@ -56,10 +77,27 @@ export let moderateContentTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new MistralClient(ctx.auth.token); - let result = await client.moderate({ - model: ctx.input.model, - input: ctx.input.input - }); + let mode = ctx.input.mode ?? 'text'; + let result = + mode === 'chat' + ? await client.moderateChat({ + model: ctx.input.model, + input: + ctx.input.messages ?? + (() => { + throw mistralServiceError('messages is required when mode is "chat"'); + })(), + metadata: ctx.input.metadata + }) + : await client.moderate({ + model: ctx.input.model, + input: + ctx.input.input ?? + (() => { + throw mistralServiceError('input is required when mode is "text"'); + })(), + metadata: ctx.input.metadata + }); let mapResult = (r: any) => ({ categories: { diff --git a/integrations/mistral-ai/src/tools/ocr.ts b/integrations/mistral-ai/src/tools/ocr.ts index 3fb6ebfdc4..8997d7db9b 100644 --- a/integrations/mistral-ai/src/tools/ocr.ts +++ b/integrations/mistral-ai/src/tools/ocr.ts @@ -1,6 +1,7 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { MistralClient } from '../lib/client'; +import { mistralServiceError } from '../lib/errors'; import { spec } from '../spec'; let pageSchema = z.object({ @@ -20,6 +21,25 @@ let pageSchema = z.object({ .describe('Page dimensions') }); +let mimeTypeFromImageId = (imageId: unknown) => { + if (typeof imageId !== 'string') { + return 'image/jpeg'; + } + + let lower = imageId.toLowerCase(); + if (lower.endsWith('.png')) return 'image/png'; + if (lower.endsWith('.webp')) return 'image/webp'; + if (lower.endsWith('.gif')) return 'image/gif'; + return 'image/jpeg'; +}; + +let stripDataUrlPrefix = (content: string) => { + let commaIndex = content.indexOf(','); + return content.slice(0, 32).includes(';base64,') && commaIndex >= 0 + ? content.slice(commaIndex + 1) + : content; +}; + export let extractDocumentTool = SlateTool.create(spec, { name: 'Extract Document (OCR)', key: 'extract_document', @@ -62,7 +82,23 @@ export let extractDocumentTool = SlateTool.create(spec, { .optional() .describe('Format for extracted tables'), extractHeader: z.boolean().optional().describe('Extract page headers separately'), - extractFooter: z.boolean().optional().describe('Extract page footers separately') + extractFooter: z.boolean().optional().describe('Extract page footers separately'), + bboxAnnotationFormat: z + .record(z.string(), z.any()) + .optional() + .describe('JSON schema response format for extracted bounding boxes/images'), + documentAnnotationFormat: z + .record(z.string(), z.any()) + .optional() + .describe('JSON schema response format for the entire document'), + documentAnnotationPrompt: z + .string() + .optional() + .describe('Prompt for document-level structured extraction'), + confidenceScoresGranularity: z + .enum(['word', 'page']) + .optional() + .describe('Granularity for OCR confidence scores') }) ) .output( @@ -70,7 +106,15 @@ export let extractDocumentTool = SlateTool.create(spec, { model: z.string().describe('Model used'), pages: z.array(pageSchema).describe('Extracted pages'), pagesProcessed: z.number().describe('Number of pages processed'), - documentSizeBytes: z.number().optional().describe('Document size in bytes') + documentSizeBytes: z.number().optional().describe('Document size in bytes'), + documentAnnotation: z + .string() + .nullable() + .optional() + .describe('Document-level structured annotation when requested'), + imageAttachmentCount: z + .number() + .describe('Number of extracted image attachments returned separately') }) ) .handleInvocation(async ctx => { @@ -78,13 +122,28 @@ export let extractDocumentTool = SlateTool.create(spec, { let document: any; if (ctx.input.documentType === 'document_url') { + if (!ctx.input.documentUrl) { + throw mistralServiceError('documentUrl is required for document_url OCR input'); + } document = { type: 'document_url', document_url: ctx.input.documentUrl }; } else if (ctx.input.documentType === 'image_url') { + if (!ctx.input.imageUrl) { + throw mistralServiceError('imageUrl is required for image_url OCR input'); + } document = { type: 'image_url', image_url: ctx.input.imageUrl }; } else { + if (!ctx.input.fileId) { + throw mistralServiceError('fileId is required for file OCR input'); + } document = { type: 'file', file_id: ctx.input.fileId }; } + if (ctx.input.documentAnnotationPrompt && !ctx.input.documentAnnotationFormat) { + throw mistralServiceError( + 'documentAnnotationFormat is required when documentAnnotationPrompt is provided' + ); + } + let result = await client.ocr({ model: ctx.input.model, document, @@ -94,13 +153,29 @@ export let extractDocumentTool = SlateTool.create(spec, { imageMinSize: ctx.input.imageMinSize, tableFormat: ctx.input.tableFormat, extractHeader: ctx.input.extractHeader, - extractFooter: ctx.input.extractFooter + extractFooter: ctx.input.extractFooter, + bboxAnnotationFormat: ctx.input.bboxAnnotationFormat, + documentAnnotationFormat: ctx.input.documentAnnotationFormat, + documentAnnotationPrompt: ctx.input.documentAnnotationPrompt, + confidenceScoresGranularity: ctx.input.confidenceScoresGranularity }); + let attachments: ReturnType[] = []; let pages = (result.pages || []).map((p: any) => ({ index: p.index, markdown: p.markdown || '', - images: p.images, + images: p.images?.map((image: any) => { + let { image_base64, ...metadata } = image; + if (typeof image_base64 === 'string' && image_base64.length > 0) { + attachments.push( + createBase64Attachment( + stripDataUrlPrefix(image_base64), + mimeTypeFromImageId(image.id) + ) + ); + } + return metadata; + }), tables: p.tables, header: p.header, footer: p.footer, @@ -112,8 +187,11 @@ export let extractDocumentTool = SlateTool.create(spec, { model: result.model, pages, pagesProcessed: result.usage_info?.pages_processed || pages.length, - documentSizeBytes: result.usage_info?.doc_size_bytes + documentSizeBytes: result.usage_info?.doc_size_bytes, + documentAnnotation: result.document_annotation, + imageAttachmentCount: attachments.length }, + attachments, message: `Extracted ${pages.length} page(s) from document using **${result.model}**.` }; }) diff --git a/integrations/mistral-ai/vitest.config.ts b/integrations/mistral-ai/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/mistral-ai/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/mixpanel/README.md b/integrations/mixpanel/README.md index 749f773259..152b6170da 100644 --- a/integrations/mixpanel/README.md +++ b/integrations/mixpanel/README.md @@ -1,12 +1,12 @@ # Mixpanel -Track, import, and query product analytics data. Send events and manage user and group profiles. Query analytics reports including funnels, retention, segmentation, and cohorts. Export raw event data and configure data pipelines to external warehouses. Import data from warehouses into Mixpanel. Manage lookup tables, data schemas, and annotations. Submit GDPR data retrieval and deletion requests. Evaluate feature flags for users. Sync cohort membership changes via webhooks. +Track, import, and query product analytics data. Send events, manage user and group profiles, query analytics reports including funnels, retention, segmentation, cohorts, event counts, and event property breakdowns, export raw event data as attachments, manage annotations, manage identities, and sync cohort membership changes via webhooks. ## Tools ### Export Raw Events -Export raw event data from Mixpanel for a specified date range. Returns individual event records with all properties. Useful for feeding data into external systems or performing custom analysis. +Export raw event data from Mixpanel for a specified date range as a JSONL attachment. Useful for feeding data into external systems or performing custom analysis. ### Get Activity Feed @@ -18,12 +18,20 @@ Get today's most popular events in the Mixpanel project ranked by volume. Useful ### Import Events -Send a batch of events to Mixpanel. Supports historical events older than 5 days (use Track Events for recent real-time events). Each event requires an event name, a distinct user ID, a Unix timestamp, and an optional insert ID for deduplication. Up to **2000 events** per request. +Send a batch of events to Mixpanel. Supports historical events older than 5 days (use Track Events for recent real-time events). Each event requires an event name, a distinct user ID, a Unix timestamp, and an insert ID for deduplication. Up to **2000 events** per request. ### List Cohorts List all saved cohorts in the Mixpanel project. Returns cohort IDs, names, descriptions, and member counts. Cohort IDs can be used to filter profiles in **Query User Profiles**. +### List Event Properties + +List the top property names observed for a Mixpanel event, ordered by event count. + +### List Event Property Values + +List the top observed values for a property on a Mixpanel event. + ### List Funnels List all saved funnels in the Mixpanel project. Returns funnel IDs and names that can be used with the **Query Funnel** tool. @@ -34,7 +42,7 @@ Create, list, or delete annotations in a Mixpanel project. Annotations mark sign ### Manage Group Profile -Create or update a group profile in Mixpanel. Groups represent entity-level analytics such as companies or accounts. Supports setting properties on a group or deleting the group profile entirely. Requires Group Analytics to be enabled on the Mixpanel project. +Create or update a group profile in Mixpanel. Groups represent entity-level analytics such as companies or accounts. Supports setting properties, setting properties only once, list-property updates, unsetting properties, or deleting the group profile entirely. Requires Group Analytics to be enabled on the Mixpanel project. ### Manage Identities @@ -48,6 +56,14 @@ Create or update a user profile in Mixpanel. Supports multiple operations: setti Query a saved funnel report in Mixpanel. Returns conversion data for each step of the funnel over a date range. Use **List Funnels** first to discover available funnel IDs. +### Query Event Counts + +Get aggregate total, unique, or average counts for one or more Mixpanel events over a date range or recent interval. + +### Query Event Property Values + +Get aggregate total, unique, or average counts for values of a specific property on a Mixpanel event. + ### Query Insights Report Query a saved Insights report in Mixpanel by its bookmark ID. Returns the computed report data as shown in the Mixpanel web app. diff --git a/integrations/mixpanel/docs/SPEC.md b/integrations/mixpanel/docs/SPEC.md index 3ab6222006..e1ee6b6dc6 100644 --- a/integrations/mixpanel/docs/SPEC.md +++ b/integrations/mixpanel/docs/SPEC.md @@ -34,6 +34,7 @@ Mixpanel supports different API base URLs depending on data residency: - **Standard:** `api.mixpanel.com` - **EU Residency:** `eu.mixpanel.com` +- **India Residency:** `in.mixpanel.com` ### Project Secret (Deprecating) @@ -73,7 +74,7 @@ Query pre-built analytics reports programmatically, including: ### Raw Data Export -Export raw event data for a specified date range. Returns individual event records with all properties. Useful for feeding data into external systems or performing custom analysis outside Mixpanel. +Export raw event data for a specified date range. The Slates tool returns the JSONL export as an attachment with metadata in the structured output. Useful for feeding data into external systems or performing custom analysis outside Mixpanel. ### Data Pipelines diff --git a/integrations/mixpanel/package.json b/integrations/mixpanel/package.json index 93912e50b9..31add9d799 100644 --- a/integrations/mixpanel/package.json +++ b/integrations/mixpanel/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/mixpanel/slate.json b/integrations/mixpanel/slate.json index 0284591815..6b66ba9707 100644 --- a/integrations/mixpanel/slate.json +++ b/integrations/mixpanel/slate.json @@ -1,17 +1,16 @@ { "name": "@metorial/mixpanel", - "description": "Track, import, and query product analytics data. Send events and manage user and group profiles. Query analytics reports including funnels, retention, segmentation, and cohorts. Export raw event data and configure data pipelines to external warehouses. Import data from warehouses into Mixpanel. Manage lookup tables, data schemas, and annotations. Submit GDPR data retrieval and deletion requests. Evaluate feature flags for users. Sync cohort membership changes via webhooks.", + "description": "Track, import, and query product analytics data. Send events, manage user and group profiles, query analytics reports including funnels, retention, segmentation, cohorts, event counts, and event property breakdowns, export raw event data as attachments, manage annotations, manage identities, and sync cohort membership changes via webhooks.", "categories": ["apis-and-http-requests"], "skills": [ "track and import events", "manage user profiles", + "manage group profiles", "query funnel reports", - "query retention and segmentation", + "query retention segmentation and event breakdowns", "export raw event data", - "configure data pipelines", - "manage lookup tables", - "submit GDPR requests", - "evaluate feature flags", + "manage annotations", + "manage identities", "sync cohort webhooks" ], "logoUrl": "https://provider-logos.metorial-cdn.com/mixpanel.svg" diff --git a/integrations/mixpanel/src/config.ts b/integrations/mixpanel/src/config.ts index 5d969003c8..c86285fc93 100644 --- a/integrations/mixpanel/src/config.ts +++ b/integrations/mixpanel/src/config.ts @@ -4,11 +4,9 @@ import { z } from 'zod'; export let config = SlateConfig.create( z.object({ dataResidency: z - .enum(['us', 'eu']) + .enum(['us', 'eu', 'in']) .default('us') - .describe( - 'Data residency region. Use "eu" if your Mixpanel project stores data in the EU.' - ), + .describe('Data residency region. Use "eu" for EU projects or "in" for India projects.'), projectId: z.string().describe('Mixpanel project ID, found in Project Settings.') }) ); diff --git a/integrations/mixpanel/src/index.ts b/integrations/mixpanel/src/index.ts index 497817c525..ea65890b9d 100644 --- a/integrations/mixpanel/src/index.ts +++ b/integrations/mixpanel/src/index.ts @@ -6,11 +6,15 @@ import { getTopEvents, importEvents, listCohorts, + listEventProperties, + listEventPropertyValues, listFunnels, manageAnnotations, manageGroupProfile, manageIdentities, manageUserProfile, + queryEventCounts, + queryEventPropertyValues, queryFunnel, queryInsights, queryProfiles, @@ -37,6 +41,10 @@ export let provider = Slate.create({ manageIdentities, getActivityFeed, getTopEvents, + queryEventCounts, + queryEventPropertyValues, + listEventProperties, + listEventPropertyValues, listCohorts, queryInsights ], diff --git a/integrations/mixpanel/src/lib/client.ts b/integrations/mixpanel/src/lib/client.ts index e7e330ecab..b0b8861f34 100644 --- a/integrations/mixpanel/src/lib/client.ts +++ b/integrations/mixpanel/src/lib/client.ts @@ -1,29 +1,52 @@ import { createAxios } from 'slates'; +import { mixpanelApiError, mixpanelServiceError } from './errors'; + +export type MixpanelDataResidency = 'us' | 'eu' | 'in'; export interface MixpanelClientConfig { serviceAccountUsername?: string; serviceAccountSecret?: string; projectToken?: string; projectId: string; - dataResidency: 'us' | 'eu'; + dataResidency: MixpanelDataResidency; } -let getIngestionBaseUrl = (residency: 'us' | 'eu'): string => { - return residency === 'eu' ? 'https://api-eu.mixpanel.com' : 'https://api.mixpanel.com'; +let getIngestionBaseUrl = (residency: MixpanelDataResidency): string => { + if (residency === 'eu') return 'https://api-eu.mixpanel.com'; + if (residency === 'in') return 'https://api-in.mixpanel.com'; + return 'https://api.mixpanel.com'; +}; + +let getQueryBaseUrl = (residency: MixpanelDataResidency): string => { + if (residency === 'eu') return 'https://eu.mixpanel.com/api'; + if (residency === 'in') return 'https://in.mixpanel.com/api'; + return 'https://mixpanel.com/api'; }; -let getQueryBaseUrl = (residency: 'us' | 'eu'): string => { - return residency === 'eu' ? 'https://eu.mixpanel.com/api' : 'https://mixpanel.com/api'; +let getExportBaseUrl = (residency: MixpanelDataResidency): string => { + if (residency === 'eu') return 'https://data-eu.mixpanel.com'; + if (residency === 'in') return 'https://data-in.mixpanel.com'; + return 'https://data.mixpanel.com'; }; -let getExportBaseUrl = (residency: 'us' | 'eu'): string => { - return residency === 'eu' ? 'https://data-eu.mixpanel.com' : 'https://data.mixpanel.com'; +let getAppBaseUrl = (residency: MixpanelDataResidency): string => { + if (residency === 'eu') return 'https://eu.mixpanel.com/api/app'; + if (residency === 'in') return 'https://in.mixpanel.com/api/app'; + return 'https://mixpanel.com/api/app'; }; -let getAppBaseUrl = (residency: 'us' | 'eu'): string => { - return residency === 'eu' - ? 'https://eu.mixpanel.com/api/app' - : 'https://mixpanel.com/api/app'; +type IngestionResponse = { + status?: number | string; + error?: string; + code?: number; +}; + +let isAccepted = (data: unknown) => { + if (data === 1 || data === '1') return true; + if (typeof data !== 'object' || data === null) return false; + + let response = data as IngestionResponse; + return response.status === 1 || response.status === 'OK' || response.code === 200; }; export class MixpanelClient { @@ -62,6 +85,28 @@ export class MixpanelClient { }); } + private async request(operation: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw mixpanelApiError(error, operation); + } + } + + private ensureAccepted(data: unknown, operation: string) { + if (isAccepted(data)) { + return; + } + + let response = data as IngestionResponse | undefined; + let reason = + response && typeof response === 'object' + ? (response.error ?? response.status ?? 'not accepted') + : 'not accepted'; + + throw mixpanelServiceError(`Mixpanel ${operation} failed: ${String(reason)}`); + } + // ===================== // Ingestion: Import Events // ===================== @@ -81,10 +126,13 @@ export class MixpanelClient { message: string; }>; }> { - let response = await this.ingestionAxios.post('/import', events, { - params: { strict: '1', project_id: this.config.projectId }, - headers: { 'Content-Type': 'application/json' } - }); + let response = await this.request('import events', () => + this.ingestionAxios.post('/import', events, { + params: { strict: '1', project_id: this.config.projectId }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'import events'); return { code: response.data.code, numRecordsImported: response.data.num_records_imported, @@ -107,11 +155,14 @@ export class MixpanelClient { properties: Record; }> ): Promise<{ success: boolean }> { - let response = await this.ingestionAxios.post('/track', events, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('track events', () => + this.ingestionAxios.post('/track', events, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'track events'); + return { success: true }; } // ===================== @@ -128,11 +179,14 @@ export class MixpanelClient { $set: properties } ]; - let response = await this.ingestionAxios.post('/engage#profile-set', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('set user properties', () => + this.ingestionAxios.post('/engage#profile-set', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'set user properties'); + return { success: true }; } async setUserPropertiesOnce( @@ -146,11 +200,14 @@ export class MixpanelClient { $set_once: properties } ]; - let response = await this.ingestionAxios.post('/engage#profile-set-once', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('set user properties once', () => + this.ingestionAxios.post('/engage#profile-set-once', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'set user properties once'); + return { success: true }; } async incrementUserProperties( @@ -164,11 +221,14 @@ export class MixpanelClient { $add: properties } ]; - let response = await this.ingestionAxios.post('/engage#profile-numerical-add', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('increment user properties', () => + this.ingestionAxios.post('/engage#profile-numerical-add', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'increment user properties'); + return { success: true }; } async appendToUserListProperty( @@ -182,11 +242,14 @@ export class MixpanelClient { $append: properties } ]; - let response = await this.ingestionAxios.post('/engage#profile-list-append', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('append to user list property', () => + this.ingestionAxios.post('/engage#profile-list-append', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'append to user list property'); + return { success: true }; } async removeFromUserListProperty( @@ -200,11 +263,14 @@ export class MixpanelClient { $remove: properties } ]; - let response = await this.ingestionAxios.post('/engage#profile-list-remove', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('remove from user list property', () => + this.ingestionAxios.post('/engage#profile-list-remove', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'remove from user list property'); + return { success: true }; } async unionToUserListProperty( @@ -218,11 +284,14 @@ export class MixpanelClient { $union: properties } ]; - let response = await this.ingestionAxios.post('/engage#profile-union', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('union to user list property', () => + this.ingestionAxios.post('/engage#profile-union', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'union to user list property'); + return { success: true }; } async deleteUserProperties( @@ -236,11 +305,14 @@ export class MixpanelClient { $unset: propertyNames } ]; - let response = await this.ingestionAxios.post('/engage#profile-unset', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('delete user properties', () => + this.ingestionAxios.post('/engage#profile-unset', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'delete user properties'); + return { success: true }; } async deleteUserProfile(distinctId: string): Promise<{ success: boolean }> { @@ -251,11 +323,14 @@ export class MixpanelClient { $delete: '' } ]; - let response = await this.ingestionAxios.post('/engage#profile-delete', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('delete user profile', () => + this.ingestionAxios.post('/engage#profile-delete', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'delete user profile'); + return { success: true }; } // ===================== @@ -274,11 +349,106 @@ export class MixpanelClient { $set: properties } ]; - let response = await this.ingestionAxios.post('/groups#group-set', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('set group properties', () => + this.ingestionAxios.post('/groups#group-set', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'set group properties'); + return { success: true }; + } + + async setGroupPropertiesOnce( + groupKey: string, + groupId: string, + properties: Record + ): Promise<{ success: boolean }> { + let payload = [ + { + $token: this.config.projectToken, + $group_key: groupKey, + $group_id: groupId, + $set_once: properties + } + ]; + let response = await this.request('set group properties once', () => + this.ingestionAxios.post('/groups#group-set-once', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'set group properties once'); + return { success: true }; + } + + async removeFromGroupListProperty( + groupKey: string, + groupId: string, + properties: Record + ): Promise<{ success: boolean }> { + let payload = [ + { + $token: this.config.projectToken, + $group_key: groupKey, + $group_id: groupId, + $remove: properties + } + ]; + let response = await this.request('remove from group list property', () => + this.ingestionAxios.post('/groups#group-remove-from-list', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'remove from group list property'); + return { success: true }; + } + + async unionToGroupListProperty( + groupKey: string, + groupId: string, + properties: Record + ): Promise<{ success: boolean }> { + let payload = [ + { + $token: this.config.projectToken, + $group_key: groupKey, + $group_id: groupId, + $union: properties + } + ]; + let response = await this.request('union to group list property', () => + this.ingestionAxios.post('/groups#group-union', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'union to group list property'); + return { success: true }; + } + + async deleteGroupProperties( + groupKey: string, + groupId: string, + propertyNames: string[] + ): Promise<{ success: boolean }> { + let payload = [ + { + $token: this.config.projectToken, + $group_key: groupKey, + $group_id: groupId, + $unset: propertyNames + } + ]; + let response = await this.request('delete group properties', () => + this.ingestionAxios.post('/groups#group-unset', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'delete group properties'); + return { success: true }; } async deleteGroupProfile(groupKey: string, groupId: string): Promise<{ success: boolean }> { @@ -290,11 +460,14 @@ export class MixpanelClient { $delete: '' } ]; - let response = await this.ingestionAxios.post('/groups#group-delete', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('delete group profile', () => + this.ingestionAxios.post('/groups#group-delete', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'delete group profile'); + return { success: true }; } // ===================== @@ -314,21 +487,23 @@ export class MixpanelClient { series: string[]; values: Record>; }> { - let response = await this.queryAxios.get('/query/segmentation', { - params: { - project_id: this.config.projectId, - event: params.event, - from_date: params.fromDate, - to_date: params.toDate, - on: params.on, - unit: params.unit, - where: params.where, - limit: params.limit, - type: params.type - } - }); + let response = await this.request('query segmentation', () => + this.queryAxios.get('/query/segmentation', { + params: { + project_id: this.config.projectId, + event: params.event, + from_date: params.fromDate, + to_date: params.toDate, + on: params.on, + unit: params.unit, + where: params.where, + limit: params.limit, + type: params.type + } + }) + ); return { - legendSize: response.data.data?.legend_size ?? 0, + legendSize: response.data?.legend_size ?? 0, series: response.data.data?.series ?? [], values: response.data.data?.values ?? {} }; @@ -348,20 +523,22 @@ export class MixpanelClient { where?: string; limit?: number; }): Promise<{ meta: Record; data: Record }> { - let response = await this.queryAxios.get('/query/funnels', { - params: { - project_id: this.config.projectId, - funnel_id: params.funnelId, - from_date: params.fromDate, - to_date: params.toDate, - length: params.length, - length_unit: params.lengthUnit, - unit: params.unit, - on: params.on, - where: params.where, - limit: params.limit - } - }); + let response = await this.request('query funnel', () => + this.queryAxios.get('/query/funnels', { + params: { + project_id: this.config.projectId, + funnel_id: params.funnelId, + from_date: params.fromDate, + to_date: params.toDate, + length: params.length, + length_unit: params.lengthUnit, + unit: params.unit, + on: params.on, + where: params.where, + limit: params.limit + } + }) + ); return { meta: response.data.meta ?? {}, data: response.data.data ?? {} @@ -369,9 +546,11 @@ export class MixpanelClient { } async listFunnels(): Promise> { - let response = await this.queryAxios.get('/query/funnels/list', { - params: { project_id: this.config.projectId } - }); + let response = await this.request('list funnels', () => + this.queryAxios.get('/query/funnels/list', { + params: { project_id: this.config.projectId } + }) + ); return (response.data ?? []).map((f: any) => ({ funnelId: f.funnel_id, name: f.name @@ -395,23 +574,25 @@ export class MixpanelClient { on?: string; limit?: number; }): Promise> { - let response = await this.queryAxios.get('/query/retention', { - params: { - project_id: this.config.projectId, - from_date: params.fromDate, - to_date: params.toDate, - retention_type: params.retentionType, - born_event: params.bornEvent, - event: params.event, - born_where: params.bornWhere, - where: params.where, - interval: params.interval, - interval_count: params.intervalCount, - unit: params.unit, - on: params.on, - limit: params.limit - } - }); + let response = await this.request('query retention', () => + this.queryAxios.get('/query/retention', { + params: { + project_id: this.config.projectId, + from_date: params.fromDate, + to_date: params.toDate, + retention_type: params.retentionType, + born_event: params.bornEvent, + event: params.event, + born_where: params.bornWhere, + where: params.where, + interval: params.interval, + interval_count: params.intervalCount, + unit: params.unit, + on: params.on, + limit: params.limit + } + }) + ); return response.data ?? {}; } @@ -419,12 +600,14 @@ export class MixpanelClient { // Query: Insights // ===================== async queryInsights(bookmarkId: number): Promise> { - let response = await this.queryAxios.get('/query/insights', { - params: { - project_id: this.config.projectId, - bookmark_id: bookmarkId - } - }); + let response = await this.request('query insights', () => + this.queryAxios.get('/query/insights', { + params: { + project_id: this.config.projectId, + bookmark_id: bookmarkId + } + }) + ); return response.data ?? {}; } @@ -440,9 +623,11 @@ export class MixpanelClient { count: number; }> > { - let response = await this.queryAxios.post('/query/cohorts/list', null, { - params: { project_id: this.config.projectId } - }); + let response = await this.request('list cohorts', () => + this.queryAxios.post('/query/cohorts/list', null, { + params: { project_id: this.config.projectId } + }) + ); return (response.data ?? []).map((c: any) => ({ cohortId: c.id, name: c.name, @@ -479,13 +664,11 @@ export class MixpanelClient { if (params.filterByCohort !== undefined) bodyParams.filter_by_cohort = JSON.stringify({ id: params.filterByCohort }); - let response = await this.queryAxios.post( - '/query/engage', - new URLSearchParams(bodyParams).toString(), - { + let response = await this.request('query profiles', () => + this.queryAxios.post('/query/engage', new URLSearchParams(bodyParams).toString(), { params: { project_id: this.config.projectId }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - } + }) ); return { page: response.data.page ?? 0, @@ -507,14 +690,16 @@ export class MixpanelClient { fromDate: string; toDate: string; }): Promise }>> { - let response = await this.queryAxios.get('/query/stream/query', { - params: { - project_id: this.config.projectId, - distinct_ids: JSON.stringify(params.distinctIds), - from_date: params.fromDate, - to_date: params.toDate - } - }); + let response = await this.request('query activity feed', () => + this.queryAxios.get('/query/stream/query', { + params: { + project_id: this.config.projectId, + distinct_ids: JSON.stringify(params.distinctIds), + from_date: params.fromDate, + to_date: params.toDate + } + }) + ); let events = response.data?.results?.events ?? []; return events.map((e: any) => ({ event: e.event, @@ -528,12 +713,14 @@ export class MixpanelClient { async getTopEvents(params: { limit?: number; }): Promise> { - let response = await this.queryAxios.get('/query/events/top', { - params: { - project_id: this.config.projectId, - limit: params.limit ?? 10 - } - }); + let response = await this.request("query today's top events", () => + this.queryAxios.get('/query/events/top', { + params: { + project_id: this.config.projectId, + limit: params.limit ?? 10 + } + }) + ); let events = response.data?.events ?? {}; return Object.entries(events).map(([event, amount]) => ({ event, @@ -544,19 +731,114 @@ export class MixpanelClient { async getEventCounts(params: { eventNames: string[]; type: 'general' | 'unique' | 'average'; - unit: 'minute' | 'hour' | 'day' | 'month'; + unit: 'minute' | 'hour' | 'day' | 'week' | 'month'; interval?: number; - }): Promise> { - let response = await this.queryAxios.get('/query/events', { - params: { - project_id: this.config.projectId, - event: JSON.stringify(params.eventNames), - type: params.type, - unit: params.unit, - interval: params.interval - } - }); - return response.data?.data ?? {}; + fromDate?: string; + toDate?: string; + }): Promise<{ + legendSize: number; + series: string[]; + values: Record>; + }> { + let response = await this.request('query event counts', () => + this.queryAxios.get('/query/events', { + params: { + project_id: this.config.projectId, + event: JSON.stringify(params.eventNames), + type: params.type, + unit: params.unit, + interval: params.interval, + from_date: params.fromDate, + to_date: params.toDate + } + }) + ); + return { + legendSize: response.data?.legend_size ?? 0, + series: response.data?.data?.series ?? [], + values: response.data?.data?.values ?? {} + }; + } + + async queryEventPropertyValues(params: { + eventName: string; + propertyName: string; + values?: string[]; + type: 'general' | 'unique' | 'average'; + unit: 'minute' | 'hour' | 'day' | 'week' | 'month'; + interval?: number; + fromDate?: string; + toDate?: string; + limit?: number; + }): Promise<{ + legendSize: number; + series: string[]; + values: Record>; + }> { + let response = await this.request('query event property values', () => + this.queryAxios.get('/query/events/properties', { + params: { + project_id: this.config.projectId, + event: params.eventName, + name: params.propertyName, + values: params.values ? JSON.stringify(params.values) : undefined, + type: params.type, + unit: params.unit, + interval: params.interval, + from_date: params.fromDate, + to_date: params.toDate, + limit: params.limit + } + }) + ); + return { + legendSize: response.data?.legend_size ?? 0, + series: response.data?.data?.series ?? [], + values: response.data?.data?.values ?? {} + }; + } + + async listTopEventProperties(params: { + eventName: string; + limit?: number; + }): Promise> { + let response = await this.request('list top event properties', () => + this.queryAxios.get('/query/events/properties/top', { + params: { + project_id: this.config.projectId, + event: params.eventName, + limit: params.limit + } + }) + ); + + return Object.entries(response.data ?? {}).map(([propertyName, value]) => ({ + propertyName, + count: + typeof value === 'object' && + value !== null && + typeof (value as { count?: unknown }).count === 'number' + ? (value as { count: number }).count + : 0 + })); + } + + async listTopEventPropertyValues(params: { + eventName: string; + propertyName: string; + limit?: number; + }): Promise { + let response = await this.request('list top event property values', () => + this.queryAxios.get('/query/events/properties/values', { + params: { + project_id: this.config.projectId, + event: params.eventName, + name: params.propertyName, + limit: params.limit + } + }) + ); + return Array.isArray(response.data) ? response.data.map(value => String(value)) : []; } // ===================== @@ -568,32 +850,46 @@ export class MixpanelClient { event?: string; where?: string; limit?: number; - }): Promise }>> { - let response = await this.exportAxios.get('/api/2.0/export', { - params: { - project_id: this.config.projectId, - from_date: params.fromDate, - to_date: params.toDate, - event: params.event ? JSON.stringify([params.event]) : undefined, - where: params.where, - limit: params.limit - }, - headers: { accept: 'text/plain' }, - responseType: 'text' - }); + }): Promise<{ content: string; count: number; contentType: string; byteLength: number }> { + let response = await this.request('export raw events', () => + this.exportAxios.get('/api/2.0/export', { + params: { + project_id: this.config.projectId, + from_date: params.fromDate, + to_date: params.toDate, + event: params.event ? JSON.stringify([params.event]) : undefined, + where: params.where, + limit: params.limit + }, + headers: { accept: 'text/plain' }, + responseType: 'text' + }) + ); let text = typeof response.data === 'string' ? response.data : String(response.data); let lines = text .trim() .split('\n') .filter((l: string) => l.trim()); - return lines.map((line: string) => { - let parsed = JSON.parse(line); - return { - event: parsed.event, - properties: parsed.properties ?? {} - }; - }); + + try { + for (let line of lines) { + JSON.parse(line); + } + } catch (error) { + let serviceError = mixpanelServiceError('Mixpanel raw export returned invalid JSONL.'); + if (error instanceof Error) { + serviceError.setParent(error); + } + throw serviceError; + } + + return { + content: text, + count: lines.length, + contentType: 'application/jsonl', + byteLength: Buffer.byteLength(text, 'utf8') + }; } // ===================== @@ -607,12 +903,14 @@ export class MixpanelClient { tags: Array<{ tagId: number; name: string }>; }> > { - let response = await this.appAxios.get(`/projects/${this.config.projectId}/annotations`, { - params: { - fromDate: params?.fromDate, - toDate: params?.toDate - } - }); + let response = await this.request('list annotations', () => + this.appAxios.get(`/projects/${this.config.projectId}/annotations`, { + params: { + fromDate: params?.fromDate, + toDate: params?.toDate + } + }) + ); return (response.data?.results ?? []).map((a: any) => ({ annotationId: a.id, date: a.date, @@ -625,15 +923,17 @@ export class MixpanelClient { date: string; description: string; }): Promise<{ annotationId: number; date: string; description: string }> { - let response = await this.appAxios.post( - `/projects/${this.config.projectId}/annotations`, - { - date: params.date, - description: params.description - }, - { - headers: { 'Content-Type': 'application/json' } - } + let response = await this.request('create annotation', () => + this.appAxios.post( + `/projects/${this.config.projectId}/annotations`, + { + date: params.date, + description: params.description + }, + { + headers: { 'Content-Type': 'application/json' } + } + ) ); return { annotationId: response.data?.id ?? response.data?.results?.id, @@ -644,8 +944,8 @@ export class MixpanelClient { } async deleteAnnotation(annotationId: number): Promise { - await this.appAxios.delete( - `/projects/${this.config.projectId}/annotations/${annotationId}` + await this.request('delete annotation', () => + this.appAxios.delete(`/projects/${this.config.projectId}/annotations/${annotationId}`) ); } @@ -663,11 +963,14 @@ export class MixpanelClient { } } ]; - let response = await this.ingestionAxios.post('/track#create-identity', payload, { - params: { verbose: '1' }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.status === 1 || response.data === 1 }; + let response = await this.request('create identity', () => + this.ingestionAxios.post('/track#create-identity', payload, { + params: { verbose: '1' }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'create identity'); + return { success: true }; } async mergeIdentities( @@ -683,10 +986,13 @@ export class MixpanelClient { } } ]; - let response = await this.ingestionAxios.post('/import', payload, { - params: { strict: '1', project_id: this.config.projectId }, - headers: { 'Content-Type': 'application/json' } - }); - return { success: response.data.code === 200 }; + let response = await this.request('merge identities', () => + this.ingestionAxios.post('/import', payload, { + params: { strict: '1', project_id: this.config.projectId }, + headers: { 'Content-Type': 'application/json' } + }) + ); + this.ensureAccepted(response.data, 'merge identities'); + return { success: true }; } } diff --git a/integrations/mixpanel/src/lib/errors.ts b/integrations/mixpanel/src/lib/errors.ts new file mode 100644 index 0000000000..231d9fb073 --- /dev/null +++ b/integrations/mixpanel/src/lib/errors.ts @@ -0,0 +1,94 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addUnique = (values: string[], value: unknown) => { + let text = typeof value === 'string' ? value.trim() : ''; + if (text && !values.includes(text)) { + values.push(text); + } +}; + +let getErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return response?.status ?? error.status; +}; + +let getErrorStatusText = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return response?.statusText; +}; + +let extractMixpanelMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + addUnique(details, data.error); + addUnique(details, data.status); + addUnique(details, data.message); + + let failedRecords = data.failed_records; + if (Array.isArray(failedRecords)) { + for (let record of failedRecords.slice(0, 3)) { + if (isRecord(record)) { + addUnique(details, record.message); + } + } + } + } else if (typeof data === 'string' && data.trim()) { + addUnique(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let mixpanelServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let mixpanelApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let status = getErrorStatus(error); + let statusText = getErrorStatusText(error); + let statusLabel = + status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : ''; + let serviceError = mixpanelServiceError( + `Mixpanel API ${operation} failed: ${statusLabel}${extractMixpanelMessage(error)}` + ); + + serviceError.data.reason = 'mixpanel_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/mixpanel/src/lib/helpers.ts b/integrations/mixpanel/src/lib/helpers.ts index d98d34049a..34cdb7777f 100644 --- a/integrations/mixpanel/src/lib/helpers.ts +++ b/integrations/mixpanel/src/lib/helpers.ts @@ -1,18 +1,72 @@ import { MixpanelClient } from './client'; +import { mixpanelServiceError } from './errors'; -export let createClientFromContext = (ctx: { +type MixpanelContext = { config: { dataResidency: string; projectId: string }; auth: { serviceAccountUsername?: string; serviceAccountSecret?: string; projectToken?: string; }; -}): MixpanelClient => { +}; + +let hasText = (value: string | undefined) => + typeof value === 'string' && value.trim().length > 0; + +export let requireProjectToken = (ctx: MixpanelContext) => { + if (!hasText(ctx.auth.projectToken)) { + throw mixpanelServiceError( + 'This Mixpanel operation requires a project token. Authenticate with Project Token auth or include a project token in Service Account auth.' + ); + } +}; + +export let requireServiceAccount = (ctx: MixpanelContext) => { + if (!hasText(ctx.auth.serviceAccountUsername) || !hasText(ctx.auth.serviceAccountSecret)) { + throw mixpanelServiceError( + 'This Mixpanel operation requires service account username and secret credentials.' + ); + } +}; + +export let requireNonEmptyRecord = ( + value: Record | undefined, + fieldName: string +) => { + if (!value || Object.keys(value).length === 0) { + throw mixpanelServiceError(`${fieldName} must include at least one property.`); + } +}; + +export let requireNonEmptyStringArray = (value: string[] | undefined, fieldName: string) => { + if (!value || value.length === 0) { + throw mixpanelServiceError(`${fieldName} must include at least one value.`); + } +}; + +export let requireDateRangeOrInterval = (params: { + fromDate?: string; + toDate?: string; + interval?: number; +}) => { + let hasDateRange = hasText(params.fromDate) || hasText(params.toDate); + let hasCompleteDateRange = hasText(params.fromDate) && hasText(params.toDate); + + if (hasDateRange && !hasCompleteDateRange) { + throw mixpanelServiceError('Provide both fromDate and toDate, or omit both.'); + } + + if (!hasCompleteDateRange && params.interval === undefined) { + throw mixpanelServiceError('Provide either fromDate and toDate, or interval.'); + } +}; + +export let createClientFromContext = (ctx: MixpanelContext): MixpanelClient => { return new MixpanelClient({ serviceAccountUsername: ctx.auth.serviceAccountUsername, serviceAccountSecret: ctx.auth.serviceAccountSecret, projectToken: ctx.auth.projectToken, projectId: ctx.config.projectId, - dataResidency: ctx.config.dataResidency as 'us' | 'eu' + dataResidency: ctx.config.dataResidency as 'us' | 'eu' | 'in' }); }; diff --git a/integrations/mixpanel/src/tools.schema.test.ts b/integrations/mixpanel/src/tools.schema.test.ts new file mode 100644 index 0000000000..acb66473ba --- /dev/null +++ b/integrations/mixpanel/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Mixpanel tool input schemas', provider.actions); diff --git a/integrations/mixpanel/src/tools/export-events.ts b/integrations/mixpanel/src/tools/export-events.ts index 9689ccab83..d6962c82b7 100644 --- a/integrations/mixpanel/src/tools/export-events.ts +++ b/integrations/mixpanel/src/tools/export-events.ts @@ -1,12 +1,12 @@ -import { SlateTool } from 'slates'; +import { createTextAttachment, SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let exportEvents = SlateTool.create(spec, { name: 'Export Raw Events', key: 'export_events', - description: `Export raw event data from Mixpanel for a specified date range. Returns individual event records with all properties. + description: `Export raw event data from Mixpanel for a specified date range as a JSONL attachment. Useful for feeding data into external systems or performing custom analysis.`, constraints: [ 'Rate limit: 60 queries per hour, 3 queries per second, max 100 concurrent queries.', @@ -27,23 +27,18 @@ Useful for feeding data into external systems or performing custom analysis.`, ) .output( z.object({ - events: z - .array( - z.object({ - eventName: z.string().describe('Event name'), - properties: z - .record(z.string(), z.unknown()) - .describe('Event properties including distinct_id and time') - }) - ) - .describe('Exported events'), - count: z.number().describe('Number of events exported') + count: z.number().describe('Number of events exported'), + contentType: z.string().describe('MIME type of the exported attachment'), + byteLength: z.number().describe('Size of the exported attachment in bytes'), + attachmentCount: z.number().describe('Number of attachments returned') }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); - let events = await client.exportRawEvents({ + let result = await client.exportRawEvents({ fromDate: ctx.input.fromDate, toDate: ctx.input.toDate, event: ctx.input.eventName, @@ -53,10 +48,13 @@ Useful for feeding data into external systems or performing custom analysis.`, return { output: { - events: events.map(e => ({ eventName: e.event, properties: e.properties })), - count: events.length + count: result.count, + contentType: result.contentType, + byteLength: result.byteLength, + attachmentCount: 1 }, - message: `Exported **${events.length}** event(s) from ${ctx.input.fromDate} to ${ctx.input.toDate}.` + attachments: [createTextAttachment(result.content, result.contentType)], + message: `Exported **${result.count}** event(s) from ${ctx.input.fromDate} to ${ctx.input.toDate}.` }; }) .build(); diff --git a/integrations/mixpanel/src/tools/get-activity-feed.ts b/integrations/mixpanel/src/tools/get-activity-feed.ts index 6715383295..eddae8ccb8 100644 --- a/integrations/mixpanel/src/tools/get-activity-feed.ts +++ b/integrations/mixpanel/src/tools/get-activity-feed.ts @@ -1,6 +1,10 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { + createClientFromContext, + requireNonEmptyStringArray, + requireServiceAccount +} from '../lib/helpers'; import { spec } from '../spec'; export let getActivityFeed = SlateTool.create(spec, { @@ -33,6 +37,9 @@ export let getActivityFeed = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + requireNonEmptyStringArray(ctx.input.distinctIds, 'distinctIds'); + let client = createClientFromContext(ctx); let events = await client.queryActivityFeed({ diff --git a/integrations/mixpanel/src/tools/get-top-events.ts b/integrations/mixpanel/src/tools/get-top-events.ts index e1e05a0074..f415a01e83 100644 --- a/integrations/mixpanel/src/tools/get-top-events.ts +++ b/integrations/mixpanel/src/tools/get-top-events.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let getTopEvents = SlateTool.create(spec, { @@ -29,6 +29,8 @@ export let getTopEvents = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); let events = await client.getTopEvents({ limit: ctx.input.limit }); diff --git a/integrations/mixpanel/src/tools/import-events.ts b/integrations/mixpanel/src/tools/import-events.ts index 4ae87fe6c3..e106ea6f3c 100644 --- a/integrations/mixpanel/src/tools/import-events.ts +++ b/integrations/mixpanel/src/tools/import-events.ts @@ -1,6 +1,10 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { + createClientFromContext, + requireNonEmptyStringArray, + requireServiceAccount +} from '../lib/helpers'; import { spec } from '../spec'; export let importEvents = SlateTool.create(spec, { @@ -31,7 +35,7 @@ Up to **2000 events** per request.`, eventName: z.string().describe('Name of the event'), distinctId: z.string().describe('Unique identifier for the user'), time: z.number().describe('Unix timestamp in seconds when the event occurred'), - insertId: z.string().optional().describe('Unique ID for deduplication'), + insertId: z.string().describe('Unique ID for deduplication'), properties: z .record(z.string(), z.unknown()) .optional() @@ -59,6 +63,12 @@ Up to **2000 events** per request.`, }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + requireNonEmptyStringArray( + ctx.input.events.map(event => event.eventName), + 'events' + ); + let client = createClientFromContext(ctx); let events = ctx.input.events.map(e => ({ diff --git a/integrations/mixpanel/src/tools/index.ts b/integrations/mixpanel/src/tools/index.ts index 39fd1ac22a..095b1d1781 100644 --- a/integrations/mixpanel/src/tools/index.ts +++ b/integrations/mixpanel/src/tools/index.ts @@ -3,11 +3,15 @@ export { getActivityFeed } from './get-activity-feed'; export { getTopEvents } from './get-top-events'; export { importEvents } from './import-events'; export { listCohorts } from './list-cohorts'; +export { listEventProperties } from './list-event-properties'; +export { listEventPropertyValues } from './list-event-property-values'; export { listFunnels } from './list-funnels'; export { manageAnnotations } from './manage-annotations'; export { manageGroupProfile } from './manage-group-profile'; export { manageIdentities } from './manage-identities'; export { manageUserProfile } from './manage-user-profile'; +export { queryEventCounts } from './query-event-counts'; +export { queryEventPropertyValues } from './query-event-property-values'; export { queryFunnel } from './query-funnel'; export { queryInsights } from './query-insights'; export { queryProfiles } from './query-profiles'; diff --git a/integrations/mixpanel/src/tools/list-cohorts.ts b/integrations/mixpanel/src/tools/list-cohorts.ts index 1438d8af0d..9c2683b437 100644 --- a/integrations/mixpanel/src/tools/list-cohorts.ts +++ b/integrations/mixpanel/src/tools/list-cohorts.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let listCohorts = SlateTool.create(spec, { @@ -28,6 +28,8 @@ export let listCohorts = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); let cohorts = await client.listCohorts(); diff --git a/integrations/mixpanel/src/tools/list-event-properties.ts b/integrations/mixpanel/src/tools/list-event-properties.ts new file mode 100644 index 0000000000..b69ec31f2f --- /dev/null +++ b/integrations/mixpanel/src/tools/list-event-properties.ts @@ -0,0 +1,49 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listEventProperties = SlateTool.create(spec, { + name: 'List Event Properties', + key: 'list_event_properties', + description: `List the top property names observed for a Mixpanel event, ordered by event count.`, + constraints: ['Rate limit: 60 queries per hour, max 5 concurrent queries.'], + tags: { + readOnly: true + } +}) + .input( + z.object({ + eventName: z.string().describe('Event name to inspect'), + limit: z.number().optional().describe('Maximum number of property names to return') + }) + ) + .output( + z.object({ + properties: z + .array( + z.object({ + propertyName: z.string().describe('Event property name'), + count: z.number().describe('Number of events with this property') + }) + ) + .describe('Top event properties') + }) + ) + .handleInvocation(async ctx => { + requireServiceAccount(ctx); + + let client = createClientFromContext(ctx); + let properties = await client.listTopEventProperties({ + eventName: ctx.input.eventName, + limit: ctx.input.limit + }); + + return { + output: { properties }, + message: `Found **${properties.length}** top propert${ + properties.length === 1 ? 'y' : 'ies' + } for event **${ctx.input.eventName}**.` + }; + }) + .build(); diff --git a/integrations/mixpanel/src/tools/list-event-property-values.ts b/integrations/mixpanel/src/tools/list-event-property-values.ts new file mode 100644 index 0000000000..a7ffe0dcac --- /dev/null +++ b/integrations/mixpanel/src/tools/list-event-property-values.ts @@ -0,0 +1,42 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; +import { spec } from '../spec'; + +export let listEventPropertyValues = SlateTool.create(spec, { + name: 'List Event Property Values', + key: 'list_event_property_values', + description: `List the top observed values for a property on a Mixpanel event.`, + constraints: ['Rate limit: 60 queries per hour, max 5 concurrent queries.'], + tags: { + readOnly: true + } +}) + .input( + z.object({ + eventName: z.string().describe('Event name to inspect'), + propertyName: z.string().describe('Property name to inspect'), + limit: z.number().optional().describe('Maximum number of property values to return') + }) + ) + .output( + z.object({ + values: z.array(z.string()).describe('Top observed property values') + }) + ) + .handleInvocation(async ctx => { + requireServiceAccount(ctx); + + let client = createClientFromContext(ctx); + let values = await client.listTopEventPropertyValues({ + eventName: ctx.input.eventName, + propertyName: ctx.input.propertyName, + limit: ctx.input.limit + }); + + return { + output: { values }, + message: `Found **${values.length}** top value(s) for property **${ctx.input.propertyName}** on event **${ctx.input.eventName}**.` + }; + }) + .build(); diff --git a/integrations/mixpanel/src/tools/list-funnels.ts b/integrations/mixpanel/src/tools/list-funnels.ts index 0485ec9de6..71678c8754 100644 --- a/integrations/mixpanel/src/tools/list-funnels.ts +++ b/integrations/mixpanel/src/tools/list-funnels.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let listFunnels = SlateTool.create(spec, { @@ -25,6 +25,8 @@ export let listFunnels = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); let funnels = await client.listFunnels(); diff --git a/integrations/mixpanel/src/tools/manage-annotations.ts b/integrations/mixpanel/src/tools/manage-annotations.ts index 57c8937b92..132b90a139 100644 --- a/integrations/mixpanel/src/tools/manage-annotations.ts +++ b/integrations/mixpanel/src/tools/manage-annotations.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { mixpanelServiceError } from '../lib/errors'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let manageAnnotations = SlateTool.create(spec, { @@ -65,6 +66,8 @@ export let manageAnnotations = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); let { operation } = ctx.input; @@ -80,9 +83,13 @@ export let manageAnnotations = SlateTool.create(spec, { } if (operation === 'create') { + if (!ctx.input.description?.trim()) { + throw mixpanelServiceError('description is required for create operations.'); + } + let created = await client.createAnnotation({ date: ctx.input.date ?? new Date().toISOString().slice(0, 19).replace('T', ' '), - description: ctx.input.description ?? '' + description: ctx.input.description }); return { output: { createdAnnotation: created }, @@ -90,7 +97,11 @@ export let manageAnnotations = SlateTool.create(spec, { }; } - if (operation === 'delete' && ctx.input.annotationId !== undefined) { + if (operation === 'delete') { + if (ctx.input.annotationId === undefined) { + throw mixpanelServiceError('annotationId is required for delete operations.'); + } + await client.deleteAnnotation(ctx.input.annotationId); return { output: { deleted: true }, @@ -98,9 +109,6 @@ export let manageAnnotations = SlateTool.create(spec, { }; } - return { - output: {}, - message: 'No operation performed. Provide a valid operation and required parameters.' - }; + throw mixpanelServiceError(`Unsupported annotation operation: ${operation}`); }) .build(); diff --git a/integrations/mixpanel/src/tools/manage-group-profile.ts b/integrations/mixpanel/src/tools/manage-group-profile.ts index f9703b6d2c..b1f7094df0 100644 --- a/integrations/mixpanel/src/tools/manage-group-profile.ts +++ b/integrations/mixpanel/src/tools/manage-group-profile.ts @@ -1,13 +1,19 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { mixpanelServiceError } from '../lib/errors'; +import { + createClientFromContext, + requireNonEmptyRecord, + requireNonEmptyStringArray, + requireProjectToken +} from '../lib/helpers'; import { spec } from '../spec'; export let manageGroupProfile = SlateTool.create(spec, { name: 'Manage Group Profile', key: 'manage_group_profile', description: `Create or update a group profile in Mixpanel. Groups represent entity-level analytics such as companies or accounts. -Supports setting properties on a group or deleting the group profile entirely. +Supports setting properties, setting properties only once, list-property updates, unsetting properties, or deleting the group profile entirely. Requires Group Analytics to be enabled on the Mixpanel project.`, tags: { destructive: false, @@ -19,12 +25,18 @@ Requires Group Analytics to be enabled on the Mixpanel project.`, groupKey: z.string().describe('The group key (e.g., "company" or "account")'), groupId: z.string().describe('The group identifier value'), operation: z - .enum(['set', 'deleteProfile']) + .enum(['set', 'setOnce', 'remove', 'union', 'unset', 'deleteProfile']) .describe('Group profile operation to perform'), properties: z .record(z.string(), z.unknown()) .optional() - .describe('Properties to set on the group (for set operation)') + .describe( + 'Properties for set, setOnce, remove, and union operations. Union values must be arrays.' + ), + propertyNames: z + .array(z.string()) + .optional() + .describe('Property names to unset (for unset operation)') }) ) .output( @@ -33,19 +45,49 @@ Requires Group Analytics to be enabled on the Mixpanel project.`, }) ) .handleInvocation(async ctx => { + requireProjectToken(ctx); + let client = createClientFromContext(ctx); - let { groupKey, groupId, operation, properties } = ctx.input; + let { groupKey, groupId, operation, properties, propertyNames } = ctx.input; let result: { success: boolean }; switch (operation) { case 'set': + requireNonEmptyRecord(properties, 'properties'); result = await client.setGroupProperties(groupKey, groupId, properties ?? {}); break; + case 'setOnce': + requireNonEmptyRecord(properties, 'properties'); + result = await client.setGroupPropertiesOnce(groupKey, groupId, properties ?? {}); + break; + case 'remove': + requireNonEmptyRecord(properties, 'properties'); + result = await client.removeFromGroupListProperty(groupKey, groupId, properties ?? {}); + break; + case 'union': + requireNonEmptyRecord(properties, 'properties'); + for (let [name, value] of Object.entries(properties ?? {})) { + if (!Array.isArray(value)) { + throw mixpanelServiceError( + `Property "${name}" must be an array for union operations.` + ); + } + } + result = await client.unionToGroupListProperty( + groupKey, + groupId, + (properties ?? {}) as Record + ); + break; + case 'unset': + requireNonEmptyStringArray(propertyNames, 'propertyNames'); + result = await client.deleteGroupProperties(groupKey, groupId, propertyNames ?? []); + break; case 'deleteProfile': result = await client.deleteGroupProfile(groupKey, groupId); break; default: - result = { success: false }; + throw mixpanelServiceError(`Unsupported group profile operation: ${operation}`); } return { diff --git a/integrations/mixpanel/src/tools/manage-identities.ts b/integrations/mixpanel/src/tools/manage-identities.ts index bc2c4b29b5..5afed2cff9 100644 --- a/integrations/mixpanel/src/tools/manage-identities.ts +++ b/integrations/mixpanel/src/tools/manage-identities.ts @@ -1,6 +1,11 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { mixpanelServiceError } from '../lib/errors'; +import { + createClientFromContext, + requireProjectToken, + requireServiceAccount +} from '../lib/helpers'; import { spec } from '../spec'; export let manageIdentities = SlateTool.create(spec, { @@ -40,7 +45,14 @@ Use **merge** to combine two distinct IDs into one identity cluster. Merging is let client = createClientFromContext(ctx); let { operation } = ctx.input; - if (operation === 'identify' && ctx.input.identifiedId && ctx.input.anonId) { + if (operation === 'identify') { + requireProjectToken(ctx); + if (!ctx.input.identifiedId?.trim() || !ctx.input.anonId?.trim()) { + throw mixpanelServiceError( + 'identifiedId and anonId are required for identify operations.' + ); + } + let result = await client.createIdentity(ctx.input.identifiedId, ctx.input.anonId); return { output: { success: result.success }, @@ -50,7 +62,15 @@ Use **merge** to combine two distinct IDs into one identity cluster. Merging is }; } - if (operation === 'merge' && ctx.input.distinctId1 && ctx.input.distinctId2) { + if (operation === 'merge') { + requireServiceAccount(ctx); + requireProjectToken(ctx); + if (!ctx.input.distinctId1?.trim() || !ctx.input.distinctId2?.trim()) { + throw mixpanelServiceError( + 'distinctId1 and distinctId2 are required for merge operations.' + ); + } + let result = await client.mergeIdentities(ctx.input.distinctId1, ctx.input.distinctId2); return { output: { success: result.success }, @@ -60,9 +80,6 @@ Use **merge** to combine two distinct IDs into one identity cluster. Merging is }; } - return { - output: { success: false }, - message: 'Missing required parameters for the specified operation.' - }; + throw mixpanelServiceError(`Unsupported identity operation: ${operation}`); }) .build(); diff --git a/integrations/mixpanel/src/tools/manage-user-profile.ts b/integrations/mixpanel/src/tools/manage-user-profile.ts index b138926470..eb5ececcb0 100644 --- a/integrations/mixpanel/src/tools/manage-user-profile.ts +++ b/integrations/mixpanel/src/tools/manage-user-profile.ts @@ -1,6 +1,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { mixpanelServiceError } from '../lib/errors'; +import { + createClientFromContext, + requireNonEmptyRecord, + requireNonEmptyStringArray, + requireProjectToken +} from '../lib/helpers'; import { spec } from '../spec'; export let manageUserProfile = SlateTool.create(spec, { @@ -51,43 +57,66 @@ Provide the desired operation and the corresponding data.`, }) ) .handleInvocation(async ctx => { + requireProjectToken(ctx); + let client = createClientFromContext(ctx); let { distinctId, operation, properties, propertyNames } = ctx.input; let result: { success: boolean }; switch (operation) { case 'set': + requireNonEmptyRecord(properties, 'properties'); result = await client.setUserProperties(distinctId, properties ?? {}); break; case 'setOnce': + requireNonEmptyRecord(properties, 'properties'); result = await client.setUserPropertiesOnce(distinctId, properties ?? {}); break; case 'increment': + requireNonEmptyRecord(properties, 'properties'); + for (let [name, value] of Object.entries(properties ?? {})) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw mixpanelServiceError( + `Property "${name}" must be a finite number for increment operations.` + ); + } + } result = await client.incrementUserProperties( distinctId, (properties ?? {}) as Record ); break; case 'append': + requireNonEmptyRecord(properties, 'properties'); result = await client.appendToUserListProperty(distinctId, properties ?? {}); break; case 'remove': + requireNonEmptyRecord(properties, 'properties'); result = await client.removeFromUserListProperty(distinctId, properties ?? {}); break; case 'union': + requireNonEmptyRecord(properties, 'properties'); + for (let [name, value] of Object.entries(properties ?? {})) { + if (!Array.isArray(value)) { + throw mixpanelServiceError( + `Property "${name}" must be an array for union operations.` + ); + } + } result = await client.unionToUserListProperty( distinctId, (properties ?? {}) as Record ); break; case 'unset': + requireNonEmptyStringArray(propertyNames, 'propertyNames'); result = await client.deleteUserProperties(distinctId, propertyNames ?? []); break; case 'deleteProfile': result = await client.deleteUserProfile(distinctId); break; default: - result = { success: false }; + throw mixpanelServiceError(`Unsupported user profile operation: ${operation}`); } return { diff --git a/integrations/mixpanel/src/tools/query-event-counts.ts b/integrations/mixpanel/src/tools/query-event-counts.ts new file mode 100644 index 0000000000..ad0df13839 --- /dev/null +++ b/integrations/mixpanel/src/tools/query-event-counts.ts @@ -0,0 +1,74 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { + createClientFromContext, + requireDateRangeOrInterval, + requireNonEmptyStringArray, + requireServiceAccount +} from '../lib/helpers'; +import { spec } from '../spec'; + +export let queryEventCounts = SlateTool.create(spec, { + name: 'Query Event Counts', + key: 'query_event_counts', + description: `Get aggregate total, unique, or average counts for one or more Mixpanel events over a date range or recent interval.`, + constraints: ['Rate limit: 60 queries per hour, max 5 concurrent queries.'], + tags: { + readOnly: true + } +}) + .input( + z.object({ + eventNames: z.array(z.string()).describe('Event names to query'), + type: z + .enum(['general', 'unique', 'average']) + .describe('Analysis type: general, unique, or average'), + unit: z + .enum(['minute', 'hour', 'day', 'week', 'month']) + .describe('Time bucket granularity'), + interval: z + .number() + .optional() + .describe( + 'Number of recent units to return. Provide either interval or fromDate/toDate.' + ), + fromDate: z + .string() + .optional() + .describe('Start date in yyyy-mm-dd format. Provide with toDate.'), + toDate: z + .string() + .optional() + .describe('End date in yyyy-mm-dd format. Provide with fromDate.') + }) + ) + .output( + z.object({ + legendSize: z.number().describe('Number of event series in the response'), + series: z.array(z.string()).describe('Array of date strings in the time series'), + values: z + .record(z.string(), z.record(z.string(), z.number())) + .describe('Nested map of event names to date-count pairs') + }) + ) + .handleInvocation(async ctx => { + requireServiceAccount(ctx); + requireNonEmptyStringArray(ctx.input.eventNames, 'eventNames'); + requireDateRangeOrInterval(ctx.input); + + let client = createClientFromContext(ctx); + let result = await client.getEventCounts({ + eventNames: ctx.input.eventNames, + type: ctx.input.type, + unit: ctx.input.unit, + interval: ctx.input.interval, + fromDate: ctx.input.fromDate, + toDate: ctx.input.toDate + }); + + return { + output: result, + message: `Event count query returned **${result.legendSize}** event series over **${result.series.length}** time periods.` + }; + }) + .build(); diff --git a/integrations/mixpanel/src/tools/query-event-property-values.ts b/integrations/mixpanel/src/tools/query-event-property-values.ts new file mode 100644 index 0000000000..f39a713b11 --- /dev/null +++ b/integrations/mixpanel/src/tools/query-event-property-values.ts @@ -0,0 +1,81 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { + createClientFromContext, + requireDateRangeOrInterval, + requireServiceAccount +} from '../lib/helpers'; +import { spec } from '../spec'; + +export let queryEventPropertyValues = SlateTool.create(spec, { + name: 'Query Event Property Values', + key: 'query_event_property_values', + description: `Get aggregate total, unique, or average counts for values of a specific property on a Mixpanel event.`, + constraints: ['Rate limit: 60 queries per hour, max 5 concurrent queries.'], + tags: { + readOnly: true + } +}) + .input( + z.object({ + eventName: z.string().describe('Event name to query'), + propertyName: z.string().describe('Event property name to aggregate by'), + values: z + .array(z.string()) + .optional() + .describe('Specific property values to include. Omit to return top values.'), + type: z + .enum(['general', 'unique', 'average']) + .describe('Analysis type: general, unique, or average'), + unit: z + .enum(['minute', 'hour', 'day', 'week', 'month']) + .describe('Time bucket granularity'), + interval: z + .number() + .optional() + .describe( + 'Number of recent units to return. Provide either interval or fromDate/toDate.' + ), + fromDate: z + .string() + .optional() + .describe('Start date in yyyy-mm-dd format. Provide with toDate.'), + toDate: z + .string() + .optional() + .describe('End date in yyyy-mm-dd format. Provide with fromDate.'), + limit: z.number().optional().describe('Maximum number of property values to return') + }) + ) + .output( + z.object({ + legendSize: z.number().describe('Number of property-value series in the response'), + series: z.array(z.string()).describe('Array of date strings in the time series'), + values: z + .record(z.string(), z.record(z.string(), z.number())) + .describe('Nested map of property values to date-count pairs') + }) + ) + .handleInvocation(async ctx => { + requireServiceAccount(ctx); + requireDateRangeOrInterval(ctx.input); + + let client = createClientFromContext(ctx); + let result = await client.queryEventPropertyValues({ + eventName: ctx.input.eventName, + propertyName: ctx.input.propertyName, + values: ctx.input.values, + type: ctx.input.type, + unit: ctx.input.unit, + interval: ctx.input.interval, + fromDate: ctx.input.fromDate, + toDate: ctx.input.toDate, + limit: ctx.input.limit + }); + + return { + output: result, + message: `Event property query returned **${result.legendSize}** value series over **${result.series.length}** time periods.` + }; + }) + .build(); diff --git a/integrations/mixpanel/src/tools/query-funnel.ts b/integrations/mixpanel/src/tools/query-funnel.ts index ff97a8b87c..7ee1464f9d 100644 --- a/integrations/mixpanel/src/tools/query-funnel.ts +++ b/integrations/mixpanel/src/tools/query-funnel.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let queryFunnel = SlateTool.create(spec, { @@ -47,6 +47,8 @@ Use **List Funnels** first to discover available funnel IDs.`, }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); let result = await client.queryFunnel({ diff --git a/integrations/mixpanel/src/tools/query-insights.ts b/integrations/mixpanel/src/tools/query-insights.ts index f6d1c9eee1..b50d665026 100644 --- a/integrations/mixpanel/src/tools/query-insights.ts +++ b/integrations/mixpanel/src/tools/query-insights.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let queryInsights = SlateTool.create(spec, { @@ -25,6 +25,8 @@ export let queryInsights = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); let data = await client.queryInsights(ctx.input.bookmarkId); diff --git a/integrations/mixpanel/src/tools/query-profiles.ts b/integrations/mixpanel/src/tools/query-profiles.ts index fb1df6f02c..f6ff5bab0b 100644 --- a/integrations/mixpanel/src/tools/query-profiles.ts +++ b/integrations/mixpanel/src/tools/query-profiles.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let queryProfiles = SlateTool.create(spec, { @@ -54,6 +54,8 @@ Returns paginated results with profile properties. Use sessionId and page for pa }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); let result = await client.queryProfiles({ diff --git a/integrations/mixpanel/src/tools/query-retention.ts b/integrations/mixpanel/src/tools/query-retention.ts index fa0ec3fc65..f3418426aa 100644 --- a/integrations/mixpanel/src/tools/query-retention.ts +++ b/integrations/mixpanel/src/tools/query-retention.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let queryRetention = SlateTool.create(spec, { @@ -58,6 +58,8 @@ Returns retention counts per cohort date bucket.`, }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); let result = await client.queryRetention({ diff --git a/integrations/mixpanel/src/tools/query-segmentation.ts b/integrations/mixpanel/src/tools/query-segmentation.ts index 2e8bad61eb..3f3ea5f293 100644 --- a/integrations/mixpanel/src/tools/query-segmentation.ts +++ b/integrations/mixpanel/src/tools/query-segmentation.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { createClientFromContext, requireServiceAccount } from '../lib/helpers'; import { spec } from '../spec'; export let querySegmentation = SlateTool.create(spec, { @@ -44,6 +44,8 @@ Useful for understanding how often an event occurs, broken down by property valu }) ) .handleInvocation(async ctx => { + requireServiceAccount(ctx); + let client = createClientFromContext(ctx); let result = await client.querySegmentation({ diff --git a/integrations/mixpanel/src/tools/track-events.ts b/integrations/mixpanel/src/tools/track-events.ts index 1056792ea9..bc4a9c4fd7 100644 --- a/integrations/mixpanel/src/tools/track-events.ts +++ b/integrations/mixpanel/src/tools/track-events.ts @@ -1,6 +1,10 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClientFromContext } from '../lib/helpers'; +import { + createClientFromContext, + requireNonEmptyStringArray, + requireProjectToken +} from '../lib/helpers'; import { spec } from '../spec'; export let trackEvents = SlateTool.create(spec, { @@ -43,6 +47,12 @@ export let trackEvents = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + requireProjectToken(ctx); + requireNonEmptyStringArray( + ctx.input.events.map(event => event.eventName), + 'events' + ); + let client = createClientFromContext(ctx); let events = ctx.input.events.map(e => ({ diff --git a/integrations/mixpanel/vitest.config.ts b/integrations/mixpanel/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/mixpanel/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/monday/README.md b/integrations/monday/README.md index 97c768a918..f16fb69e24 100644 --- a/integrations/monday/README.md +++ b/integrations/monday/README.md @@ -1,72 +1,52 @@ -# Mondaycom +# Monday.com -Create, read, update, and delete boards, items, sub-items, groups, and columns to manage projects and workflows. Post updates and replies on items for team communication. Manage workspaces, folders, documents (Workdocs), users, teams, tags, and file assets. Upload files, send notifications, query activity logs and dashboards, and configure webhooks for real-time event notifications on boards. Supports a wide variety of column types including status, date, people, timeline, dropdown, and more. +Manage monday.com boards, items, groups, columns, updates, workspaces, folders, webhooks, users, teams, tags, notifications, and activity logs through the monday.com GraphQL API. -## Tools - -### Create Board - -Create a new board in Monday.com. Specify the board name, visibility type, and optionally assign it to a workspace or folder. - -### Create Item - -Create a new item (row) on a Monday.com board. Optionally place it in a specific group and set initial column values. Column values should be a JSON object mapping column IDs to their values, formatted per Monday.com's column value specification. - -### Create Sub-item - -Create a sub-item under a parent item. Sub-items are nested items that appear within the parent item. Optionally set initial column values. - -### Get Activity Logs - -Retrieve activity log entries for one or more boards. Activity logs capture a history of changes and actions performed on the board, including item creation, column updates, status changes, etc. - -### List Boards - -Retrieve boards from the Monday.com account. Supports filtering by board IDs, workspace, board kind, and state. Returns board metadata including columns, groups, and owners. - -### List Items +This integration pins monday.com API requests to the current stable `2026-04` version. -Retrieve items from a board or by item IDs. When fetching by board, supports pagination via cursor and filtering by group. Returns item data including column values and sub-item references. - -### List Tags - -Retrieve all tags in the Monday.com account. Tags are used to label and categorize items across boards. - -### List Teams - -Retrieve teams from the Monday.com account. Optionally filter by team IDs. Returns team members and owners. - -### List Users - -Retrieve users from the Monday.com account. Filter by user IDs, email addresses, or name. Returns user profile details and team memberships. - -### List Columns - -Retrieve all columns (fields) defined on a board. Returns column metadata including type, title, and settings. - -### List Groups - -Retrieve all groups from a board. Groups are sections that organize items within a board. +## Tools -### List Updates +### Boards -Retrieve updates (comments/discussions) from an item or by update IDs. Updates include threaded replies. +- `list_boards`: Retrieve board metadata, columns, groups, and owners. +- `create_board`: Create a board, including `2026-04` empty-board and prompt options. +- `update_board`: Update board name or description, archive a board, or delete a board. +- `duplicate_board`: Duplicate a board with structure, items, or items and updates. +- `move_board`: Move a board to a workspace/folder or update its hierarchy position. -### List Workspaces +### Items and Sub-items -Retrieve workspaces from the Monday.com account. Workspaces are organizational containers that hold boards, dashboards, and folders. +- `list_items`: Retrieve specific items or board items with cursor pagination, group filtering, `items_page` filters, sorting, and hierarchy scope. +- `create_item`: Create an item with optional group and initial column values. +- `update_item`: Update column values, move an item to a group, archive an item, or delete an item. +- `duplicate_item`: Duplicate an item or sub-item. +- `move_item`: Move an item to a group, a board position, or another board. +- `set_item_description`: Replace an item's `2026-04` markdown description content. +- `create_subitem`: Create a sub-item under a parent item. -### Send Notification +### Groups and Columns -Send a notification to a specific user on behalf of the authenticated user. The notification is linked to a target item on a board. +- `list_groups`, `create_group`, `update_group`: Read and manage board groups. +- `list_columns`, `create_column`, `update_column_metadata`, `delete_column`: Read and manage board column definitions. -### Update Board +### Updates -Update a board's properties such as name, description, or communication settings. Can also archive or delete a board. +- `list_updates`: Read item or account updates. +- `create_update`: Post an update or reply. +- `edit_update`: Edit update body content. +- `react_to_update`: Like or unlike an update. +- `pin_update`: Pin or unpin an update. +- `clear_item_updates`: Clear all updates from an item. +- `delete_update`: Delete an update. -### Update Item +### Account Resources -Update an item's column values, move it to a different group, archive it, or delete it. For column value updates, provide a JSON object mapping column IDs to their new values. +- `list_workspaces`, `create_workspace`, `update_workspace`: Read and manage workspaces. +- `list_folders`, `create_folder`, `update_folder`, `delete_folder`: Read and manage workspace folders. +- `list_webhooks`, `create_webhook`, `delete_webhook`: Read and manage board webhooks. +- `list_users`, `list_teams`, `list_tags`: Read account users, teams, and tags. +- `send_notification`: Send a notification linked to an item or board target. +- `get_activity_logs`: Read board activity logs. ## License diff --git a/integrations/monday/docs/SPEC.md b/integrations/monday/docs/SPEC.md index ceb6e9b143..56ef80fb5f 100644 --- a/integrations/monday/docs/SPEC.md +++ b/integrations/monday/docs/SPEC.md @@ -1,154 +1,91 @@ -Let me get the specific webhook event types and OAuth scopes from the Monday.com API docs.Now I have enough information to compile the specification. Let me also check the OAuth scopes page for the complete list.Now I have all the information I need. Let me compile the specification. - # Slates Specification for Monday.com ## Overview -Monday.com is a work operating system (Work OS) that provides teams with boards, items, columns, and automations to manage projects, workflows, and operations. It offers products for work management, dev, sales CRM, and service. The platform exposes a GraphQL API for programmatic access to all account data. +monday.com is a work operating system built around boards, groups, items, columns, updates, workspaces, folders, and webhooks. This integration uses the monday.com GraphQL API and passes `API-Version: 2026-04`, the current stable API version at the time of this update. ## Authentication -Monday.com supports two authentication methods: - -### 1. Personal API Token - -The monday.com platform API utilizes personal V2 API tokens to authenticate requests and identify the user making the call. Personal tokens allow you to interact with the API using your own user account. Their permissions mirror what you can do in the monday.com UI, ensuring that API access is consistent with your platform-level permissions. - -To obtain a token: navigate to your profile picture → Developers → API token → Show, and copy the token. +The integration supports personal API tokens and OAuth 2.0. -Once you have your token, you can make requests with the API by passing the token in the Authorization header. +Personal API tokens are sent in the `Authorization` header when calling `https://api.monday.com/v2`. -All requests are POST requests to `https://api.monday.com/v2` with the token in the `Authorization` header and `Content-Type: application/json`. - -### 2. OAuth 2.0 - -OAuth 2.0 is a protocol that lets your app request authorization to read or modify data in a user's monday account. At the end of the OAuth process, your app gets an access token that belongs to the user and grants access to specified permission scopes. - -**Endpoints:** +OAuth uses: - Authorization URL: `https://auth.monday.com/oauth2/authorize` - Token URL: `https://auth.monday.com/oauth2/token` -**Credentials required:** `client_id` and `client_secret`, obtained by creating an app in the Monday.com Developer Center. - -**Flow:** +Configured OAuth scopes include account, boards, updates, users, teams, tags, workspaces, webhooks, notifications, docs, and assets scopes. Tool behavior only exposes the surfaces implemented in this package. -1. Redirect user to authorization URL with `client_id`. -2. User approves scopes; redirected back with a temporary authorization `code` (valid for 10 minutes). -3. Exchange the code at the token URL for an access token. -4. The access token gives your app access to the monday API on behalf of the user and will be valid until the user uninstalls your app. There are no refresh tokens. +## Implemented Tool Coverage -**Optional parameters:** `subdomain` can be specified to target a specific account for users who belong to multiple accounts. +### Boards -**Supported OAuth Scopes:** +- List boards by ID, workspace, board kind, and state. +- Create boards, including current `2026-04` `empty` and `prompt` arguments. +- Update board name and description. +- Archive and delete boards. +- Duplicate boards with structure, items, or items and updates. +- Move boards by updating workspace, folder, product, or hierarchy position. -| Scope | Description | -| --------------------- | ------------------------------------------------- | -| `account:read` | Read general information about the account | -| `assets:read` | Read data from assets the user has access to | -| `boards:read` | Read a user's board data | -| `boards:write` | Modify a user's board data | -| `docs:read` | Read a user's docs | -| `docs:write` | Modify a user's docs | -| `me:read` | Read a user's profile information | -| `notifications:write` | Send notifications on behalf of the user | -| `tags:read` | Read the account's tags | -| `teams:read` | Read information about the account's teams | -| `teams:write` | Modify the account's teams | -| `updates:read` | Read updates and replies the user can see | -| `updates:write` | Post or edit updates on behalf of the user | -| `users:read` | Read profile information of the account's users | -| `users:write` | Modify profile information of the account's users | -| `webhooks:read` | Read existing webhooks configuration | -| `webhooks:write` | Create and modify webhooks | -| `workspaces:read` | Read a user's workspaces data | -| `workspaces:write` | Modify a user's workspaces data | +### Items and Sub-items -## Features - -### Board Management - -Create, read, update, and delete boards. Boards are the primary containers in Monday.com, analogous to spreadsheets or project boards. You can manage board settings, templates, and board views. The platform API currently supports the monday work management, dev, sales CRM, and service products. It currently does not support Workforms. - -### Item and Sub-item Management - -Create, read, update, and delete items (rows) within boards. Items represent individual work entries. You can also manage sub-items, which are nested items under a parent item. Items can be moved between groups and boards. - -### Column and Column Value Management - -Manage board columns (fields) and their values on items. Monday.com supports a wide variety of column types including status, date, people, text, numbers, timeline, dropdown, checkbox, email, phone, location, files, formulas, and many more. You can create columns, update column values for individual items, and change multiple column values at once. +- Read items by ID. +- Read board items through `items_page`, including cursor pagination, group filtering, filter rules, order rules, and hierarchy scope. +- Create items and sub-items. +- Update item column values. +- Move items to groups, positions, or another board. +- Duplicate items. +- Replace item description markdown content through the `2026-04` `set_item_description_content` mutation. +- Archive, delete, and clear item updates. ### Groups -Create, read, update, and delete groups within boards. Groups are used to categorize and organize items into sections within a board. - -### Updates and Replies +- List, create, update, archive, and delete groups on a board. -Post updates (comments/discussions) on items and reply to existing updates. Updates serve as a communication thread attached to specific items. +### Columns -### Users and Teams +- List columns using the current `settings` JSON field. +- Create columns with optional defaults, custom ID, and insertion point. +- Update column title or description. +- Delete columns. -Read user profiles and team information within the account. Manage team membership. User data includes profile details like name, email, and role. +### Updates -### Workspaces +- List updates by item or update IDs. +- Create updates and replies. +- Edit update bodies. +- Like and unlike updates. +- Pin and unpin updates. +- Clear an item's updates. +- Delete updates. -Create and manage workspaces, which are organizational containers that hold boards and other resources. Workspaces help separate different departments or projects. +### Workspaces and Folders -### Documents (Docs) - -Create, read, and manage Monday Workdocs. Docs support block-based content (text, tables, images, etc.) and can be exported as Markdown. - -### Folders - -Organize boards into folders within workspaces for better structure and navigation. - -### Notifications - -Send notifications to users on behalf of the authenticated user, targeting specific items. - -### File/Asset Management - -Upload and manage files attached to items or updates. File uploads use multipart requests rather than the standard JSON body. - -### Tags - -Read and manage tags used to label and categorize items across boards. - -### Dashboards and Widgets - -Query dashboards and their associated widgets for reporting and visualization purposes. +- List, create, update, and delete workspaces. +- List, create, update, and delete folders. ### Webhooks -Create and manage webhook subscriptions on boards to receive real-time notifications when specific events occur. Webhooks are scoped to individual boards. - -### Activity Logs - -Query activity log data on boards to see a history of changes and actions performed. - -## Events - -Monday.com offers the ability to send a Webhook via integrations, or create them via the API. You can send a Webhook each time a chosen event occurs within your board. Whenever you try to create a new Webhook on monday.com, they send a JSON challenge to the URL you provide to verify you have control over the endpoint. +- List board webhooks. +- Create board webhooks for supported event types. +- Delete webhooks. -Webhooks are created per board via the `create_webhook` GraphQL mutation, specifying the board ID, target URL, and event type. +monday.com verifies webhook URLs during creation by sending a JSON challenge to the callback URL. Private live E2E coverage therefore fixture-gates create/delete webhook scenarios behind a callback URL that can pass this challenge. -### Item Events +### Account Reads and Utilities -- **Item created** (`create_item`): Fires when a new item is created on the board. The payload includes the item name, group, and board information but column values are empty. -- **Sub-item created** (`create_subitem`): Fires when a sub-item is created. Payload includes the parent item ID. +- List users, teams, tags, and board activity logs. +- Send notifications to users. -### Column Value Change Events +## Non-Implemented Surfaces -- **Any column value changed** (`change_column_value`): Fires when any column value changes on any item in the board. Includes the column ID, type, new value, and previous value. -- **Specific column value changed** (`change_specific_column_value`): Fires only when a particular column changes. Requires a `config` parameter with the `columnId` to watch. -- **Sub-item column value changed** (`change_subitem_column_value`): Fires when any column value changes on a sub-item. +This package does not currently expose Workdocs, dashboards, widgets, forms, asset upload/download, automations, validations, or the `2026-07` release-candidate search API. File-producing tools are not present in this integration. -### Item Name Events +## Error Handling -- **Item name changed** (`change_name`): Fires when an item's name is updated. -- **Sub-item name changed** (`change_subitem_name`): Fires when a sub-item's name is updated. +Integration API, auth, and tool validation errors are wrapped in `ServiceError` from `@lowerdeck/error` for user-facing failures. GraphQL errors returned inside successful HTTP responses are also converted to `ServiceError`. -### Update/Comment Events +## Schema Compatibility -- **Update created** (`create_update`): Fires when an update (comment) is posted on an item. Payload includes the update body, update ID, and reply ID. -- **Sub-item update created** (`create_subitem_update`): Fires when an update is posted on a sub-item. +All tool input schemas are top-level `z.object` schemas. The package includes a schema regression test that serializes each tool input with `z.toJSONSchema(...)` and asserts the top-level schema is an object without top-level `oneOf`, `anyOf`, or `allOf`. diff --git a/integrations/monday/package.json b/integrations/monday/package.json index 4840d7eb3e..da5d4465a7 100644 --- a/integrations/monday/package.json +++ b/integrations/monday/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/monday/slate.json b/integrations/monday/slate.json index 5c7156b136..d8ab601033 100644 --- a/integrations/monday/slate.json +++ b/integrations/monday/slate.json @@ -1,13 +1,13 @@ { "name": "@metorial/mondaycom", - "description": "Create, read, update, and delete boards, items, sub-items, groups, and columns to manage projects and workflows. Post updates and replies on items for team communication. Manage workspaces, folders, documents (Workdocs), users, teams, tags, and file assets. Upload files, send notifications, query activity logs and dashboards, and configure webhooks for real-time event notifications on boards. Supports a wide variety of column types including status, date, people, timeline, dropdown, and more.", + "description": "Create, read, update, duplicate, move, archive, and delete monday.com boards and items. Manage groups, columns, updates, workspaces, folders, webhooks, users, teams, tags, notifications, and activity logs using the current stable monday.com GraphQL API.", "categories": ["email-and-messaging", "task-and-project-management"], "skills": [ "manage boards and items", - "create and update columns", - "post updates and replies", + "duplicate and move boards and items", + "create update and delete columns", + "post edit react to and pin updates", "manage groups and sub-items", - "upload and manage files", "manage workspaces and folders", "send user notifications", "configure webhooks", diff --git a/integrations/monday/src/auth.ts b/integrations/monday/src/auth.ts index 22783e776c..2e13529278 100644 --- a/integrations/monday/src/auth.ts +++ b/integrations/monday/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { mondayApiError, mondayGraphQLError } from './lib/errors'; let mondayApi = createAxios({ baseURL: 'https://api.monday.com/v2' @@ -9,6 +10,42 @@ let mondayAuth = createAxios({ baseURL: 'https://auth.monday.com' }); +let getProfile = async (token: string) => { + let response: any; + try { + response = await mondayApi.post( + '', + { + query: `{ me { id name email photo_original } }` + }, + { + headers: { + Authorization: token, + 'Content-Type': 'application/json', + 'API-Version': '2026-04' + } + } + ); + } catch (error) { + throw mondayApiError(error, 'get profile'); + } + + if (response.data.errors?.length) { + throw mondayGraphQLError(response.data.errors, 'get profile'); + } + + let me = response.data.data.me; + + return { + profile: { + id: String(me.id), + name: me.name, + email: me.email, + imageUrl: me.photo_original + } + }; +}; + export let auth = SlateAuth.create() .output( z.object({ @@ -115,12 +152,17 @@ export let auth = SlateAuth.create() }, handleCallback: async ctx => { - let response = await mondayAuth.post('/oauth2/token', { - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - code: ctx.code, - redirect_uri: ctx.redirectUri - }); + let response: any; + try { + response = await mondayAuth.post('/oauth2/token', { + client_id: ctx.clientId, + client_secret: ctx.clientSecret, + code: ctx.code, + redirect_uri: ctx.redirectUri + }); + } catch (error) { + throw mondayApiError(error, 'oauth callback'); + } return { output: { @@ -130,29 +172,7 @@ export let auth = SlateAuth.create() }, getProfile: async (ctx: { output: { token: string }; input: any; scopes: string[] }) => { - let response = await mondayApi.post( - '', - { - query: `{ me { id name email photo_original } }` - }, - { - headers: { - Authorization: ctx.output.token, - 'Content-Type': 'application/json' - } - } - ); - - let me = response.data.data.me; - - return { - profile: { - id: String(me.id), - name: me.name, - email: me.email, - imageUrl: me.photo_original - } - }; + return getProfile(ctx.output.token); } }) .addTokenAuth({ @@ -173,28 +193,6 @@ export let auth = SlateAuth.create() }, getProfile: async (ctx: { output: { token: string }; input: { apiToken: string } }) => { - let response = await mondayApi.post( - '', - { - query: `{ me { id name email photo_original } }` - }, - { - headers: { - Authorization: ctx.output.token, - 'Content-Type': 'application/json' - } - } - ); - - let me = response.data.data.me; - - return { - profile: { - id: String(me.id), - name: me.name, - email: me.email, - imageUrl: me.photo_original - } - }; + return getProfile(ctx.output.token); } }); diff --git a/integrations/monday/src/index.ts b/integrations/monday/src/index.ts index 3e4effcc83..1053e24db0 100644 --- a/integrations/monday/src/index.ts +++ b/integrations/monday/src/index.ts @@ -1,26 +1,44 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + clearItemUpdatesTool, createBoardTool, createColumnTool, + createFolderTool, createGroupTool, createItemTool, createSubitemTool, createUpdateTool, + createWebhookTool, createWorkspaceTool, + deleteColumnTool, + deleteFolderTool, deleteUpdateTool, + deleteWebhookTool, + duplicateBoardTool, + duplicateItemTool, + editUpdateTool, getActivityLogsTool, listBoardsTool, listColumnsTool, + listFoldersTool, listGroupsTool, listItemsTool, listTagsTool, listTeamsTool, listUpdatesTool, listUsersTool, + listWebhooksTool, listWorkspacesTool, + moveBoardTool, + moveItemTool, + pinUpdateTool, + reactToUpdateTool, sendNotificationTool, + setItemDescriptionTool, updateBoardTool, + updateColumnMetadataTool, + updateFolderTool, updateGroupTool, updateItemTool, updateWorkspaceTool @@ -33,23 +51,41 @@ export let provider = Slate.create({ listBoardsTool, createBoardTool, updateBoardTool, + duplicateBoardTool, + moveBoardTool, listItemsTool, createItemTool, updateItemTool, + duplicateItemTool, + moveItemTool, + setItemDescriptionTool, createSubitemTool, listGroupsTool, createGroupTool, updateGroupTool, listColumnsTool, createColumnTool, + updateColumnMetadataTool, + deleteColumnTool, listUpdatesTool, createUpdateTool, + editUpdateTool, + reactToUpdateTool, + pinUpdateTool, + clearItemUpdatesTool, deleteUpdateTool, listUsersTool, listTeamsTool, listWorkspacesTool, createWorkspaceTool, updateWorkspaceTool, + listFoldersTool, + createFolderTool, + updateFolderTool, + deleteFolderTool, + listWebhooksTool, + createWebhookTool, + deleteWebhookTool, sendNotificationTool, listTagsTool, getActivityLogsTool diff --git a/integrations/monday/src/lib/client.ts b/integrations/monday/src/lib/client.ts index 15d9d882e4..6b21668206 100644 --- a/integrations/monday/src/lib/client.ts +++ b/integrations/monday/src/lib/client.ts @@ -1,9 +1,34 @@ import { createAxios } from 'slates'; +import { mondayApiError, mondayGraphQLError } from './errors'; let api = createAxios({ baseURL: 'https://api.monday.com/v2' }); +type ItemFilterRule = { + column_id: string; + compare_value?: unknown; + operator?: string; +}; + +type ItemOrderBy = { + column_id: string; + direction?: string; +}; + +type ColumnMapping = { + source: string; + target?: string | null; +}; + +type DynamicPosition = { + object_id: string; + object_type: string; + is_after?: boolean; +}; + +type FolderPosition = DynamicPosition; + export class MondayClient { private token: string; @@ -15,24 +40,31 @@ export class MondayClient { return { Authorization: this.token, 'Content-Type': 'application/json', - 'API-Version': '2025-04' + 'API-Version': '2026-04' }; } - async query(graphqlQuery: string, variables?: Record): Promise { + async query( + graphqlQuery: string, + variables?: Record, + operation = 'request' + ): Promise { let body: Record = { query: graphqlQuery }; if (variables) { body.variables = variables; } - let response = await api.post('', body, { - headers: this.headers - }); + let response: any; + try { + response = await api.post('', body, { + headers: this.headers + }); + } catch (error) { + throw mondayApiError(error, operation); + } if (response.data.errors && response.data.errors.length > 0) { - throw new Error( - `Monday.com API error: ${response.data.errors.map((e: any) => e.message).join(', ')}` - ); + throw mondayGraphQLError(response.data.errors, operation); } return response.data.data; @@ -71,6 +103,8 @@ export class MondayClient { workspaceId?: string; folderId?: string; templateId?: string; + empty?: boolean; + prompt?: string; }): Promise { let args: string[] = [ `board_name: "${this.escapeGraphQL(params.boardName)}"`, @@ -81,32 +115,104 @@ export class MondayClient { if (params.workspaceId) args.push(`workspace_id: ${params.workspaceId}`); if (params.folderId) args.push(`folder_id: ${params.folderId}`); if (params.templateId) args.push(`template_id: ${params.templateId}`); + if (params.empty !== undefined) args.push(`empty: ${params.empty}`); + if (params.prompt) args.push(`prompt: "${this.escapeGraphQL(params.prompt)}"`); let data = await this.query( - `mutation { create_board(${args.join(', ')}) { id name description state board_kind } }` + `mutation { create_board(${args.join(', ')}) { id name description state board_kind workspace_id } }`, + undefined, + 'create board' ); return data.create_board; } async updateBoard(boardId: string, attribute: string, newValue: string): Promise { let data = await this.query( - `mutation { update_board(board_id: ${boardId}, board_attribute: ${attribute}, new_value: "${this.escapeGraphQL(newValue)}") }` + `mutation($boardId: ID!, $newValue: String!) { update_board(board_id: $boardId, board_attribute: ${attribute}, new_value: $newValue) }`, + { boardId, newValue }, + 'update board' ); return data.update_board; } async archiveBoard(boardId: string): Promise { let data = await this.query( - `mutation { archive_board(board_id: ${boardId}) { id name state } }` + `mutation($boardId: ID!) { archive_board(board_id: $boardId) { id name state } }`, + { boardId }, + 'archive board' ); return data.archive_board; } async deleteBoard(boardId: string): Promise { - let data = await this.query(`mutation { delete_board(board_id: ${boardId}) { id } }`); + let data = await this.query( + `mutation($boardId: ID!) { delete_board(board_id: $boardId) { id } }`, + { boardId }, + 'delete board' + ); return data.delete_board; } + async duplicateBoard(params: { + boardId: string; + duplicateType: string; + boardName?: string; + workspaceId?: string; + folderId?: string; + keepSubscribers?: boolean; + }): Promise { + let varDefs = ['$boardId: ID!']; + let args = ['board_id: $boardId', `duplicate_type: ${params.duplicateType}`]; + let variables: Record = { + boardId: params.boardId + }; + + if (params.boardName) { + variables.boardName = params.boardName; + varDefs.push('$boardName: String'); + args.push('board_name: $boardName'); + } + if (params.workspaceId) { + variables.workspaceId = params.workspaceId; + varDefs.push('$workspaceId: ID'); + args.push('workspace_id: $workspaceId'); + } + if (params.folderId) { + variables.folderId = params.folderId; + varDefs.push('$folderId: ID'); + args.push('folder_id: $folderId'); + } + if (params.keepSubscribers !== undefined) { + variables.keepSubscribers = params.keepSubscribers; + varDefs.push('$keepSubscribers: Boolean'); + args.push('keep_subscribers: $keepSubscribers'); + } + + let data = await this.query( + `mutation(${varDefs.join(', ')}) { duplicate_board(${args.join(', ')}) { board { id name description state board_kind workspace_id } } }`, + variables, + 'duplicate board' + ); + return data.duplicate_board?.board; + } + + async updateBoardHierarchy( + boardId: string, + attributes: { + workspace_id?: string; + folder_id?: string; + account_product_id?: string; + position?: DynamicPosition; + } + ): Promise { + let data = await this.query( + `mutation($boardId: ID!, $attributes: UpdateBoardHierarchyAttributesInput!) { update_board_hierarchy(board_id: $boardId, attributes: $attributes) { success } }`, + { boardId, attributes }, + 'update board hierarchy' + ); + return data.update_board_hierarchy; + } + // ==================== Items ==================== async getItems(ids: string[]): Promise { @@ -122,23 +228,50 @@ export class MondayClient { limit?: number; cursor?: string; groupId?: string; - queryParams?: { rules: any[]; operator?: string }; + queryParams?: { + rules?: ItemFilterRule[]; + operator?: string; + order_by?: ItemOrderBy[]; + }; + hierarchyScopeConfig?: string; } ): Promise<{ items: any[]; cursor: string | null }> { let itemsPageArgs: string[] = []; - if (options?.limit) itemsPageArgs.push(`limit: ${options.limit}`); - if (options?.cursor) itemsPageArgs.push(`cursor: "${options.cursor}"`); + let variables: Record = { + boardId + }; + let varDefs = ['$boardId: ID!']; + + if (options?.limit) { + variables.limit = options.limit; + varDefs.push('$limit: Int'); + itemsPageArgs.push('limit: $limit'); + } + if (options?.cursor) { + variables.cursor = options.cursor; + varDefs.push('$cursor: String'); + itemsPageArgs.push('cursor: $cursor'); + } if (options?.queryParams) { - let rules = JSON.stringify(options.queryParams.rules).replace(/"([^"]+)":/g, '$1:'); - let op = options.queryParams.operator || 'and'; - itemsPageArgs.push(`query_params: {rules: ${rules}, operator: ${op}}`); + variables.queryParams = options.queryParams; + varDefs.push('$queryParams: ItemsQuery'); + itemsPageArgs.push('query_params: $queryParams'); + } + if (options?.hierarchyScopeConfig) { + variables.hierarchyScopeConfig = options.hierarchyScopeConfig; + varDefs.push('$hierarchyScopeConfig: String'); + itemsPageArgs.push('hierarchy_scope_config: $hierarchyScopeConfig'); } let itemsPageArgsStr = itemsPageArgs.length > 0 ? `(${itemsPageArgs.join(', ')})` : ''; if (options?.groupId) { + variables.groupId = options.groupId; + varDefs.push('$groupId: String!'); let data = await this.query( - `{ boards(ids: [${boardId}]) { groups(ids: ["${this.escapeGraphQL(options.groupId)}"]) { items_page${itemsPageArgsStr} { cursor items { id name state created_at updated_at group { id title } column_values { id type text value } subitems { id name } } } } } }` + `query(${varDefs.join(', ')}) { boards(ids: [$boardId]) { groups(ids: [$groupId]) { items_page${itemsPageArgsStr} { cursor items { id name state created_at updated_at group { id title } column_values { id type text value } subitems { id name } } } } } }`, + variables, + 'get board group items' ); let group = data.boards?.[0]?.groups?.[0]; return { @@ -148,7 +281,9 @@ export class MondayClient { } let data = await this.query( - `{ boards(ids: [${boardId}]) { items_page${itemsPageArgsStr} { cursor items { id name state created_at updated_at group { id title } column_values { id type text value } subitems { id name } } } } }` + `query(${varDefs.join(', ')}) { boards(ids: [$boardId]) { items_page${itemsPageArgsStr} { cursor items { id name state created_at updated_at group { id title } column_values { id type text value } subitems { id name } } } } }`, + variables, + 'get board items' ); let board = data.boards?.[0]; return { @@ -187,7 +322,8 @@ export class MondayClient { let data = await this.query( `mutation(${varDefs.join(', ')}) { create_item(${mutationArgs.join(', ')}) { id name group { id title } column_values { id type text value } } }`, - variables + variables, + 'create item' ); return data.create_item; } @@ -206,30 +342,133 @@ export class MondayClient { let labelsArg = createLabelsIfMissing ? ', create_labels_if_missing: true' : ''; let data = await this.query( `mutation($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues${labelsArg}) { id name column_values { id type text value } } }`, - variables + variables, + 'update item column values' ); return data.change_multiple_column_values; } async moveItemToGroup(itemId: string, groupId: string): Promise { let data = await this.query( - `mutation { move_item_to_group(item_id: ${itemId}, group_id: "${this.escapeGraphQL(groupId)}") { id name group { id title } } }` + `mutation($itemId: ID!, $groupId: String!) { move_item_to_group(item_id: $itemId, group_id: $groupId) { id name group { id title } } }`, + { itemId, groupId }, + 'move item to group' ); return data.move_item_to_group; } + async duplicateItem( + boardId: string, + itemId: string, + options?: { withUpdates?: boolean } + ): Promise { + let data = await this.query( + `mutation($boardId: ID!, $itemId: ID!, $withUpdates: Boolean) { duplicate_item(board_id: $boardId, item_id: $itemId, with_updates: $withUpdates) { id name state group { id title } column_values { id type text value } } }`, + { boardId, itemId, withUpdates: options?.withUpdates }, + 'duplicate item' + ); + return data.duplicate_item; + } + + async changeItemPosition( + itemId: string, + options: + | { + relativeTo: string; + positionRelativeMethod: string; + } + | { + groupId: string; + groupTop: boolean; + } + ): Promise { + if ('relativeTo' in options) { + let data = await this.query( + `mutation($itemId: ID!, $relativeTo: ID!) { change_item_position(item_id: $itemId, relative_to: $relativeTo, position_relative_method: ${options.positionRelativeMethod}) { id name group { id title } } }`, + { itemId, relativeTo: options.relativeTo }, + 'change item position' + ); + return data.change_item_position; + } + + let data = await this.query( + `mutation($itemId: ID!, $groupId: ID!, $groupTop: Boolean!) { change_item_position(item_id: $itemId, group_id: $groupId, group_top: $groupTop) { id name group { id title } } }`, + { itemId, groupId: options.groupId, groupTop: options.groupTop }, + 'change item position' + ); + return data.change_item_position; + } + + async moveItemToBoard(params: { + itemId: string; + boardId: string; + groupId: string; + columnsMapping?: ColumnMapping[]; + subitemsColumnsMapping?: ColumnMapping[]; + }): Promise { + let varDefs = ['$itemId: ID!', '$boardId: ID!', '$groupId: ID!']; + let args = ['item_id: $itemId', 'board_id: $boardId', 'group_id: $groupId']; + let variables: Record = { + itemId: params.itemId, + boardId: params.boardId, + groupId: params.groupId + }; + + if (params.columnsMapping) { + variables.columnsMapping = params.columnsMapping; + varDefs.push('$columnsMapping: [ColumnMappingInput!]'); + args.push('columns_mapping: $columnsMapping'); + } + if (params.subitemsColumnsMapping) { + variables.subitemsColumnsMapping = params.subitemsColumnsMapping; + varDefs.push('$subitemsColumnsMapping: [ColumnMappingInput!]'); + args.push('subitems_columns_mapping: $subitemsColumnsMapping'); + } + + let data = await this.query( + `mutation(${varDefs.join(', ')}) { move_item_to_board(${args.join(', ')}) { id name board { id name } group { id title } } }`, + variables, + 'move item to board' + ); + return data.move_item_to_board; + } + + async setItemDescriptionContent(itemId: string, markdown: string): Promise { + let data = await this.query( + `mutation($itemId: ID!, $markdown: String!) { set_item_description_content(item_id: $itemId, markdown: $markdown) { success error block_ids } }`, + { itemId, markdown }, + 'set item description content' + ); + return data.set_item_description_content; + } + async archiveItem(itemId: string): Promise { let data = await this.query( - `mutation { archive_item(item_id: ${itemId}) { id name state } }` + `mutation($itemId: ID!) { archive_item(item_id: $itemId) { id name state } }`, + { itemId }, + 'archive item' ); return data.archive_item; } async deleteItem(itemId: string): Promise { - let data = await this.query(`mutation { delete_item(item_id: ${itemId}) { id } }`); + let data = await this.query( + `mutation($itemId: ID!) { delete_item(item_id: $itemId) { id } }`, + { itemId }, + 'delete item' + ); return data.delete_item; } + async clearItemUpdates(itemId: string): Promise { + let data = await this.query( + `mutation($itemId: ID!) { clear_item_updates(item_id: $itemId) { id } }`, + { itemId }, + 'clear item updates' + ); + return data.clear_item_updates; + } + // ==================== Sub-items ==================== async createSubitem(params: { @@ -256,7 +495,8 @@ export class MondayClient { let data = await this.query( `mutation(${varDefs.join(', ')}) { create_subitem(parent_item_id: $parentItemId, item_name: $itemName${params.columnValues ? ', column_values: $columnValues' : ''}${params.createLabelsIfMissing ? ', create_labels_if_missing: true' : ''}) { id name column_values { id type text value } } }`, - variables + variables, + 'create subitem' ); return data.create_subitem; } @@ -314,7 +554,9 @@ export class MondayClient { async getColumns(boardId: string): Promise { let data = await this.query( - `{ boards(ids: [${boardId}]) { columns { id title type description settings_str } } }` + `query($boardId: ID!) { boards(ids: [$boardId]) { columns { id title type description settings revision width archived } } }`, + { boardId }, + 'get columns' ); return data.boards?.[0]?.columns || []; } @@ -323,26 +565,71 @@ export class MondayClient { boardId: string, title: string, columnType: string, - options?: { description?: string; defaults?: string } + options?: { + description?: string; + defaults?: unknown; + afterColumnId?: string; + customColumnId?: string; + } ): Promise { - let args = [ - `board_id: ${boardId}`, - `title: "${this.escapeGraphQL(title)}"`, - `column_type: ${columnType}` - ]; - if (options?.description) - args.push(`description: "${this.escapeGraphQL(options.description)}"`); - if (options?.defaults) args.push(`defaults: ${JSON.stringify(options.defaults)}`); + let variables: Record = { + boardId, + title + }; + let varDefs = ['$boardId: ID!', '$title: String!']; + let args = ['board_id: $boardId', 'title: $title', `column_type: ${columnType}`]; + + if (options?.description) { + variables.description = options.description; + varDefs.push('$description: String'); + args.push('description: $description'); + } + if (options?.defaults !== undefined) { + variables.defaults = + typeof options.defaults === 'string' + ? options.defaults + : JSON.stringify(options.defaults); + varDefs.push('$defaults: JSON'); + args.push('defaults: $defaults'); + } + if (options?.afterColumnId) { + variables.afterColumnId = options.afterColumnId; + varDefs.push('$afterColumnId: ID'); + args.push('after_column_id: $afterColumnId'); + } + if (options?.customColumnId) { + variables.customColumnId = options.customColumnId; + varDefs.push('$customColumnId: String'); + args.push('id: $customColumnId'); + } let data = await this.query( - `mutation { create_column(${args.join(', ')}) { id title type description } }` + `mutation(${varDefs.join(', ')}) { create_column(${args.join(', ')}) { id title type description settings revision width archived } }`, + variables, + 'create column' ); return data.create_column; } + async changeColumnMetadata( + boardId: string, + columnId: string, + property: string, + value: string + ): Promise { + let data = await this.query( + `mutation($boardId: ID!, $columnId: String!, $value: String!) { change_column_metadata(board_id: $boardId, column_id: $columnId, column_property: ${property}, value: $value) { id title type description settings revision width archived } }`, + { boardId, columnId, value }, + 'change column metadata' + ); + return data.change_column_metadata; + } + async deleteColumn(boardId: string, columnId: string): Promise { let data = await this.query( - `mutation { delete_column(board_id: ${boardId}, column_id: "${this.escapeGraphQL(columnId)}") { id } }` + `mutation($boardId: ID!, $columnId: String!) { delete_column(board_id: $boardId, column_id: $columnId) { id } }`, + { boardId, columnId }, + 'delete column' ); return data.delete_column; } @@ -361,15 +648,21 @@ export class MondayClient { let argsStr = args.length > 0 ? `(${args.join(', ')})` : ''; let data = await this.query( - `{ updates${argsStr} { id body text_body created_at updated_at creator_id creator { id name } item_id replies { id body text_body created_at creator_id } } }` + `{ updates${argsStr} { id body text_body created_at updated_at creator_id creator { id name } item_id replies { id body text_body created_at creator_id } } }`, + undefined, + 'get updates' ); return data.updates; } async getItemUpdates(itemId: string, limit?: number): Promise { - let limitStr = limit ? `(limit: ${limit})` : ''; + let limitStr = limit ? '(limit: $limit)' : ''; + let variables: Record = { itemId }; + if (limit) variables.limit = limit; let data = await this.query( - `{ items(ids: [${itemId}]) { updates${limitStr} { id body text_body created_at updated_at creator_id creator { id name } replies { id body text_body created_at creator_id } } } }` + `query($itemId: ID!${limit ? ', $limit: Int' : ''}) { items(ids: [$itemId]) { updates${limitStr} { id body text_body created_at updated_at creator_id creator { id name } replies { id body text_body created_at creator_id } } } }`, + variables, + 'get item updates' ); return data.items?.[0]?.updates || []; } @@ -390,13 +683,79 @@ export class MondayClient { let data = await this.query( `mutation(${varDefs.join(', ')}) { create_update(${mutationArgs.join(', ')}) { id body text_body created_at creator_id } }`, - variables + variables, + 'create update' ); return data.create_update; } + async editUpdate(updateId: string, body: string): Promise { + let data = await this.query( + `mutation($updateId: ID!, $body: String!) { edit_update(id: $updateId, body: $body) { id body text_body created_at updated_at creator_id item_id } }`, + { updateId, body }, + 'edit update' + ); + return data.edit_update; + } + + async likeUpdate(updateId: string): Promise { + let data = await this.query( + `mutation($updateId: ID!) { like_update(update_id: $updateId) { id body text_body creator_id item_id } }`, + { updateId }, + 'like update' + ); + return data.like_update; + } + + async unlikeUpdate(updateId: string): Promise { + let data = await this.query( + `mutation($updateId: ID!) { unlike_update(update_id: $updateId) { id body text_body creator_id item_id } }`, + { updateId }, + 'unlike update' + ); + return data.unlike_update; + } + + async pinUpdate(updateId: string, itemId?: string): Promise { + let args = ['id: $updateId']; + let variables: Record = { updateId }; + let varDefs = ['$updateId: ID!']; + if (itemId) { + variables.itemId = itemId; + varDefs.push('$itemId: ID'); + args.push('item_id: $itemId'); + } + let data = await this.query( + `mutation(${varDefs.join(', ')}) { pin_to_top(${args.join(', ')}) { id body text_body creator_id item_id } }`, + variables, + 'pin update' + ); + return data.pin_to_top; + } + + async unpinUpdate(updateId: string, itemId?: string): Promise { + let args = ['id: $updateId']; + let variables: Record = { updateId }; + let varDefs = ['$updateId: ID!']; + if (itemId) { + variables.itemId = itemId; + varDefs.push('$itemId: ID'); + args.push('item_id: $itemId'); + } + let data = await this.query( + `mutation(${varDefs.join(', ')}) { unpin_from_top(${args.join(', ')}) { id body text_body creator_id item_id } }`, + variables, + 'unpin update' + ); + return data.unpin_from_top; + } + async deleteUpdate(updateId: string): Promise { - let data = await this.query(`mutation { delete_update(id: ${updateId}) { id } }`); + let data = await this.query( + `mutation($updateId: ID!) { delete_update(id: $updateId) { id } }`, + { updateId }, + 'delete update' + ); return data.delete_update; } @@ -498,6 +857,152 @@ export class MondayClient { return data.delete_workspace; } + // ==================== Folders ==================== + + async getFolders(options?: { + ids?: string[]; + workspaceIds?: (string | null)[]; + limit?: number; + page?: number; + }): Promise { + let varDefs: string[] = []; + let args: string[] = []; + let variables: Record = {}; + + if (options?.ids?.length) { + variables.ids = options.ids; + varDefs.push('$ids: [ID!]'); + args.push('ids: $ids'); + } + if (options?.workspaceIds?.length) { + variables.workspaceIds = options.workspaceIds; + varDefs.push('$workspaceIds: [ID]'); + args.push('workspace_ids: $workspaceIds'); + } + if (options?.limit) { + variables.limit = options.limit; + varDefs.push('$limit: Int'); + args.push('limit: $limit'); + } + if (options?.page) { + variables.page = options.page; + varDefs.push('$page: Int'); + args.push('page: $page'); + } + + let queryPrefix = varDefs.length ? `query(${varDefs.join(', ')})` : 'query'; + let argsStr = args.length ? `(${args.join(', ')})` : ''; + let data = await this.query( + `${queryPrefix} { folders${argsStr} { id name color created_at owner_id app_feature_slug workspace { id name } parent { id name } sub_folders { id name } children { id name } } }`, + variables, + 'get folders' + ); + return data.folders; + } + + async createFolder(params: { + name: string; + workspaceId?: string; + parentFolderId?: string; + color?: string; + }): Promise { + let varDefs = ['$name: String!']; + let args = ['name: $name']; + let variables: Record = { + name: params.name + }; + + if (params.workspaceId) { + variables.workspaceId = params.workspaceId; + varDefs.push('$workspaceId: ID'); + args.push('workspace_id: $workspaceId'); + } + if (params.parentFolderId) { + variables.parentFolderId = params.parentFolderId; + varDefs.push('$parentFolderId: ID'); + args.push('parent_folder_id: $parentFolderId'); + } + if (params.color) { + args.push(`color: ${params.color}`); + } + + let data = await this.query( + `mutation(${varDefs.join(', ')}) { create_folder(${args.join(', ')}) { id name color workspace { id name } parent { id name } } }`, + variables, + 'create folder' + ); + return data.create_folder; + } + + async updateFolder(params: { + folderId: string; + name?: string; + workspaceId?: string; + parentFolderId?: string; + color?: string; + customIcon?: string; + fontWeight?: string; + accountProductId?: string; + position?: FolderPosition; + }): Promise { + let varDefs = ['$folderId: ID!']; + let args = ['folder_id: $folderId']; + let variables: Record = { + folderId: params.folderId + }; + + if (params.name) { + variables.name = params.name; + varDefs.push('$name: String'); + args.push('name: $name'); + } + if (params.workspaceId) { + variables.workspaceId = params.workspaceId; + varDefs.push('$workspaceId: ID'); + args.push('workspace_id: $workspaceId'); + } + if (params.parentFolderId) { + variables.parentFolderId = params.parentFolderId; + varDefs.push('$parentFolderId: ID'); + args.push('parent_folder_id: $parentFolderId'); + } + if (params.accountProductId) { + variables.accountProductId = params.accountProductId; + varDefs.push('$accountProductId: ID'); + args.push('account_product_id: $accountProductId'); + } + if (params.position) { + variables.position = params.position; + varDefs.push('$position: DynamicPosition'); + args.push('position: $position'); + } + if (params.color) { + args.push(`color: ${params.color}`); + } + if (params.customIcon) { + args.push(`custom_icon: ${params.customIcon}`); + } + if (params.fontWeight) { + args.push(`font_weight: ${params.fontWeight}`); + } + + let data = await this.query( + `mutation(${varDefs.join(', ')}) { update_folder(${args.join(', ')}) { id name color workspace { id name } parent { id name } } }`, + variables, + 'update folder' + ); + return data.update_folder; + } + + async deleteFolder(folderId: string): Promise { + let data = await this.query( + `mutation($folderId: ID!) { delete_folder(folder_id: $folderId) { id name } }`, + { folderId }, + 'delete folder' + ); + return data.delete_folder; + } + // ==================== Tags ==================== async getTags(): Promise { @@ -507,9 +1012,19 @@ export class MondayClient { // ==================== Webhooks ==================== - async getWebhooks(boardId: string): Promise { + async getWebhooks(boardId: string, options?: { appWebhooksOnly?: boolean }): Promise { + let args = ['board_id: $boardId']; + let variables: Record = { boardId }; + let varDefs = ['$boardId: ID!']; + if (options?.appWebhooksOnly !== undefined) { + variables.appWebhooksOnly = options.appWebhooksOnly; + varDefs.push('$appWebhooksOnly: Boolean'); + args.push('app_webhooks_only: $appWebhooksOnly'); + } let data = await this.query( - `{ webhooks(board_id: ${boardId}) { id event board_id config } }` + `query(${varDefs.join(', ')}) { webhooks(${args.join(', ')}) { id event board_id config } }`, + variables, + 'get webhooks' ); return data.webhooks; } @@ -520,21 +1035,32 @@ export class MondayClient { event: string, config?: Record ): Promise { - let args = [ - `board_id: ${boardId}`, - `url: "${this.escapeGraphQL(url)}"`, - `event: ${event}` - ]; - if (config) args.push(`config: "${this.escapeGraphQL(JSON.stringify(config))}"`); + let args = ['board_id: $boardId', 'url: $url', `event: ${event}`]; + let variables: Record = { + boardId, + url + }; + let varDefs = ['$boardId: ID!', '$url: String!']; + if (config) { + variables.config = JSON.stringify(config); + varDefs.push('$config: JSON'); + args.push('config: $config'); + } let data = await this.query( - `mutation { create_webhook(${args.join(', ')}) { id board_id } }` + `mutation(${varDefs.join(', ')}) { create_webhook(${args.join(', ')}) { id event board_id config } }`, + variables, + 'create webhook' ); return data.create_webhook; } async deleteWebhook(webhookId: string): Promise { - let data = await this.query(`mutation { delete_webhook(id: ${webhookId}) { id } }`); + let data = await this.query( + `mutation($webhookId: ID!) { delete_webhook(id: $webhookId) { id board_id } }`, + { webhookId }, + 'delete webhook' + ); return data.delete_webhook; } diff --git a/integrations/monday/src/lib/errors.ts b/integrations/monday/src/lib/errors.ts new file mode 100644 index 0000000000..ba34147bcf --- /dev/null +++ b/integrations/monday/src/lib/errors.ts @@ -0,0 +1,111 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +type MondayGraphQLError = { + message?: string; + extensions?: { + code?: string; + status_code?: number; + request_id?: string; + error_code?: string; + }; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + addDetail(details, data.message); + addDetail(details, data.error_description); + addDetail(details, data.error); + + let errors = Array.isArray(data.errors) ? data.errors : []; + for (let item of errors) { + if (isRecord(item)) { + addDetail(details, item.message); + } + } + } else { + addDetail(details, data); + } + + if (details.length > 0) return details.join(', '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let getStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let mondayServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let mondayApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = mondayServiceError( + `monday.com API ${operation} failed: ${statusLabel}${extractMessage(error)}` + ); + serviceError.data.reason = 'monday_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let mondayGraphQLError = (errors: MondayGraphQLError[], operation = 'request') => { + let message = errors + .map(error => { + let parts = [error.message || 'Unknown GraphQL error']; + if (error.extensions?.code) parts.push(`code=${error.extensions.code}`); + if (error.extensions?.error_code) + parts.push(`error_code=${error.extensions.error_code}`); + return parts.join(' '); + }) + .join(', '); + + let serviceError = mondayServiceError(`monday.com API ${operation} failed: ${message}`); + serviceError.data.reason = 'monday_graphql_error'; + serviceError.data.upstreamStatus = errors.find( + error => error.extensions?.status_code + )?.extensions?.status_code; + serviceError.data.requestId = errors.find( + error => error.extensions?.request_id + )?.extensions?.request_id; + return serviceError; +}; diff --git a/integrations/monday/src/tools.schema.test.ts b/integrations/monday/src/tools.schema.test.ts new file mode 100644 index 0000000000..ad15677dd8 --- /dev/null +++ b/integrations/monday/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('monday tool input schemas', provider.actions); diff --git a/integrations/monday/src/tools/create-board.ts b/integrations/monday/src/tools/create-board.ts index 275e4418ba..946f8a0119 100644 --- a/integrations/monday/src/tools/create-board.ts +++ b/integrations/monday/src/tools/create-board.ts @@ -18,7 +18,15 @@ export let createBoardTool = SlateTool.create(spec, { description: z.string().optional().describe('Board description'), workspaceId: z.string().optional().describe('Workspace ID to create the board in'), folderId: z.string().optional().describe('Folder ID to place the board in'), - templateId: z.string().optional().describe('Template board ID to duplicate from') + templateId: z.string().optional().describe('Template board ID to duplicate from'), + empty: z + .boolean() + .optional() + .describe('Create an empty board without monday.com default items'), + prompt: z + .string() + .optional() + .describe('2026-04 AI prompt to generate board structure and content') }) ) .output( @@ -39,7 +47,9 @@ export let createBoardTool = SlateTool.create(spec, { description: ctx.input.description, workspaceId: ctx.input.workspaceId, folderId: ctx.input.folderId, - templateId: ctx.input.templateId + templateId: ctx.input.templateId, + empty: ctx.input.empty, + prompt: ctx.input.prompt }); return { diff --git a/integrations/monday/src/tools/duplicate-board.ts b/integrations/monday/src/tools/duplicate-board.ts new file mode 100644 index 0000000000..9a3ac51518 --- /dev/null +++ b/integrations/monday/src/tools/duplicate-board.ts @@ -0,0 +1,64 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MondayClient } from '../lib/client'; +import { spec } from '../spec'; + +export let duplicateBoardTool = SlateTool.create(spec, { + name: 'Duplicate Board', + key: 'duplicate_board', + description: `Duplicate a board, optionally into another workspace or folder.` +}) + .input( + z.object({ + boardId: z.string().describe('ID of the board to duplicate'), + duplicateType: z + .enum([ + 'duplicate_board_with_structure', + 'duplicate_board_with_pulses', + 'duplicate_board_with_pulses_and_updates' + ]) + .default('duplicate_board_with_structure') + .describe('How much board content to duplicate'), + boardName: z.string().optional().describe('Name for the duplicated board'), + workspaceId: z.string().optional().describe('Destination workspace ID'), + folderId: z.string().optional().describe('Destination folder ID'), + keepSubscribers: z + .boolean() + .optional() + .describe('Whether to copy subscribers to the duplicated board') + }) + ) + .output( + z.object({ + boardId: z.string().describe('ID of the duplicated board'), + name: z.string().describe('Duplicated board name'), + description: z.string().nullable().describe('Board description'), + state: z.string().nullable().describe('Board state'), + boardKind: z.string().nullable().describe('Board kind'), + workspaceId: z.string().nullable().describe('Workspace ID') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let board = await client.duplicateBoard({ + boardId: ctx.input.boardId, + duplicateType: ctx.input.duplicateType, + boardName: ctx.input.boardName, + workspaceId: ctx.input.workspaceId, + folderId: ctx.input.folderId, + keepSubscribers: ctx.input.keepSubscribers + }); + + return { + output: { + boardId: String(board.id), + name: board.name, + description: board.description || null, + state: board.state || null, + boardKind: board.board_kind || null, + workspaceId: board.workspace_id ? String(board.workspace_id) : null + }, + message: `Duplicated board ${ctx.input.boardId} as **${board.name}** (ID: ${board.id}).` + }; + }) + .build(); diff --git a/integrations/monday/src/tools/duplicate-item.ts b/integrations/monday/src/tools/duplicate-item.ts new file mode 100644 index 0000000000..3302cca21c --- /dev/null +++ b/integrations/monday/src/tools/duplicate-item.ts @@ -0,0 +1,47 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MondayClient } from '../lib/client'; +import { spec } from '../spec'; + +let duplicatedItemSchema = z.object({ + itemId: z.string().describe('Duplicated item ID'), + name: z.string().describe('Duplicated item name'), + state: z.string().nullable().describe('Item state'), + groupId: z.string().nullable().describe('Group ID'), + groupTitle: z.string().nullable().describe('Group title') +}); + +export let duplicateItemTool = SlateTool.create(spec, { + name: 'Duplicate Item', + key: 'duplicate_item', + description: `Duplicate an item or sub-item, optionally including its updates.` +}) + .input( + z.object({ + boardId: z.string().describe('Board ID containing the item'), + itemId: z.string().describe('Item ID to duplicate'), + withUpdates: z + .boolean() + .optional() + .describe('Whether to duplicate updates asynchronously with the item') + }) + ) + .output(duplicatedItemSchema) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let item = await client.duplicateItem(ctx.input.boardId, ctx.input.itemId, { + withUpdates: ctx.input.withUpdates + }); + + return { + output: { + itemId: String(item.id), + name: item.name, + state: item.state || null, + groupId: item.group?.id || null, + groupTitle: item.group?.title || null + }, + message: `Duplicated item ${ctx.input.itemId} as **${item.name}** (ID: ${item.id}).` + }; + }) + .build(); diff --git a/integrations/monday/src/tools/index.ts b/integrations/monday/src/tools/index.ts index 607a5190fa..77f1d294b6 100644 --- a/integrations/monday/src/tools/index.ts +++ b/integrations/monday/src/tools/index.ts @@ -1,6 +1,8 @@ export * from './create-board'; export * from './create-item'; export * from './create-subitem'; +export * from './duplicate-board'; +export * from './duplicate-item'; export * from './get-activity-logs'; export * from './list-boards'; export * from './list-items'; @@ -8,9 +10,14 @@ export * from './list-tags'; export * from './list-teams'; export * from './list-users'; export * from './manage-columns'; +export * from './manage-folders'; export * from './manage-groups'; export * from './manage-updates'; +export * from './manage-webhooks'; export * from './manage-workspaces'; +export * from './move-board'; +export * from './move-item'; export * from './send-notification'; +export * from './set-item-description'; export * from './update-board'; export * from './update-item'; diff --git a/integrations/monday/src/tools/list-items.ts b/integrations/monday/src/tools/list-items.ts index 6c4bd0337e..409554e3f3 100644 --- a/integrations/monday/src/tools/list-items.ts +++ b/integrations/monday/src/tools/list-items.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { MondayClient } from '../lib/client'; +import { mondayServiceError } from '../lib/errors'; import { spec } from '../spec'; let columnValueSchema = z.object({ @@ -35,14 +36,53 @@ export let listItemsTool = SlateTool.create(spec, { boardId: z.string().optional().describe('Board ID to list items from'), itemIds: z .array(z.string()) + .max(100) .optional() .describe('Specific item IDs to retrieve (up to 100)'), groupId: z.string().optional().describe('Filter items by group ID (requires boardId)'), limit: z .number() + .int() + .min(1) + .max(500) .optional() - .describe('Maximum number of items to return (default: 500)'), - cursor: z.string().optional().describe('Pagination cursor from a previous response') + .describe('Maximum number of items to return (default: 25, max: 500)'), + cursor: z.string().optional().describe('Pagination cursor from a previous response'), + filters: z + .array( + z.object({ + columnId: z + .string() + .describe('Column ID to filter, such as name, group, status, people, or date'), + compareValue: z + .any() + .optional() + .describe('Value or values to compare against for this filter rule'), + operator: z + .string() + .optional() + .describe('monday.com filter operator, such as any_of or contains_text') + }) + ) + .optional() + .describe('items_page filter rules. Cannot be used together with cursor.'), + filterOperator: z + .enum(['and', 'or']) + .optional() + .describe('How to combine filter rules when filters are provided'), + orderBy: z + .array( + z.object({ + columnId: z.string().describe('Column ID to sort by'), + direction: z.enum(['asc', 'desc']).optional().describe('Sort direction') + }) + ) + .optional() + .describe('items_page order_by rules. Cannot be used together with cursor.'), + hierarchyScopeConfig: z + .enum(['allItems', 'parentItems']) + .optional() + .describe('How multi-level board hierarchy is handled while filtering') }) ) .output( @@ -57,17 +97,43 @@ export let listItemsTool = SlateTool.create(spec, { let cursor: string | null = null; if (ctx.input.itemIds?.length) { + if (ctx.input.boardId || ctx.input.groupId || ctx.input.filters || ctx.input.orderBy) { + throw mondayServiceError( + 'itemIds cannot be combined with boardId, groupId, filters, or orderBy.' + ); + } items = await client.getItems(ctx.input.itemIds); } else if (ctx.input.boardId) { + if (ctx.input.cursor && (ctx.input.filters?.length || ctx.input.orderBy?.length)) { + throw mondayServiceError( + 'monday.com items_page does not allow cursor together with filters or orderBy.' + ); + } let result = await client.getBoardItems(ctx.input.boardId, { limit: ctx.input.limit, cursor: ctx.input.cursor, - groupId: ctx.input.groupId + groupId: ctx.input.groupId, + hierarchyScopeConfig: ctx.input.hierarchyScopeConfig, + queryParams: + ctx.input.filters?.length || ctx.input.orderBy?.length + ? { + rules: ctx.input.filters?.map(filter => ({ + column_id: filter.columnId, + compare_value: filter.compareValue ?? null, + ...(filter.operator ? { operator: filter.operator } : {}) + })), + operator: ctx.input.filterOperator, + order_by: ctx.input.orderBy?.map(order => ({ + column_id: order.columnId, + ...(order.direction ? { direction: order.direction } : {}) + })) + } + : undefined }); items = result.items; cursor = result.cursor; } else { - throw new Error('Either boardId or itemIds must be provided'); + throw mondayServiceError('Either boardId or itemIds must be provided.'); } let mapped = items.map((item: any) => ({ diff --git a/integrations/monday/src/tools/manage-columns.ts b/integrations/monday/src/tools/manage-columns.ts index e0e2a60a1d..777a18eb66 100644 --- a/integrations/monday/src/tools/manage-columns.ts +++ b/integrations/monday/src/tools/manage-columns.ts @@ -8,7 +8,10 @@ let columnSchema = z.object({ title: z.string().describe('Column title'), type: z.string().describe('Column type'), description: z.string().nullable().describe('Column description'), - settings: z.string().nullable().describe('Column settings as JSON string') + settings: z.any().nullable().describe('Column settings as JSON'), + revision: z.string().nullable().describe('Column revision'), + width: z.number().nullable().describe('Column width'), + archived: z.boolean().nullable().describe('Whether the column is archived') }); export let listColumnsTool = SlateTool.create(spec, { @@ -36,7 +39,10 @@ export let listColumnsTool = SlateTool.create(spec, { title: c.title, type: c.type, description: c.description || null, - settings: c.settings_str || null + settings: c.settings ?? null, + revision: c.revision || null, + width: c.width ?? null, + archived: c.archived ?? null })); return { @@ -63,9 +69,17 @@ export let createColumnTool = SlateTool.create(spec, { .describe('Monday.com column type identifier (e.g., status, text, numbers, date)'), description: z.string().optional().describe('Column description'), defaults: z + .any() + .optional() + .describe('JSON object or string of default settings for the column'), + afterColumnId: z + .string() + .optional() + .describe('Column ID after which the new column should be inserted'), + customColumnId: z .string() .optional() - .describe('JSON string of default settings for the column') + .describe('Optional user-specified column ID, 1-20 lowercase letters/underscores') }) ) .output( @@ -73,7 +87,9 @@ export let createColumnTool = SlateTool.create(spec, { columnId: z.string().describe('ID of the created column'), title: z.string().describe('Column title'), type: z.string().describe('Column type'), - description: z.string().nullable().describe('Column description') + description: z.string().nullable().describe('Column description'), + settings: z.any().nullable().describe('Column settings as JSON'), + revision: z.string().nullable().describe('Column revision') }) ) .handleInvocation(async ctx => { @@ -85,7 +101,9 @@ export let createColumnTool = SlateTool.create(spec, { ctx.input.columnType, { description: ctx.input.description, - defaults: ctx.input.defaults + defaults: ctx.input.defaults, + afterColumnId: ctx.input.afterColumnId, + customColumnId: ctx.input.customColumnId } ); @@ -94,9 +112,86 @@ export let createColumnTool = SlateTool.create(spec, { columnId: column.id, title: column.title, type: column.type, - description: column.description || null + description: column.description || null, + settings: column.settings ?? null, + revision: column.revision || null }, message: `Created column **${column.title}** (type: ${column.type}) on board ${ctx.input.boardId}.` }; }) .build(); + +export let updateColumnMetadataTool = SlateTool.create(spec, { + name: 'Update Column Metadata', + key: 'update_column_metadata', + description: `Update a column's title or description without changing item values.` +}) + .input( + z.object({ + boardId: z.string().describe('Board ID containing the column'), + columnId: z.string().describe('Column ID to update'), + property: z + .enum(['title', 'description']) + .describe('Column metadata property to update'), + value: z.string().describe('New title or description value') + }) + ) + .output(columnSchema) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + + let column = await client.changeColumnMetadata( + ctx.input.boardId, + ctx.input.columnId, + ctx.input.property, + ctx.input.value + ); + + return { + output: { + columnId: column.id, + title: column.title, + type: column.type, + description: column.description || null, + settings: column.settings ?? null, + revision: column.revision || null, + width: column.width ?? null, + archived: column.archived ?? null + }, + message: `Updated ${ctx.input.property} for column ${ctx.input.columnId}.` + }; + }) + .build(); + +export let deleteColumnTool = SlateTool.create(spec, { + name: 'Delete Column', + key: 'delete_column', + description: `Delete a column from a board.`, + tags: { destructive: true } +}) + .input( + z.object({ + boardId: z.string().describe('Board ID containing the column'), + columnId: z.string().describe('Column ID to delete') + }) + ) + .output( + z.object({ + columnId: z.string().describe('Deleted column ID'), + success: z.boolean().describe('Whether the deletion succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + + let column = await client.deleteColumn(ctx.input.boardId, ctx.input.columnId); + + return { + output: { + columnId: String(column.id ?? ctx.input.columnId), + success: true + }, + message: `Deleted column ${ctx.input.columnId}.` + }; + }) + .build(); diff --git a/integrations/monday/src/tools/manage-folders.ts b/integrations/monday/src/tools/manage-folders.ts new file mode 100644 index 0000000000..c23d22bf8c --- /dev/null +++ b/integrations/monday/src/tools/manage-folders.ts @@ -0,0 +1,219 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MondayClient } from '../lib/client'; +import { mondayServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let folderSchema = z.object({ + folderId: z.string().describe('Folder ID'), + name: z.string().describe('Folder name'), + color: z.string().nullable().describe('Folder color'), + createdAt: z.string().nullable().describe('Creation timestamp'), + ownerId: z.string().nullable().describe('Owner user ID'), + workspaceId: z.string().nullable().describe('Workspace ID'), + workspaceName: z.string().nullable().describe('Workspace name'), + parentFolderId: z.string().nullable().describe('Parent folder ID'), + subFolderIds: z.array(z.string()).describe('Nested subfolder IDs'), + boardIds: z.array(z.string()).describe('Board IDs in the folder') +}); + +let mapFolder = (folder: any) => ({ + folderId: String(folder.id), + name: folder.name, + color: folder.color || null, + createdAt: folder.created_at || null, + ownerId: folder.owner_id ? String(folder.owner_id) : null, + workspaceId: folder.workspace?.id ? String(folder.workspace.id) : null, + workspaceName: folder.workspace?.name || null, + parentFolderId: folder.parent?.id ? String(folder.parent.id) : null, + subFolderIds: (folder.sub_folders || []).map((subfolder: any) => String(subfolder.id)), + boardIds: (folder.children || []).map((board: any) => String(board.id)) +}); + +export let listFoldersTool = SlateTool.create(spec, { + name: 'List Folders', + key: 'list_folders', + description: `List monday.com workspace folders. Use includeMainWorkspace to request folders in the Main Workspace.`, + tags: { readOnly: true } +}) + .input( + z.object({ + folderIds: z.array(z.string()).optional().describe('Specific folder IDs to retrieve'), + workspaceIds: z + .array(z.string()) + .optional() + .describe('Workspace IDs whose folders should be returned'), + includeMainWorkspace: z + .boolean() + .optional() + .describe('Include folders in the Main Workspace by passing null workspace ID'), + limit: z.number().int().min(1).max(100).optional().describe('Max folders to return'), + page: z.number().int().min(1).optional().describe('Page number starting at 1') + }) + ) + .output( + z.object({ + folders: z.array(folderSchema).describe('Folders') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let workspaceIds: (string | null)[] = ctx.input.workspaceIds + ? [...ctx.input.workspaceIds] + : []; + if (ctx.input.includeMainWorkspace) workspaceIds.push(null); + + let folders = await client.getFolders({ + ids: ctx.input.folderIds, + workspaceIds: workspaceIds as (string | null)[], + limit: ctx.input.limit, + page: ctx.input.page + }); + + let mapped = folders.map(mapFolder); + + return { + output: { folders: mapped }, + message: `Found **${mapped.length}** folder(s).` + }; + }) + .build(); + +export let createFolderTool = SlateTool.create(spec, { + name: 'Create Folder', + key: 'create_folder', + description: `Create a folder in a workspace or the Main Workspace.` +}) + .input( + z.object({ + name: z.string().describe('Folder name'), + workspaceId: z + .string() + .optional() + .describe('Workspace ID. Omit to create in the Main Workspace.'), + parentFolderId: z.string().optional().describe('Parent folder ID for nesting'), + color: z.string().optional().describe('monday.com FolderColor enum value') + }) + ) + .output(folderSchema) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let folder = await client.createFolder({ + name: ctx.input.name, + workspaceId: ctx.input.workspaceId, + parentFolderId: ctx.input.parentFolderId, + color: ctx.input.color + }); + + return { + output: mapFolder(folder), + message: `Created folder **${folder.name}** (ID: ${folder.id}).` + }; + }) + .build(); + +export let updateFolderTool = SlateTool.create(spec, { + name: 'Update Folder', + key: 'update_folder', + description: `Update folder metadata, workspace, parent, or menu position.` +}) + .input( + z.object({ + folderId: z.string().describe('Folder ID to update'), + name: z.string().optional().describe('New folder name'), + workspaceId: z.string().optional().describe('Workspace ID to move the folder to'), + parentFolderId: z.string().optional().describe('Parent folder ID for nesting'), + color: z.string().optional().describe('monday.com FolderColor enum value'), + customIcon: z.string().optional().describe('monday.com FolderCustomIcon enum value'), + fontWeight: z + .enum([ + 'FONT_WEIGHT_BOLD', + 'FONT_WEIGHT_LIGHT', + 'FONT_WEIGHT_NORMAL', + 'FONT_WEIGHT_VERY_LIGHT', + 'NULL' + ]) + .optional() + .describe('Folder font weight'), + accountProductId: z.string().optional().describe('Product ID to move the folder to'), + positionObjectId: z + .string() + .optional() + .describe('Object ID to position this folder before or after'), + positionObjectType: z + .enum(['Board', 'Folder', 'Overview']) + .optional() + .describe('Type of object referenced by positionObjectId'), + positionIsAfter: z + .boolean() + .optional() + .describe('Whether to place the folder after the referenced object') + }) + ) + .output(folderSchema) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let hasPosition = + ctx.input.positionObjectId !== undefined || ctx.input.positionObjectType !== undefined; + + if (hasPosition && (!ctx.input.positionObjectId || !ctx.input.positionObjectType)) { + throw mondayServiceError( + 'positionObjectId and positionObjectType must be provided together.' + ); + } + + let folder = await client.updateFolder({ + folderId: ctx.input.folderId, + name: ctx.input.name, + workspaceId: ctx.input.workspaceId, + parentFolderId: ctx.input.parentFolderId, + color: ctx.input.color, + customIcon: ctx.input.customIcon, + fontWeight: ctx.input.fontWeight, + accountProductId: ctx.input.accountProductId, + position: hasPosition + ? { + object_id: ctx.input.positionObjectId as string, + object_type: ctx.input.positionObjectType as string, + is_after: ctx.input.positionIsAfter + } + : undefined + }); + + return { + output: mapFolder(folder), + message: `Updated folder ${ctx.input.folderId}.` + }; + }) + .build(); + +export let deleteFolderTool = SlateTool.create(spec, { + name: 'Delete Folder', + key: 'delete_folder', + description: `Delete a folder and its contents.`, + tags: { destructive: true } +}) + .input( + z.object({ + folderId: z.string().describe('Folder ID to delete') + }) + ) + .output( + z.object({ + folderId: z.string().describe('Deleted folder ID'), + success: z.boolean().describe('Whether the deletion succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let folder = await client.deleteFolder(ctx.input.folderId); + + return { + output: { + folderId: String(folder.id ?? ctx.input.folderId), + success: true + }, + message: `Deleted folder ${ctx.input.folderId}.` + }; + }) + .build(); diff --git a/integrations/monday/src/tools/manage-updates.ts b/integrations/monday/src/tools/manage-updates.ts index 579d7e3688..fefd946ae4 100644 --- a/integrations/monday/src/tools/manage-updates.ts +++ b/integrations/monday/src/tools/manage-updates.ts @@ -158,3 +158,141 @@ export let deleteUpdateTool = SlateTool.create(spec, { }; }) .build(); + +export let editUpdateTool = SlateTool.create(spec, { + name: 'Edit Update', + key: 'edit_update', + description: `Edit the body of an existing update.` +}) + .input( + z.object({ + updateId: z.string().describe('ID of the update to edit'), + body: z.string().describe('New update body content (supports HTML)') + }) + ) + .output( + z.object({ + updateId: z.string().describe('ID of the edited update'), + body: z.string().describe('Updated body'), + textBody: z.string().nullable().describe('Plain text body') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let update = await client.editUpdate(ctx.input.updateId, ctx.input.body); + + return { + output: { + updateId: String(update.id), + body: update.body, + textBody: update.text_body || null + }, + message: `Edited update ${ctx.input.updateId}.` + }; + }) + .build(); + +export let reactToUpdateTool = SlateTool.create(spec, { + name: 'React To Update', + key: 'react_to_update', + description: `Like or unlike an update.` +}) + .input( + z.object({ + updateId: z.string().describe('ID of the update'), + action: z.enum(['like', 'unlike']).describe('Reaction action to perform') + }) + ) + .output( + z.object({ + updateId: z.string().describe('Update ID'), + action: z.string().describe('Reaction action performed'), + success: z.boolean().describe('Whether the operation succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + + if (ctx.input.action === 'like') { + await client.likeUpdate(ctx.input.updateId); + } else { + await client.unlikeUpdate(ctx.input.updateId); + } + + return { + output: { + updateId: ctx.input.updateId, + action: ctx.input.action, + success: true + }, + message: `${ctx.input.action === 'like' ? 'Liked' : 'Unliked'} update ${ctx.input.updateId}.` + }; + }) + .build(); + +export let pinUpdateTool = SlateTool.create(spec, { + name: 'Pin Update', + key: 'pin_update', + description: `Pin or unpin an update at the top of an item.` +}) + .input( + z.object({ + updateId: z.string().describe('ID of the update'), + itemId: z.string().optional().describe('Item ID for the update'), + action: z.enum(['pin', 'unpin']).describe('Pin action to perform') + }) + ) + .output( + z.object({ + updateId: z.string().describe('Update ID'), + action: z.string().describe('Pin action performed'), + success: z.boolean().describe('Whether the operation succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + + if (ctx.input.action === 'pin') { + await client.pinUpdate(ctx.input.updateId, ctx.input.itemId); + } else { + await client.unpinUpdate(ctx.input.updateId, ctx.input.itemId); + } + + return { + output: { + updateId: ctx.input.updateId, + action: ctx.input.action, + success: true + }, + message: `${ctx.input.action === 'pin' ? 'Pinned' : 'Unpinned'} update ${ctx.input.updateId}.` + }; + }) + .build(); + +export let clearItemUpdatesTool = SlateTool.create(spec, { + name: 'Clear Item Updates', + key: 'clear_item_updates', + description: `Delete all updates, replies, and likes from an item.`, + tags: { destructive: true } +}) + .input( + z.object({ + itemId: z.string().describe('ID of the item whose updates should be cleared') + }) + ) + .output( + z.object({ + itemId: z.string().describe('Item ID'), + success: z.boolean().describe('Whether the operation succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + await client.clearItemUpdates(ctx.input.itemId); + + return { + output: { itemId: ctx.input.itemId, success: true }, + message: `Cleared updates from item ${ctx.input.itemId}.` + }; + }) + .build(); diff --git a/integrations/monday/src/tools/manage-webhooks.ts b/integrations/monday/src/tools/manage-webhooks.ts new file mode 100644 index 0000000000..e35cbdba48 --- /dev/null +++ b/integrations/monday/src/tools/manage-webhooks.ts @@ -0,0 +1,146 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MondayClient } from '../lib/client'; +import { spec } from '../spec'; + +let webhookEventSchema = z.enum([ + 'change_column_value', + 'change_status_column_value', + 'change_subitem_column_value', + 'change_specific_column_value', + 'change_name', + 'create_item', + 'item_archived', + 'item_deleted', + 'item_moved_to_any_group', + 'item_moved_to_specific_group', + 'item_restored', + 'create_subitem', + 'change_subitem_name', + 'move_subitem', + 'subitem_archived', + 'subitem_deleted', + 'create_column', + 'create_update', + 'edit_update', + 'delete_update', + 'create_subitem_update' +]); + +let webhookSchema = z.object({ + webhookId: z.string().describe('Webhook ID'), + boardId: z.string().describe('Board ID'), + event: z.string().nullable().describe('Subscribed event'), + config: z.string().nullable().describe('Webhook config JSON string') +}); + +let mapWebhook = (webhook: any) => ({ + webhookId: String(webhook.id), + boardId: String(webhook.board_id), + event: webhook.event || null, + config: webhook.config || null +}); + +export let listWebhooksTool = SlateTool.create(spec, { + name: 'List Webhooks', + key: 'list_webhooks', + description: `List webhooks configured on a board.`, + tags: { readOnly: true } +}) + .input( + z.object({ + boardId: z.string().describe('Board ID whose webhooks should be listed'), + appWebhooksOnly: z + .boolean() + .optional() + .describe('Return only webhooks created by the calling app') + }) + ) + .output( + z.object({ + webhooks: z.array(webhookSchema).describe('Webhooks') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let webhooks = await client.getWebhooks(ctx.input.boardId, { + appWebhooksOnly: ctx.input.appWebhooksOnly + }); + let mapped = webhooks.map(mapWebhook); + + return { + output: { webhooks: mapped }, + message: `Found **${mapped.length}** webhook(s) on board ${ctx.input.boardId}.` + }; + }) + .build(); + +export let createWebhookTool = SlateTool.create(spec, { + name: 'Create Webhook', + key: 'create_webhook', + description: `Create a webhook subscription on a board. The URL must respond to monday.com's challenge verification.` +}) + .input( + z.object({ + boardId: z.string().describe('Board ID to subscribe to'), + url: z + .string() + .url() + .max(255) + .describe('Webhook callback URL that can pass monday.com verification'), + event: webhookEventSchema.describe('Webhook event type'), + config: z + .record(z.string(), z.any()) + .optional() + .describe('Optional event-specific webhook config') + }) + ) + .output(webhookSchema) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let webhook = await client.createWebhook( + ctx.input.boardId, + ctx.input.url, + ctx.input.event, + ctx.input.config + ); + + return { + output: mapWebhook(webhook), + message: `Created webhook ${webhook.id} on board ${ctx.input.boardId}.` + }; + }) + .build(); + +export let deleteWebhookTool = SlateTool.create(spec, { + name: 'Delete Webhook', + key: 'delete_webhook', + description: `Delete a webhook subscription.`, + tags: { destructive: true } +}) + .input( + z.object({ + webhookId: z.string().describe('Webhook ID to delete') + }) + ) + .output( + z.object({ + webhookId: z.string().describe('Deleted webhook ID'), + boardId: z.string().nullable().describe('Board ID'), + success: z.boolean().describe('Whether the deletion succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let webhook = await client.deleteWebhook(ctx.input.webhookId); + + return { + output: { + webhookId: String(webhook.id ?? ctx.input.webhookId), + boardId: webhook.board_id ? String(webhook.board_id) : null, + success: true + }, + message: `Deleted webhook ${ctx.input.webhookId}.` + }; + }) + .build(); diff --git a/integrations/monday/src/tools/move-board.ts b/integrations/monday/src/tools/move-board.ts new file mode 100644 index 0000000000..a94340a87e --- /dev/null +++ b/integrations/monday/src/tools/move-board.ts @@ -0,0 +1,81 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MondayClient } from '../lib/client'; +import { mondayServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +export let moveBoardTool = SlateTool.create(spec, { + name: 'Move Board', + key: 'move_board', + description: `Move a board to another workspace or folder, or change its hierarchy position.` +}) + .input( + z.object({ + boardId: z.string().describe('ID of the board to move'), + workspaceId: z.string().optional().describe('Destination workspace ID'), + folderId: z.string().optional().describe('Destination folder ID'), + accountProductId: z.string().optional().describe('Destination product ID'), + positionObjectId: z + .string() + .optional() + .describe('Object ID to position this board before or after'), + positionObjectType: z + .enum(['Board', 'Folder', 'Overview']) + .optional() + .describe('Type of object referenced by positionObjectId'), + positionIsAfter: z + .boolean() + .optional() + .describe('Whether to place the board after the referenced object') + }) + ) + .output( + z.object({ + boardId: z.string().describe('Moved board ID'), + success: z.boolean().describe('Whether the hierarchy update succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let hasPosition = + ctx.input.positionObjectId !== undefined || ctx.input.positionObjectType !== undefined; + + if (hasPosition && (!ctx.input.positionObjectId || !ctx.input.positionObjectType)) { + throw mondayServiceError( + 'positionObjectId and positionObjectType must be provided together.' + ); + } + + if ( + !ctx.input.workspaceId && + !ctx.input.folderId && + !ctx.input.accountProductId && + !hasPosition + ) { + throw mondayServiceError( + 'Provide at least one destination field or position field to move a board.' + ); + } + + let result = await client.updateBoardHierarchy(ctx.input.boardId, { + workspace_id: ctx.input.workspaceId, + folder_id: ctx.input.folderId, + account_product_id: ctx.input.accountProductId, + position: hasPosition + ? { + object_id: ctx.input.positionObjectId as string, + object_type: ctx.input.positionObjectType as string, + is_after: ctx.input.positionIsAfter + } + : undefined + }); + + return { + output: { + boardId: ctx.input.boardId, + success: result?.success === true + }, + message: `Moved board ${ctx.input.boardId}.` + }; + }) + .build(); diff --git a/integrations/monday/src/tools/move-item.ts b/integrations/monday/src/tools/move-item.ts new file mode 100644 index 0000000000..87b8998b84 --- /dev/null +++ b/integrations/monday/src/tools/move-item.ts @@ -0,0 +1,114 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MondayClient } from '../lib/client'; +import { mondayServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let columnMappingSchema = z.object({ + source: z.string().describe('Source column ID'), + target: z.string().nullable().optional().describe('Target column ID, or null to drop data') +}); + +export let moveItemTool = SlateTool.create(spec, { + name: 'Move Item', + key: 'move_item', + description: `Move an item within its board, to a group, or to another board.` +}) + .input( + z.object({ + itemId: z.string().describe('Item ID to move'), + target: z + .enum(['group', 'position', 'board']) + .describe('Move target: group, position on the same board, or another board'), + groupId: z + .string() + .optional() + .describe('Group ID for target=group, target=board, or group-top positioning'), + boardId: z.string().optional().describe('Target board ID for target=board'), + relativeTo: z + .string() + .optional() + .describe('Item ID to position relative to for target=position'), + positionRelativeMethod: z + .enum(['before_at', 'after_at']) + .optional() + .describe('Whether to place before or after relativeTo for target=position'), + groupTop: z + .boolean() + .optional() + .describe('When target=position with groupId, true places at top, false at bottom'), + columnsMapping: z + .array(columnMappingSchema) + .optional() + .describe('Column mappings when moving to another board'), + subitemsColumnsMapping: z + .array(columnMappingSchema) + .optional() + .describe('Subitem column mappings when moving to another board') + }) + ) + .output( + z.object({ + itemId: z.string().describe('Moved item ID'), + name: z.string().nullable().describe('Moved item name'), + groupId: z.string().nullable().describe('Resulting group ID'), + boardId: z.string().nullable().describe('Resulting board ID'), + success: z.boolean().describe('Whether the operation succeeded') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let item: any; + + if (ctx.input.target === 'group') { + if (!ctx.input.groupId) { + throw mondayServiceError('groupId is required when target is group.'); + } + item = await client.moveItemToGroup(ctx.input.itemId, ctx.input.groupId); + } else if (ctx.input.target === 'position') { + if (ctx.input.relativeTo || ctx.input.positionRelativeMethod) { + if (!ctx.input.relativeTo || !ctx.input.positionRelativeMethod) { + throw mondayServiceError( + 'relativeTo and positionRelativeMethod must be provided together.' + ); + } + item = await client.changeItemPosition(ctx.input.itemId, { + relativeTo: ctx.input.relativeTo, + positionRelativeMethod: ctx.input.positionRelativeMethod + }); + } else { + if (!ctx.input.groupId || ctx.input.groupTop === undefined) { + throw mondayServiceError( + 'For group-top positioning, groupId and groupTop must be provided together.' + ); + } + item = await client.changeItemPosition(ctx.input.itemId, { + groupId: ctx.input.groupId, + groupTop: ctx.input.groupTop + }); + } + } else { + if (!ctx.input.boardId || !ctx.input.groupId) { + throw mondayServiceError('boardId and groupId are required when target is board.'); + } + item = await client.moveItemToBoard({ + itemId: ctx.input.itemId, + boardId: ctx.input.boardId, + groupId: ctx.input.groupId, + columnsMapping: ctx.input.columnsMapping, + subitemsColumnsMapping: ctx.input.subitemsColumnsMapping + }); + } + + return { + output: { + itemId: String(item.id ?? ctx.input.itemId), + name: item.name || null, + groupId: item.group?.id || ctx.input.groupId || null, + boardId: item.board?.id ? String(item.board.id) : ctx.input.boardId || null, + success: true + }, + message: `Moved item ${ctx.input.itemId}.` + }; + }) + .build(); diff --git a/integrations/monday/src/tools/set-item-description.ts b/integrations/monday/src/tools/set-item-description.ts new file mode 100644 index 0000000000..c5632d10cf --- /dev/null +++ b/integrations/monday/src/tools/set-item-description.ts @@ -0,0 +1,39 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { MondayClient } from '../lib/client'; +import { spec } from '../spec'; + +export let setItemDescriptionTool = SlateTool.create(spec, { + name: 'Set Item Description', + key: 'set_item_description', + description: `Replace an item's 2026-04 markdown description content.` +}) + .input( + z.object({ + itemId: z.string().describe('Item ID whose description should be replaced'), + markdown: z.string().describe('Markdown content for the item description') + }) + ) + .output( + z.object({ + itemId: z.string().describe('Item ID'), + success: z.boolean().describe('Whether monday.com accepted the markdown'), + error: z.string().nullable().describe('monday.com conversion error, if any'), + blockIds: z.array(z.string()).describe('Created description block IDs') + }) + ) + .handleInvocation(async ctx => { + let client = new MondayClient({ token: ctx.auth.token }); + let result = await client.setItemDescriptionContent(ctx.input.itemId, ctx.input.markdown); + + return { + output: { + itemId: ctx.input.itemId, + success: result.success === true, + error: result.error || null, + blockIds: (result.block_ids || []).map(String) + }, + message: `Updated description for item ${ctx.input.itemId}.` + }; + }) + .build(); diff --git a/integrations/monday/src/triggers/column-value-changes.ts b/integrations/monday/src/triggers/column-value-changes.ts index da3506fad9..e9ccf33ced 100644 --- a/integrations/monday/src/triggers/column-value-changes.ts +++ b/integrations/monday/src/triggers/column-value-changes.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { MondayClient } from '../lib/client'; +import { mondayServiceError } from '../lib/errors'; import { spec } from '../spec'; export let columnValueChangesTrigger = SlateTrigger.create(spec, { @@ -47,7 +48,7 @@ export let columnValueChangesTrigger = SlateTrigger.create(spec, { let boardId = url.searchParams.get('boardId'); if (!boardId) { - throw new Error( + throw mondayServiceError( 'Board ID is required. Configure the boardId query parameter on the webhook URL.' ); } diff --git a/integrations/monday/src/triggers/item-events.ts b/integrations/monday/src/triggers/item-events.ts index 911b0d3265..bac526f7b7 100644 --- a/integrations/monday/src/triggers/item-events.ts +++ b/integrations/monday/src/triggers/item-events.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { MondayClient } from '../lib/client'; +import { mondayServiceError } from '../lib/errors'; import { spec } from '../spec'; export let itemEventsTrigger = SlateTrigger.create(spec, { @@ -58,7 +59,7 @@ export let itemEventsTrigger = SlateTrigger.create(spec, { let boardId = url.searchParams.get('boardId'); if (!boardId) { - throw new Error( + throw mondayServiceError( 'Board ID is required. Configure the boardId query parameter on the webhook URL.' ); } diff --git a/integrations/monday/src/triggers/update-events.ts b/integrations/monday/src/triggers/update-events.ts index 817716befb..ccc900db3d 100644 --- a/integrations/monday/src/triggers/update-events.ts +++ b/integrations/monday/src/triggers/update-events.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { MondayClient } from '../lib/client'; +import { mondayServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateEventsTrigger = SlateTrigger.create(spec, { @@ -41,7 +42,7 @@ export let updateEventsTrigger = SlateTrigger.create(spec, { let boardId = url.searchParams.get('boardId'); if (!boardId) { - throw new Error( + throw mondayServiceError( 'Board ID is required. Configure the boardId query parameter on the webhook URL.' ); } diff --git a/integrations/monday/vitest.config.ts b/integrations/monday/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/monday/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/mongodb-atlas/README.md b/integrations/mongodb-atlas/README.md index 3d5ee91d4a..17141cc797 100644 --- a/integrations/mongodb-atlas/README.md +++ b/integrations/mongodb-atlas/README.md @@ -16,6 +16,10 @@ Retrieve performance optimization recommendations from the Atlas Performance Adv Retrieve audit events for a MongoDB Atlas project or organization. Track changes like cluster creation/deletion, user modifications, alert triggers, backup events, and other administrative operations. +### List Access Logs + +Return MongoDB Atlas database access history for one cluster. Use this for security review, troubleshooting failed authentications, and investigating recent database connection activity. + ### List Projects Lists all MongoDB Atlas projects (groups) accessible to the authenticated user. Can also retrieve details of a specific organization. Use this to discover available projects and their IDs for use with other tools. @@ -36,10 +40,18 @@ Create, update, or retrieve MongoDB Atlas clusters. Supports dedicated (M10+), a Create, update, list, or delete database users in a MongoDB Atlas project. Configure authentication methods (SCRAM, X.509, AWS IAM, LDAP, OIDC), assign roles for fine-grained access control, and scope users to specific clusters. +### Manage Flex Cluster + +Create, update, list, retrieve, or delete MongoDB Atlas Flex clusters. Flex clusters use the current Atlas flexClusters API for low-cost, elastic deployments separate from dedicated cluster endpoints. + ### Manage IP Access List Manage the IP access list (whitelist) that controls which IP addresses can connect to Atlas clusters in a project. Add, list, or remove IP addresses, CIDR blocks, or AWS security groups. +### Manage Maintenance Window + +Get, update, reset, or defer the MongoDB Atlas maintenance window for a project. Use this to control when Atlas applies scheduled maintenance and to defer the next maintenance window when allowed. + ### Manage Network Peering Manage VPC/VNet network peering connections between MongoDB Atlas and your cloud provider (AWS, Azure, GCP). List, create, view, or delete peering connections to enable private network communication with your clusters. diff --git a/integrations/mongodb-atlas/package.json b/integrations/mongodb-atlas/package.json index ee4efb6a2b..f3a7b5016b 100644 --- a/integrations/mongodb-atlas/package.json +++ b/integrations/mongodb-atlas/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/mongodb-atlas/src/auth.ts b/integrations/mongodb-atlas/src/auth.ts index 52710357b7..ccd8539745 100644 --- a/integrations/mongodb-atlas/src/auth.ts +++ b/integrations/mongodb-atlas/src/auth.ts @@ -1,10 +1,43 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { atlasApiError, atlasServiceError } from './lib/errors'; let atlasOAuthAxios = createAxios({ baseURL: 'https://cloud.mongodb.com' }); +let requestServiceAccountToken = async (clientId: string, clientSecret: string) => { + try { + let params = new URLSearchParams({ + grant_type: 'client_credentials' + }); + + let credentials = btoa(`${clientId}:${clientSecret}`); + + let response = await atlasOAuthAxios.post('/api/oauth/token', params.toString(), { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + } + }); + + let tokenData = response.data; + if (!tokenData?.access_token || typeof tokenData.expires_in !== 'number') { + throw atlasServiceError( + 'MongoDB Atlas OAuth token response did not include access_token and expires_in.' + ); + } + + return { + accessToken: tokenData.access_token as string, + expiresAt: new Date(Date.now() + tokenData.expires_in * 1000).toISOString() + }; + } catch (error) { + throw atlasApiError(error, 'OAuth token exchange'); + } +}; + export let auth = SlateAuth.create() .output( z.object({ @@ -34,22 +67,10 @@ export let auth = SlateAuth.create() // We exchange client credentials directly for an access token. // The "authorization URL" step is not applicable for client_credentials, // but the framework requires it. We use a special redirect to signal direct token exchange. - let params = new URLSearchParams({ - grant_type: 'client_credentials' - }); - - let credentials = btoa(`${ctx.clientId}:${ctx.clientSecret}`); - - let response = await atlasOAuthAxios.post('/api/oauth/token', params.toString(), { - headers: { - Authorization: `Basic ${credentials}`, - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json' - } - }); - - let tokenData = response.data; - let expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString(); + let { accessToken, expiresAt } = await requestServiceAccountToken( + ctx.clientId, + ctx.clientSecret + ); // For client_credentials, we redirect to the callback with the token encoded let callbackUrl = new URL(ctx.redirectUri); @@ -59,7 +80,7 @@ export let auth = SlateAuth.create() return { url: callbackUrl.toString(), callbackState: { - accessToken: tokenData.access_token, + accessToken, expiresAt } }; @@ -68,6 +89,11 @@ export let auth = SlateAuth.create() handleCallback: async ctx => { let accessToken = ctx.callbackState?.accessToken as string; let expiresAt = ctx.callbackState?.expiresAt as string; + if (!accessToken || !expiresAt) { + throw atlasServiceError( + 'MongoDB Atlas OAuth callback state did not include an access token.' + ); + } return { output: { @@ -79,25 +105,14 @@ export let auth = SlateAuth.create() }, handleTokenRefresh: async (ctx: any) => { - let credentials = btoa(`${ctx.clientId}:${ctx.clientSecret}`); - let params = new URLSearchParams({ - grant_type: 'client_credentials' - }); - - let response = await atlasOAuthAxios.post('/api/oauth/token', params.toString(), { - headers: { - Authorization: `Basic ${credentials}`, - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json' - } - }); - - let tokenData = response.data; - let expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString(); + let { accessToken, expiresAt } = await requestServiceAccountToken( + ctx.clientId, + ctx.clientSecret + ); return { output: { - token: tokenData.access_token, + token: accessToken, authMethod: 'oauth' as const, expiresAt } diff --git a/integrations/mongodb-atlas/src/index.ts b/integrations/mongodb-atlas/src/index.ts index 236ce7df87..4111781cef 100644 --- a/integrations/mongodb-atlas/src/index.ts +++ b/integrations/mongodb-atlas/src/index.ts @@ -3,13 +3,16 @@ import { spec } from './spec'; import { getClusterMetricsTool, getPerformanceAdvisorTool, + listAccessLogsTool, listEventsTool, listProjectsTool, manageAlertsTool, manageBackupsTool, manageClusterTool, manageDatabaseUserTool, + manageFlexClusterTool, manageIpAccessListTool, + manageMaintenanceWindowTool, manageNetworkPeeringTool, manageOnlineArchiveTool, manageSearchIndexesTool @@ -26,9 +29,12 @@ export let provider = Slate.create({ manageAlertsTool, manageBackupsTool, getClusterMetricsTool, + listAccessLogsTool, manageSearchIndexesTool, getPerformanceAdvisorTool, + manageFlexClusterTool, manageNetworkPeeringTool, + manageMaintenanceWindowTool, listEventsTool, manageOnlineArchiveTool ], diff --git a/integrations/mongodb-atlas/src/lib/client.ts b/integrations/mongodb-atlas/src/lib/client.ts index cc22c4e279..325bad91fa 100644 --- a/integrations/mongodb-atlas/src/lib/client.ts +++ b/integrations/mongodb-atlas/src/lib/client.ts @@ -1,5 +1,6 @@ import { createAxios } from 'slates'; import { buildDigestHeader, parseDigestChallenge } from './digest'; +import { atlasApiError, atlasServiceError } from './errors'; let BASE_URL = 'https://cloud.mongodb.com/api/atlas/v2'; let ACCEPT_HEADER = 'application/vnd.atlas.2025-03-12+json'; @@ -28,31 +29,36 @@ export class Client { data?: any, params?: Record ): Promise { - let headers: Record = { - Accept: ACCEPT_HEADER - }; + try { + let headers: Record = { + Accept: ACCEPT_HEADER + }; - if (data && method !== 'GET' && method !== 'DELETE') { - headers['Content-Type'] = 'application/json'; - } + if (data && method !== 'GET' && method !== 'DELETE') { + headers['Content-Type'] = 'application/json'; + } - if (this.config.authMethod === 'oauth') { - headers.Authorization = `Bearer ${this.config.token}`; - let response = await this.axios.request({ - method, - url: path, - data, - params, - headers - }); - return response.data; - } + if (this.config.authMethod === 'oauth') { + headers.Authorization = `Bearer ${this.config.token}`; + let response = await this.axios.request({ + method, + url: path, + data, + params, + headers + }); + return response.data; + } - // Digest auth: first request without auth to get the challenge - let publicKey = this.config.publicKey!; - let privateKey = this.config.privateKey!; + // Digest auth: first request without auth to get the challenge. + let publicKey = this.config.publicKey; + let privateKey = this.config.privateKey; + if (!publicKey || !privateKey) { + throw atlasServiceError( + 'MongoDB Atlas API key authentication requires publicKey and privateKey.' + ); + } - try { let initialResponse = await this.axios.request({ method, url: path, @@ -68,12 +74,12 @@ export class Client { let wwwAuth = initialResponse.headers['www-authenticate'] as string | undefined; if (!wwwAuth) { - throw new Error('Expected WWW-Authenticate header for digest auth'); + throw atlasServiceError('Expected WWW-Authenticate header for digest auth.'); } let challenge = parseDigestChallenge(wwwAuth); if (!challenge) { - throw new Error('Failed to parse digest authentication challenge'); + throw atlasServiceError('Failed to parse digest authentication challenge.'); } let uri = path.startsWith('/') ? path : `/${path}`; @@ -108,14 +114,8 @@ export class Client { }); return response.data; - } catch (error: any) { - if (error?.response?.data) { - let errData = error.response.data; - throw new Error( - errData.detail || errData.reason || errData.errorCode || JSON.stringify(errData) - ); - } - throw error; + } catch (error) { + throw atlasApiError(error, `${method} ${path}`); } } @@ -726,4 +726,8 @@ export class Client { async deferMaintenanceWindow(projectId: string): Promise { await this.request('POST', `/groups/${projectId}/maintenanceWindow/defer`); } + + async resetMaintenanceWindow(projectId: string): Promise { + await this.request('DELETE', `/groups/${projectId}/maintenanceWindow`); + } } diff --git a/integrations/mongodb-atlas/src/lib/errors.ts b/integrations/mongodb-atlas/src/lib/errors.ts new file mode 100644 index 0000000000..e0175d60c2 --- /dev/null +++ b/integrations/mongodb-atlas/src/lib/errors.ts @@ -0,0 +1,82 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushString = (messages: string[], value: unknown) => { + if (typeof value === 'string' && value.trim() && !messages.includes(value.trim())) { + messages.push(value.trim()); + } +}; + +let collectAtlasMessage = (value: unknown, messages: string[]) => { + if (!isRecord(value)) return; + + for (let key of ['detail', 'reason', 'errorCode', 'message', 'error_description']) { + pushString(messages, value[key]); + } + + let badRequestDetail = value.badRequestDetail; + if (isRecord(badRequestDetail) && Array.isArray(badRequestDetail.fields)) { + for (let field of badRequestDetail.fields) { + if (!isRecord(field)) continue; + let fieldName = typeof field.field === 'string' ? `${field.field}: ` : ''; + if (typeof field.description === 'string') { + pushString(messages, `${fieldName}${field.description}`); + } + } + } +}; + +let extractAtlasMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let messages: string[] = []; + + if (Array.isArray(data)) { + for (let item of data) collectAtlasMessage(item, messages); + } else if (isRecord(data)) { + collectAtlasMessage(data, messages); + } else { + pushString(messages, data); + } + + if (messages.length > 0) return messages.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +export let atlasServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let atlasApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = atlasServiceError( + `MongoDB Atlas API ${operation} failed: ${statusLabel}${extractAtlasMessage(error)}` + ); + + serviceError.data.reason = 'mongodb_atlas_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/mongodb-atlas/src/lib/validation.ts b/integrations/mongodb-atlas/src/lib/validation.ts new file mode 100644 index 0000000000..4576915e56 --- /dev/null +++ b/integrations/mongodb-atlas/src/lib/validation.ts @@ -0,0 +1,46 @@ +import { atlasServiceError } from './errors'; + +export let failValidation = (message: string): never => { + throw atlasServiceError(message); +}; + +export let resolveProjectId = (inputProjectId?: string, configProjectId?: string): string => { + let projectId = inputProjectId || configProjectId; + if (!projectId) { + failValidation('projectId is required. Provide it in input or config.'); + } + + return projectId as string; +}; + +export let requireString = ( + value: string | undefined | null, + fieldName: string, + context?: string +): string => { + if (!value) { + failValidation( + context ? `${fieldName} is required ${context}.` : `${fieldName} is required.` + ); + } + + return value as string; +}; + +export let requireNonEmptyArray = ( + value: T[] | undefined | null, + fieldName: string, + context?: string +): T[] => { + if (!value || value.length === 0) { + failValidation( + context ? `${fieldName} is required ${context}.` : `${fieldName} is required.` + ); + } + + return value as T[]; +}; + +export let invalidAction = (action: never): never => { + throw atlasServiceError(`Unknown action: ${String(action)}`); +}; diff --git a/integrations/mongodb-atlas/src/tools.schema.test.ts b/integrations/mongodb-atlas/src/tools.schema.test.ts new file mode 100644 index 0000000000..4ae793d885 --- /dev/null +++ b/integrations/mongodb-atlas/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('MongoDB Atlas tool input schemas', provider.actions); diff --git a/integrations/mongodb-atlas/src/tools/get-cluster-metrics.ts b/integrations/mongodb-atlas/src/tools/get-cluster-metrics.ts index b40cb8eac1..0ba1c31c3a 100644 --- a/integrations/mongodb-atlas/src/tools/get-cluster-metrics.ts +++ b/integrations/mongodb-atlas/src/tools/get-cluster-metrics.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { invalidAction, requireString, resolveProjectId } from '../lib/validation'; import { spec } from '../spec'; export let getClusterMetricsTool = SlateTool.create(spec, { @@ -77,8 +78,7 @@ export let getClusterMetricsTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); let { action } = ctx.input; @@ -98,12 +98,12 @@ export let getClusterMetricsTool = SlateTool.create(spec, { }; } - if (!ctx.input.processId) throw new Error('processId is required for measurements.'); - if (!ctx.input.granularity) throw new Error('granularity is required for measurements.'); + let processId = requireString(ctx.input.processId, 'processId', 'for measurements'); + let granularity = requireString(ctx.input.granularity, 'granularity', 'for measurements'); if (action === 'get_measurements') { - let result = await client.getProcessMeasurements(projectId, ctx.input.processId, { - granularity: ctx.input.granularity, + let result = await client.getProcessMeasurements(projectId, processId, { + granularity, period: ctx.input.period, start: ctx.input.start, end: ctx.input.end, @@ -120,26 +120,24 @@ export let getClusterMetricsTool = SlateTool.create(spec, { })); return { - output: { measurements, processId: ctx.input.processId }, - message: `Retrieved **${measurements.length}** metric(s) for process **${ctx.input.processId}**.` + output: { measurements, processId }, + message: `Retrieved **${measurements.length}** metric(s) for process **${processId}**.` }; } if (action === 'get_disk_measurements') { - if (!ctx.input.partitionName) - throw new Error('partitionName is required for disk measurements.'); - let result = await client.getDiskMeasurements( - projectId, - ctx.input.processId, + let partitionName = requireString( ctx.input.partitionName, - { - granularity: ctx.input.granularity, - period: ctx.input.period, - start: ctx.input.start, - end: ctx.input.end, - m: ctx.input.metrics - } + 'partitionName', + 'for disk measurements' ); + let result = await client.getDiskMeasurements(projectId, processId, partitionName, { + granularity, + period: ctx.input.period, + start: ctx.input.start, + end: ctx.input.end, + m: ctx.input.metrics + }); let measurements = (result.measurements || []).map((m: any) => ({ name: m.name, @@ -151,11 +149,11 @@ export let getClusterMetricsTool = SlateTool.create(spec, { })); return { - output: { measurements, processId: ctx.input.processId }, - message: `Retrieved **${measurements.length}** disk metric(s) for process **${ctx.input.processId}** partition **${ctx.input.partitionName}**.` + output: { measurements, processId }, + message: `Retrieved **${measurements.length}** disk metric(s) for process **${processId}** partition **${partitionName}**.` }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/tools/get-performance-advisor.ts b/integrations/mongodb-atlas/src/tools/get-performance-advisor.ts index e68fb9a349..9265ea4054 100644 --- a/integrations/mongodb-atlas/src/tools/get-performance-advisor.ts +++ b/integrations/mongodb-atlas/src/tools/get-performance-advisor.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { invalidAction, resolveProjectId } from '../lib/validation'; import { spec } from '../spec'; export let getPerformanceAdvisorTool = SlateTool.create(spec, { @@ -61,8 +62,7 @@ export let getPerformanceAdvisorTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); let { action, processId } = ctx.input; @@ -107,6 +107,6 @@ export let getPerformanceAdvisorTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/tools/index.ts b/integrations/mongodb-atlas/src/tools/index.ts index 739d1865b9..f8fd6f83e2 100644 --- a/integrations/mongodb-atlas/src/tools/index.ts +++ b/integrations/mongodb-atlas/src/tools/index.ts @@ -1,12 +1,15 @@ export { getClusterMetricsTool } from './get-cluster-metrics'; export { getPerformanceAdvisorTool } from './get-performance-advisor'; +export { listAccessLogsTool } from './list-access-logs'; export { listEventsTool } from './list-events'; export { listProjectsTool } from './list-projects'; export { manageAlertsTool } from './manage-alerts'; export { manageBackupsTool } from './manage-backups'; export { manageClusterTool } from './manage-cluster'; export { manageDatabaseUserTool } from './manage-database-user'; +export { manageFlexClusterTool } from './manage-flex-cluster'; export { manageIpAccessListTool } from './manage-ip-access-list'; +export { manageMaintenanceWindowTool } from './manage-maintenance-window'; export { manageNetworkPeeringTool } from './manage-network-peering'; export { manageOnlineArchiveTool } from './manage-online-archive'; export { manageSearchIndexesTool } from './manage-search-indexes'; diff --git a/integrations/mongodb-atlas/src/tools/list-access-logs.ts b/integrations/mongodb-atlas/src/tools/list-access-logs.ts new file mode 100644 index 0000000000..37e53a59aa --- /dev/null +++ b/integrations/mongodb-atlas/src/tools/list-access-logs.ts @@ -0,0 +1,63 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { requireString, resolveProjectId } from '../lib/validation'; +import { spec } from '../spec'; + +export let listAccessLogsTool = SlateTool.create(spec, { + name: 'List Access Logs', + key: 'list_access_logs', + description: `Return MongoDB Atlas database access history for one cluster. Use this for security review, troubleshooting failed authentications, and investigating recent database connection activity.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z + .string() + .optional() + .describe('Atlas Project ID. Uses config projectId if not provided.'), + clusterName: z + .string() + .describe('Cluster name to retrieve database access history for.'), + start: z + .string() + .optional() + .describe('ISO 8601 start time for the access history window.'), + end: z.string().optional().describe('ISO 8601 end time for the access history window.'), + nLogs: z.number().optional().describe('Maximum number of log entries to return.'), + authResult: z + .boolean() + .optional() + .describe('Filter by authentication success or failure.'), + ipAddress: z.string().optional().describe('Filter by source IP address.') + }) + ) + .output( + z.object({ + accessLogs: z.array(z.any()), + totalCount: z.number().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); + let clusterName = requireString(ctx.input.clusterName, 'clusterName'); + let result = await client.listAccessLogs(projectId, clusterName, { + start: ctx.input.start, + end: ctx.input.end, + nLogs: ctx.input.nLogs, + authResult: ctx.input.authResult, + ipAddress: ctx.input.ipAddress + }); + let accessLogs = result.results || []; + + return { + output: { accessLogs, totalCount: result.totalCount || accessLogs.length }, + message: `Found **${accessLogs.length}** database access log entr${ + accessLogs.length === 1 ? 'y' : 'ies' + } for cluster **${clusterName}**.` + }; + }) + .build(); diff --git a/integrations/mongodb-atlas/src/tools/list-events.ts b/integrations/mongodb-atlas/src/tools/list-events.ts index 438e8b6a08..541c32295a 100644 --- a/integrations/mongodb-atlas/src/tools/list-events.ts +++ b/integrations/mongodb-atlas/src/tools/list-events.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { atlasServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -66,7 +67,7 @@ export let listEventsTool = SlateTool.create(spec, { } else if (orgId) { result = await client.listOrganizationEvents(orgId, params); } else { - throw new Error('Either projectId or organizationId is required.'); + throw atlasServiceError('Either projectId or organizationId is required.'); } let events = (result.results || []).map((e: any) => ({ diff --git a/integrations/mongodb-atlas/src/tools/manage-alerts.ts b/integrations/mongodb-atlas/src/tools/manage-alerts.ts index 0ba73bb11c..fe0e48e9fd 100644 --- a/integrations/mongodb-atlas/src/tools/manage-alerts.ts +++ b/integrations/mongodb-atlas/src/tools/manage-alerts.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { invalidAction, requireString, resolveProjectId } from '../lib/validation'; import { spec } from '../spec'; let notificationSchema = z.object({ @@ -125,8 +126,7 @@ Also allows viewing and acknowledging active alerts.`, ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); let { action } = ctx.input; @@ -140,11 +140,11 @@ Also allows viewing and acknowledging active alerts.`, } if (action === 'get_config') { - if (!ctx.input.alertConfigId) throw new Error('alertConfigId is required.'); - let config = await client.getAlertConfiguration(projectId, ctx.input.alertConfigId); + let alertConfigId = requireString(ctx.input.alertConfigId, 'alertConfigId'); + let config = await client.getAlertConfiguration(projectId, alertConfigId); return { output: { alertConfig: config }, - message: `Retrieved alert config **${ctx.input.alertConfigId}**.` + message: `Retrieved alert config **${alertConfigId}**.` }; } @@ -166,7 +166,7 @@ Also allows viewing and acknowledging active alerts.`, } if (action === 'update_config') { - if (!ctx.input.alertConfigId) throw new Error('alertConfigId is required.'); + let alertConfigId = requireString(ctx.input.alertConfigId, 'alertConfigId'); let data: any = {}; if (ctx.input.enabled !== undefined) data.enabled = ctx.input.enabled; if (ctx.input.eventTypeName) data.eventTypeName = ctx.input.eventTypeName; @@ -175,23 +175,19 @@ Also allows viewing and acknowledging active alerts.`, if (ctx.input.notifications) data.notifications = ctx.input.notifications; if (ctx.input.matchers) data.matchers = ctx.input.matchers; - let config = await client.updateAlertConfiguration( - projectId, - ctx.input.alertConfigId, - data - ); + let config = await client.updateAlertConfiguration(projectId, alertConfigId, data); return { output: { alertConfig: config }, - message: `Updated alert config **${ctx.input.alertConfigId}**.` + message: `Updated alert config **${alertConfigId}**.` }; } if (action === 'delete_config') { - if (!ctx.input.alertConfigId) throw new Error('alertConfigId is required.'); - await client.deleteAlertConfiguration(projectId, ctx.input.alertConfigId); + let alertConfigId = requireString(ctx.input.alertConfigId, 'alertConfigId'); + await client.deleteAlertConfiguration(projectId, alertConfigId); return { output: {}, - message: `Deleted alert config **${ctx.input.alertConfigId}**.` + message: `Deleted alert config **${alertConfigId}**.` }; } @@ -207,27 +203,27 @@ Also allows viewing and acknowledging active alerts.`, } if (action === 'get_alert') { - if (!ctx.input.alertId) throw new Error('alertId is required.'); - let alert = await client.getAlert(projectId, ctx.input.alertId); + let alertId = requireString(ctx.input.alertId, 'alertId'); + let alert = await client.getAlert(projectId, alertId); return { output: { alert }, - message: `Retrieved alert **${ctx.input.alertId}** (status: ${alert.status}).` + message: `Retrieved alert **${alertId}** (status: ${alert.status}).` }; } if (action === 'acknowledge') { - if (!ctx.input.alertId) throw new Error('alertId is required.'); - if (!ctx.input.acknowledgedUntil) throw new Error('acknowledgedUntil is required.'); - let alert = await client.acknowledgeAlert(projectId, ctx.input.alertId, { - acknowledgedUntil: ctx.input.acknowledgedUntil, + let alertId = requireString(ctx.input.alertId, 'alertId'); + let acknowledgedUntil = requireString(ctx.input.acknowledgedUntil, 'acknowledgedUntil'); + let alert = await client.acknowledgeAlert(projectId, alertId, { + acknowledgedUntil, acknowledgementComment: ctx.input.acknowledgementComment }); return { output: { alert }, - message: `Acknowledged alert **${ctx.input.alertId}** until ${ctx.input.acknowledgedUntil}.` + message: `Acknowledged alert **${alertId}** until ${acknowledgedUntil}.` }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/tools/manage-backups.ts b/integrations/mongodb-atlas/src/tools/manage-backups.ts index 4a10dbb3d5..02b41c4fd5 100644 --- a/integrations/mongodb-atlas/src/tools/manage-backups.ts +++ b/integrations/mongodb-atlas/src/tools/manage-backups.ts @@ -1,6 +1,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { + failValidation, + invalidAction, + requireString, + resolveProjectId +} from '../lib/validation'; import { spec } from '../spec'; export let manageBackupsTool = SlateTool.create(spec, { @@ -66,10 +72,10 @@ export let manageBackupsTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); - let { action, clusterName } = ctx.input; + let { action } = ctx.input; + let clusterName = requireString(ctx.input.clusterName, 'clusterName'); if (action === 'list_snapshots') { let result = await client.listBackupSnapshots(projectId, clusterName); @@ -81,15 +87,11 @@ export let manageBackupsTool = SlateTool.create(spec, { } if (action === 'get_snapshot') { - if (!ctx.input.snapshotId) throw new Error('snapshotId is required.'); - let snapshot = await client.getBackupSnapshot( - projectId, - clusterName, - ctx.input.snapshotId - ); + let snapshotId = requireString(ctx.input.snapshotId, 'snapshotId'); + let snapshot = await client.getBackupSnapshot(projectId, clusterName, snapshotId); return { output: { snapshot }, - message: `Retrieved snapshot **${ctx.input.snapshotId}** (status: ${snapshot.status}).` + message: `Retrieved snapshot **${snapshotId}** (status: ${snapshot.status}).` }; } @@ -115,10 +117,13 @@ export let manageBackupsTool = SlateTool.create(spec, { } if (action === 'create_restore_job') { - if (!ctx.input.deliveryType) - throw new Error('deliveryType is required for restore jobs.'); + let deliveryType = requireString( + ctx.input.deliveryType, + 'deliveryType', + 'for restore jobs' + ); let data: any = { - deliveryType: ctx.input.deliveryType + deliveryType }; if (ctx.input.snapshotId) data.snapshotId = ctx.input.snapshotId; if (ctx.input.targetClusterName) data.targetClusterName = ctx.input.targetClusterName; @@ -131,7 +136,7 @@ export let manageBackupsTool = SlateTool.create(spec, { let restoreJob = await client.createBackupRestoreJob(projectId, clusterName, data); return { output: { restoreJob }, - message: `Restore job created for cluster **${clusterName}** (type: ${ctx.input.deliveryType}).` + message: `Restore job created for cluster **${clusterName}** (type: ${deliveryType}).` }; } @@ -145,7 +150,7 @@ export let manageBackupsTool = SlateTool.create(spec, { if (action === 'update_schedule') { if (!ctx.input.scheduleData) - throw new Error('scheduleData is required for update_schedule.'); + failValidation('scheduleData is required for update_schedule.'); let schedule = await client.updateBackupSchedule( projectId, clusterName, @@ -157,6 +162,6 @@ export let manageBackupsTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/tools/manage-cluster.ts b/integrations/mongodb-atlas/src/tools/manage-cluster.ts index aa09c64bf8..641211b606 100644 --- a/integrations/mongodb-atlas/src/tools/manage-cluster.ts +++ b/integrations/mongodb-atlas/src/tools/manage-cluster.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { invalidAction, requireString, resolveProjectId } from '../lib/validation'; import { spec } from '../spec'; let replicationSpecSchema = z @@ -124,8 +125,7 @@ export let manageClusterTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); let { action, clusterName } = ctx.input; @@ -138,7 +138,7 @@ export let manageClusterTool = SlateTool.create(spec, { }; } - if (!clusterName) throw new Error('clusterName is required for this action.'); + clusterName = requireString(clusterName, 'clusterName', 'for this action'); if (action === 'get') { let cluster = await client.getCluster(projectId, clusterName); @@ -210,6 +210,6 @@ export let manageClusterTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/tools/manage-database-user.ts b/integrations/mongodb-atlas/src/tools/manage-database-user.ts index 7dbc94227c..1c11b2b3d9 100644 --- a/integrations/mongodb-atlas/src/tools/manage-database-user.ts +++ b/integrations/mongodb-atlas/src/tools/manage-database-user.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { invalidAction, requireString, resolveProjectId } from '../lib/validation'; import { spec } from '../spec'; let roleSchema = z.object({ @@ -76,8 +77,7 @@ export let manageDatabaseUserTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); let { action, username } = ctx.input; let authDb = ctx.input.authDatabaseName || 'admin'; @@ -91,7 +91,7 @@ export let manageDatabaseUserTool = SlateTool.create(spec, { }; } - if (!username) throw new Error('username is required for this action.'); + username = requireString(username, 'username', 'for this action'); if (action === 'get') { let user = await client.getDatabaseUser(projectId, authDb, username); @@ -144,6 +144,6 @@ export let manageDatabaseUserTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/tools/manage-flex-cluster.ts b/integrations/mongodb-atlas/src/tools/manage-flex-cluster.ts new file mode 100644 index 0000000000..8403c0c0c4 --- /dev/null +++ b/integrations/mongodb-atlas/src/tools/manage-flex-cluster.ts @@ -0,0 +1,129 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { invalidAction, requireString, resolveProjectId } from '../lib/validation'; +import { spec } from '../spec'; + +let tagSchema = z.object({ + key: z.string().describe('Tag key between 1 and 255 characters.'), + value: z.string().describe('Tag value between 1 and 255 characters.') +}); + +export let manageFlexClusterTool = SlateTool.create(spec, { + name: 'Manage Flex Cluster', + key: 'manage_flex_cluster', + description: `Create, update, list, retrieve, or delete MongoDB Atlas Flex clusters. Flex clusters use the current Atlas flexClusters API for low-cost, elastic deployments separate from dedicated cluster endpoints.`, + instructions: [ + 'For create, provide clusterName, backingProviderName, and regionName.', + 'For update, Atlas currently supports tags and terminationProtectionEnabled.', + 'For delete, disable termination protection first if it is enabled.' + ] +}) + .input( + z.object({ + projectId: z + .string() + .optional() + .describe('Atlas Project ID. Uses config projectId if not provided.'), + action: z + .enum(['list', 'get', 'create', 'update', 'delete']) + .describe('Action to perform. Source-specific fields are validated at runtime.'), + clusterName: z + .string() + .optional() + .describe('Flex cluster name. Required for get/create/update/delete.'), + backingProviderName: z + .enum(['AWS', 'AZURE', 'GCP']) + .optional() + .describe('Cloud provider backing the flex cluster. Required for create.'), + regionName: z.string().optional().describe('Atlas region name. Required for create.'), + terminationProtectionEnabled: z + .boolean() + .optional() + .describe('Whether Atlas prevents deleting this flex cluster.'), + tags: z.array(tagSchema).optional().describe('Tags to set on create or update.') + }) + ) + .output( + z.object({ + cluster: z.any().optional().describe('Flex cluster details'), + clusters: z.array(z.any()).optional().describe('Flex clusters in the project'), + totalCount: z.number().optional(), + deleted: z.boolean().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); + let { action } = ctx.input; + + if (action === 'list') { + let result = await client.listFlexClusters(projectId); + let clusters = result.results || []; + return { + output: { clusters, totalCount: result.totalCount || clusters.length }, + message: `Found **${clusters.length}** flex cluster(s) in project.` + }; + } + + let clusterName = requireString(ctx.input.clusterName, 'clusterName', 'for this action'); + + if (action === 'get') { + let cluster = await client.getFlexCluster(projectId, clusterName); + return { + output: { cluster }, + message: `Retrieved flex cluster **${clusterName}** (state: ${cluster.stateName}).` + }; + } + + if (action === 'create') { + let backingProviderName = requireString( + ctx.input.backingProviderName, + 'backingProviderName', + 'for create' + ); + let regionName = requireString(ctx.input.regionName, 'regionName', 'for create'); + let data: any = { + name: clusterName, + providerSettings: { + backingProviderName, + regionName + } + }; + if (ctx.input.tags) data.tags = ctx.input.tags; + if (ctx.input.terminationProtectionEnabled !== undefined) { + data.terminationProtectionEnabled = ctx.input.terminationProtectionEnabled; + } + + let cluster = await client.createFlexCluster(projectId, data); + return { + output: { cluster }, + message: `Flex cluster **${clusterName}** creation initiated (state: ${cluster.stateName}).` + }; + } + + if (action === 'update') { + let data: any = {}; + if (ctx.input.tags) data.tags = ctx.input.tags; + if (ctx.input.terminationProtectionEnabled !== undefined) { + data.terminationProtectionEnabled = ctx.input.terminationProtectionEnabled; + } + + let cluster = await client.updateFlexCluster(projectId, clusterName, data); + return { + output: { cluster }, + message: `Flex cluster **${clusterName}** update initiated.` + }; + } + + if (action === 'delete') { + await client.deleteFlexCluster(projectId, clusterName); + return { + output: { deleted: true }, + message: `Flex cluster **${clusterName}** deletion initiated.` + }; + } + + return invalidAction(action); + }) + .build(); diff --git a/integrations/mongodb-atlas/src/tools/manage-ip-access-list.ts b/integrations/mongodb-atlas/src/tools/manage-ip-access-list.ts index 883eda4f20..c704d964da 100644 --- a/integrations/mongodb-atlas/src/tools/manage-ip-access-list.ts +++ b/integrations/mongodb-atlas/src/tools/manage-ip-access-list.ts @@ -1,6 +1,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { + invalidAction, + requireNonEmptyArray, + requireString, + resolveProjectId +} from '../lib/validation'; import { spec } from '../spec'; export let manageIpAccessListTool = SlateTool.create(spec, { @@ -64,8 +70,7 @@ export let manageIpAccessListTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); let { action } = ctx.input; @@ -79,28 +84,32 @@ export let manageIpAccessListTool = SlateTool.create(spec, { } if (action === 'add') { - if (!ctx.input.entries || ctx.input.entries.length === 0) { - throw new Error('entries are required for the add action.'); - } - let result = await client.addIpAccessListEntries(projectId, ctx.input.entries); + let requestedEntries = requireNonEmptyArray( + ctx.input.entries, + 'entries', + 'for the add action' + ); + let result = await client.addIpAccessListEntries(projectId, requestedEntries); let entries = result.results || []; return { output: { entries, totalCount: entries.length }, - message: `Added **${ctx.input.entries.length}** IP access list entry/entries.` + message: `Added **${requestedEntries.length}** IP access list entry/entries.` }; } if (action === 'remove') { - if (!ctx.input.entryValue) { - throw new Error('entryValue is required for the remove action.'); - } - await client.deleteIpAccessListEntry(projectId, ctx.input.entryValue); + let entryValue = requireString( + ctx.input.entryValue, + 'entryValue', + 'for the remove action' + ); + await client.deleteIpAccessListEntry(projectId, entryValue); return { output: { removed: true }, - message: `Removed IP access list entry **${ctx.input.entryValue}**.` + message: `Removed IP access list entry **${entryValue}**.` }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/tools/manage-maintenance-window.ts b/integrations/mongodb-atlas/src/tools/manage-maintenance-window.ts new file mode 100644 index 0000000000..ed9ffd1502 --- /dev/null +++ b/integrations/mongodb-atlas/src/tools/manage-maintenance-window.ts @@ -0,0 +1,127 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { createClient } from '../lib/helpers'; +import { failValidation, invalidAction, resolveProjectId } from '../lib/validation'; +import { spec } from '../spec'; + +let protectedHoursSchema = z.object({ + startHourOfDay: z + .number() + .optional() + .describe('Start hour, 0 through 23, when maintenance must not begin.'), + endHourOfDay: z + .number() + .optional() + .describe('End hour, 0 through 23, when maintenance must not begin.') +}); + +export let manageMaintenanceWindowTool = SlateTool.create(spec, { + name: 'Manage Maintenance Window', + key: 'manage_maintenance_window', + description: `Get, update, reset, or defer the MongoDB Atlas maintenance window for a project. Use this to control when Atlas applies scheduled maintenance and to defer the next maintenance window when allowed.`, + instructions: [ + 'For update, dayOfWeek is required and must be 1 for Sunday through 7 for Saturday.', + 'hourOfDay uses 0 through 23 in the project maintenance timezone.', + 'Use reset to clear the configured maintenance window, and defer only when Atlas reports maintenance can be deferred.' + ] +}) + .input( + z.object({ + projectId: z + .string() + .optional() + .describe('Atlas Project ID. Uses config projectId if not provided.'), + action: z.enum(['get', 'update', 'reset', 'defer']).describe('Action to perform'), + dayOfWeek: z + .number() + .optional() + .describe('One-based day of week for update: 1 Sunday through 7 Saturday.'), + hourOfDay: z + .number() + .optional() + .describe('Zero-based start hour for update: 0 through 23.'), + autoDeferOnceEnabled: z + .boolean() + .optional() + .describe('Whether Atlas should auto-defer maintenance once after enabling it.'), + protectedHours: protectedHoursSchema + .optional() + .describe('Hours during which maintenance should not begin.'), + startASAP: z + .boolean() + .optional() + .describe('Whether maintenance should start as soon as possible.'), + waveAssignment: z.number().optional().describe('Maintenance wave assignment.') + }) + ) + .output( + z.object({ + maintenanceWindow: z.any().optional(), + updated: z.boolean().optional(), + reset: z.boolean().optional(), + deferred: z.boolean().optional() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); + let { action } = ctx.input; + + if (action === 'get') { + let maintenanceWindow = await client.getMaintenanceWindow(projectId); + return { + output: { maintenanceWindow }, + message: 'Retrieved the project maintenance window.' + }; + } + + if (action === 'update') { + let dayOfWeek = ctx.input.dayOfWeek; + if (dayOfWeek === undefined || dayOfWeek < 1 || dayOfWeek > 7) { + failValidation('dayOfWeek is required for update and must be between 1 and 7.'); + } + if ( + ctx.input.hourOfDay !== undefined && + (ctx.input.hourOfDay < 0 || ctx.input.hourOfDay > 23) + ) { + failValidation('hourOfDay must be between 0 and 23.'); + } + + let data: any = { + dayOfWeek + }; + if (ctx.input.hourOfDay !== undefined) data.hourOfDay = ctx.input.hourOfDay; + if (ctx.input.autoDeferOnceEnabled !== undefined) { + data.autoDeferOnceEnabled = ctx.input.autoDeferOnceEnabled; + } + if (ctx.input.protectedHours) data.protectedHours = ctx.input.protectedHours; + if (ctx.input.startASAP !== undefined) data.startASAP = ctx.input.startASAP; + if (ctx.input.waveAssignment !== undefined) + data.waveAssignment = ctx.input.waveAssignment; + + let maintenanceWindow = await client.updateMaintenanceWindow(projectId, data); + return { + output: { maintenanceWindow, updated: true }, + message: 'Updated the project maintenance window.' + }; + } + + if (action === 'reset') { + await client.resetMaintenanceWindow(projectId); + return { + output: { reset: true }, + message: 'Reset the project maintenance window.' + }; + } + + if (action === 'defer') { + await client.deferMaintenanceWindow(projectId); + return { + output: { deferred: true }, + message: 'Deferred the next project maintenance window.' + }; + } + + return invalidAction(action); + }) + .build(); diff --git a/integrations/mongodb-atlas/src/tools/manage-network-peering.ts b/integrations/mongodb-atlas/src/tools/manage-network-peering.ts index 459d0fe362..aaf6d6bfbc 100644 --- a/integrations/mongodb-atlas/src/tools/manage-network-peering.ts +++ b/integrations/mongodb-atlas/src/tools/manage-network-peering.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { invalidAction, requireString, resolveProjectId } from '../lib/validation'; import { spec } from '../spec'; export let manageNetworkPeeringTool = SlateTool.create(spec, { @@ -54,8 +55,7 @@ export let manageNetworkPeeringTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); let { action } = ctx.input; @@ -71,36 +71,42 @@ export let manageNetworkPeeringTool = SlateTool.create(spec, { } if (action === 'get') { - if (!ctx.input.peerId) throw new Error('peerId is required.'); - let peering = await client.getNetworkPeering(projectId, ctx.input.peerId); + let peerId = requireString(ctx.input.peerId, 'peerId'); + let peering = await client.getNetworkPeering(projectId, peerId); return { output: { peering }, - message: `Retrieved network peering **${ctx.input.peerId}** (status: ${peering.statusName}).` + message: `Retrieved network peering **${peerId}** (status: ${peering.statusName}).` }; } if (action === 'create') { - if (!ctx.input.containerId) - throw new Error('containerId is required for creating a peering connection.'); - if (!ctx.input.providerName) - throw new Error('providerName is required for creating a peering connection.'); + let containerId = requireString( + ctx.input.containerId, + 'containerId', + 'for creating a peering connection' + ); + let providerName = requireString( + ctx.input.providerName, + 'providerName', + 'for creating a peering connection' + ); let data: any = { - containerId: ctx.input.containerId, - providerName: ctx.input.providerName + containerId, + providerName }; - if (ctx.input.providerName === 'AWS') { + if (providerName === 'AWS') { data.accepterRegionName = ctx.input.accepterRegionName; data.awsAccountId = ctx.input.awsAccountId; data.routeTableCidrBlock = ctx.input.routeTableCidrBlock; data.vpcId = ctx.input.vpcId; - } else if (ctx.input.providerName === 'AZURE') { + } else if (providerName === 'AZURE') { data.azureDirectoryId = ctx.input.azureDirectoryId; data.azureSubscriptionId = ctx.input.azureSubscriptionId; data.resourceGroupName = ctx.input.resourceGroupName; data.vNetName = ctx.input.vNetName; - } else if (ctx.input.providerName === 'GCP') { + } else if (providerName === 'GCP') { data.gcpProjectId = ctx.input.gcpProjectId; data.networkName = ctx.input.networkName; } @@ -113,14 +119,14 @@ export let manageNetworkPeeringTool = SlateTool.create(spec, { } if (action === 'delete') { - if (!ctx.input.peerId) throw new Error('peerId is required.'); - await client.deleteNetworkPeering(projectId, ctx.input.peerId); + let peerId = requireString(ctx.input.peerId, 'peerId'); + await client.deleteNetworkPeering(projectId, peerId); return { output: {}, - message: `Deleted network peering **${ctx.input.peerId}**.` + message: `Deleted network peering **${peerId}**.` }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/tools/manage-online-archive.ts b/integrations/mongodb-atlas/src/tools/manage-online-archive.ts index 6df99d12c1..0288019cfd 100644 --- a/integrations/mongodb-atlas/src/tools/manage-online-archive.ts +++ b/integrations/mongodb-atlas/src/tools/manage-online-archive.ts @@ -1,6 +1,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { + failValidation, + invalidAction, + requireString, + resolveProjectId +} from '../lib/validation'; import { spec } from '../spec'; export let manageOnlineArchiveTool = SlateTool.create(spec, { @@ -63,10 +69,10 @@ export let manageOnlineArchiveTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); - let { action, clusterName } = ctx.input; + let { action } = ctx.input; + let clusterName = requireString(ctx.input.clusterName, 'clusterName'); if (action === 'list') { let result = await client.listOnlineArchives(projectId, clusterName); @@ -78,26 +84,31 @@ export let manageOnlineArchiveTool = SlateTool.create(spec, { } if (action === 'get') { - if (!ctx.input.archiveId) throw new Error('archiveId is required.'); - let archive = await client.getOnlineArchive(projectId, clusterName, ctx.input.archiveId); + let archiveId = requireString(ctx.input.archiveId, 'archiveId'); + let archive = await client.getOnlineArchive(projectId, clusterName, archiveId); return { output: { archive }, - message: `Retrieved online archive **${ctx.input.archiveId}** (state: ${archive.state}).` + message: `Retrieved online archive **${archiveId}** (state: ${archive.state}).` }; } if (action === 'create') { - if (!ctx.input.databaseName || !ctx.input.collectionName) { - throw new Error( - 'databaseName and collectionName are required for creating an online archive.' - ); - } + let databaseName = requireString( + ctx.input.databaseName, + 'databaseName', + 'for creating an online archive' + ); + let collectionName = requireString( + ctx.input.collectionName, + 'collectionName', + 'for creating an online archive' + ); if (!ctx.input.criteria) - throw new Error('criteria is required for creating an online archive.'); + failValidation('criteria is required for creating an online archive.'); let data: any = { - dbName: ctx.input.databaseName, - collName: ctx.input.collectionName, + dbName: databaseName, + collName: collectionName, criteria: ctx.input.criteria }; if (ctx.input.partitionFields) data.partitionFields = ctx.input.partitionFields; @@ -105,37 +116,32 @@ export let manageOnlineArchiveTool = SlateTool.create(spec, { let archive = await client.createOnlineArchive(projectId, clusterName, data); return { output: { archive }, - message: `Created online archive for **${ctx.input.databaseName}.${ctx.input.collectionName}**.` + message: `Created online archive for **${databaseName}.${collectionName}**.` }; } if (action === 'update') { - if (!ctx.input.archiveId) throw new Error('archiveId is required.'); + let archiveId = requireString(ctx.input.archiveId, 'archiveId'); let data: any = {}; if (ctx.input.criteria) data.criteria = ctx.input.criteria; if (ctx.input.paused !== undefined) data.paused = ctx.input.paused; - let archive = await client.updateOnlineArchive( - projectId, - clusterName, - ctx.input.archiveId, - data - ); + let archive = await client.updateOnlineArchive(projectId, clusterName, archiveId, data); return { output: { archive }, - message: `Updated online archive **${ctx.input.archiveId}**.` + message: `Updated online archive **${archiveId}**.` }; } if (action === 'delete') { - if (!ctx.input.archiveId) throw new Error('archiveId is required.'); - await client.deleteOnlineArchive(projectId, clusterName, ctx.input.archiveId); + let archiveId = requireString(ctx.input.archiveId, 'archiveId'); + await client.deleteOnlineArchive(projectId, clusterName, archiveId); return { output: {}, - message: `Deleted online archive **${ctx.input.archiveId}**.` + message: `Deleted online archive **${archiveId}**.` }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/tools/manage-search-indexes.ts b/integrations/mongodb-atlas/src/tools/manage-search-indexes.ts index 995c4dddff..914e615a4d 100644 --- a/integrations/mongodb-atlas/src/tools/manage-search-indexes.ts +++ b/integrations/mongodb-atlas/src/tools/manage-search-indexes.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { invalidAction, requireString, resolveProjectId } from '../lib/validation'; import { spec } from '../spec'; export let manageSearchIndexesTool = SlateTool.create(spec, { @@ -45,47 +46,57 @@ export let manageSearchIndexesTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required. Provide it in input or config.'); + let projectId = resolveProjectId(ctx.input.projectId, ctx.config.projectId); - let { action, clusterName } = ctx.input; + let { action } = ctx.input; + let clusterName = requireString(ctx.input.clusterName, 'clusterName'); if (action === 'list') { - if (!ctx.input.databaseName || !ctx.input.collectionName) { - throw new Error( - 'databaseName and collectionName are required for listing search indexes.' - ); - } + let databaseName = requireString( + ctx.input.databaseName, + 'databaseName', + 'for listing search indexes' + ); + let collectionName = requireString( + ctx.input.collectionName, + 'collectionName', + 'for listing search indexes' + ); let indexes = await client.listSearchIndexes( projectId, clusterName, - ctx.input.databaseName, - ctx.input.collectionName + databaseName, + collectionName ); return { output: { indexes: Array.isArray(indexes) ? indexes : indexes.results || [] }, - message: `Found search indexes on **${ctx.input.databaseName}.${ctx.input.collectionName}**.` + message: `Found search indexes on **${databaseName}.${collectionName}**.` }; } if (action === 'get') { - if (!ctx.input.indexId) throw new Error('indexId is required.'); - let index = await client.getSearchIndex(projectId, clusterName, ctx.input.indexId); + let indexId = requireString(ctx.input.indexId, 'indexId'); + let index = await client.getSearchIndex(projectId, clusterName, indexId); return { output: { index }, - message: `Retrieved search index **${ctx.input.indexId}**.` + message: `Retrieved search index **${indexId}**.` }; } if (action === 'create') { - if (!ctx.input.databaseName || !ctx.input.collectionName) { - throw new Error( - 'databaseName and collectionName are required for creating a search index.' - ); - } + let databaseName = requireString( + ctx.input.databaseName, + 'databaseName', + 'for creating a search index' + ); + let collectionName = requireString( + ctx.input.collectionName, + 'collectionName', + 'for creating a search index' + ); let data: any = { - database: ctx.input.databaseName, - collectionName: ctx.input.collectionName, + database: databaseName, + collectionName, name: ctx.input.indexName || 'default', type: ctx.input.type || 'search' }; @@ -94,37 +105,32 @@ export let manageSearchIndexesTool = SlateTool.create(spec, { let index = await client.createSearchIndex(projectId, clusterName, data); return { output: { index }, - message: `Created search index **${data.name}** on **${ctx.input.databaseName}.${ctx.input.collectionName}**.` + message: `Created search index **${data.name}** on **${databaseName}.${collectionName}**.` }; } if (action === 'update') { - if (!ctx.input.indexId) throw new Error('indexId is required.'); + let indexId = requireString(ctx.input.indexId, 'indexId'); let data: any = {}; if (ctx.input.definition) data.definition = ctx.input.definition; if (ctx.input.indexName) data.name = ctx.input.indexName; - let index = await client.updateSearchIndex( - projectId, - clusterName, - ctx.input.indexId, - data - ); + let index = await client.updateSearchIndex(projectId, clusterName, indexId, data); return { output: { index }, - message: `Updated search index **${ctx.input.indexId}**.` + message: `Updated search index **${indexId}**.` }; } if (action === 'delete') { - if (!ctx.input.indexId) throw new Error('indexId is required.'); - await client.deleteSearchIndex(projectId, clusterName, ctx.input.indexId); + let indexId = requireString(ctx.input.indexId, 'indexId'); + await client.deleteSearchIndex(projectId, clusterName, indexId); return { output: {}, - message: `Deleted search index **${ctx.input.indexId}**.` + message: `Deleted search index **${indexId}**.` }; } - throw new Error(`Unknown action: ${action}`); + return invalidAction(action); }) .build(); diff --git a/integrations/mongodb-atlas/src/triggers/alert-webhook.ts b/integrations/mongodb-atlas/src/triggers/alert-webhook.ts index da842e6224..54829a61df 100644 --- a/integrations/mongodb-atlas/src/triggers/alert-webhook.ts +++ b/integrations/mongodb-atlas/src/triggers/alert-webhook.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { requireString } from '../lib/validation'; import { spec } from '../spec'; export let alertWebhookTrigger = SlateTrigger.create(spec, { @@ -47,9 +48,11 @@ export let alertWebhookTrigger = SlateTrigger.create(spec, { .webhook({ autoRegisterWebhook: async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.config.projectId; - if (!projectId) - throw new Error('projectId is required in config for webhook registration.'); + let projectId = requireString( + ctx.config.projectId, + 'projectId', + 'in config for webhook registration' + ); // Configure the webhook integration for the project await client.configureIntegration(projectId, 'WEBHOOK', { diff --git a/integrations/mongodb-atlas/src/triggers/project-events.ts b/integrations/mongodb-atlas/src/triggers/project-events.ts index 4d05e1e501..f72a368ec3 100644 --- a/integrations/mongodb-atlas/src/triggers/project-events.ts +++ b/integrations/mongodb-atlas/src/triggers/project-events.ts @@ -1,6 +1,7 @@ import { SlateDefaultPollingIntervalSeconds, SlateTrigger } from 'slates'; import { z } from 'zod'; import { createClient } from '../lib/helpers'; +import { requireString } from '../lib/validation'; import { spec } from '../spec'; export let projectEventsTrigger = SlateTrigger.create(spec, { @@ -42,9 +43,11 @@ export let projectEventsTrigger = SlateTrigger.create(spec, { pollEvents: async ctx => { let client = createClient(ctx.auth); - let projectId = ctx.config.projectId; - if (!projectId) - throw new Error('projectId is required in config for project event polling.'); + let projectId = requireString( + ctx.config.projectId, + 'projectId', + 'in config for project event polling' + ); let state = ctx.state || {}; let lastPolledAt = state.lastPolledAt as string | undefined; diff --git a/integrations/mongodb-atlas/vitest.config.ts b/integrations/mongodb-atlas/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/mongodb-atlas/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/mongodb/package.json b/integrations/mongodb/package.json index d0603ec17d..a8645da7a1 100644 --- a/integrations/mongodb/package.json +++ b/integrations/mongodb/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/mongodb/src/auth.ts b/integrations/mongodb/src/auth.ts index 019e4ed7d2..59c1752aee 100644 --- a/integrations/mongodb/src/auth.ts +++ b/integrations/mongodb/src/auth.ts @@ -1,17 +1,61 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { AtlasClient } from './lib/client'; +import { mongodbApiError, mongodbServiceError } from './lib/errors'; let atlasAxios = createAxios({ baseURL: 'https://cloud.mongodb.com' }); +let exchangeServiceAccountToken = async (ctx: { + clientId: string; + clientSecret: string; + scopes?: string[]; +}) => { + try { + let credentials = Buffer.from(`${ctx.clientId}:${ctx.clientSecret}`).toString('base64'); + let params = new URLSearchParams({ + grant_type: 'client_credentials' + }); + + if (ctx.scopes && ctx.scopes.length > 0) { + params.set('scope', ctx.scopes.join(' ')); + } + + let tokenResponse = await atlasAxios.post('/api/oauth/token', params.toString(), { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + } + }); + + let tokenData = tokenResponse.data as { + access_token?: string; + expires_in?: number; + }; + + if (!tokenData.access_token) { + throw mongodbServiceError('MongoDB Atlas token response did not include access_token.'); + } + + return { + accessToken: tokenData.access_token, + expiresAt: new Date(Date.now() + (tokenData.expires_in ?? 3600) * 1000).toISOString() + }; + } catch (error) { + throw mongodbApiError(error, 'service account token exchange'); + } +}; + export let auth = SlateAuth.create() .output( z.object({ token: z.string(), publicKey: z.string().optional(), privateKey: z.string().optional(), - authMethod: z.enum(['oauth', 'digest']) + authMethod: z.enum(['oauth', 'digest']), + expiresAt: z.string().optional() }) ) .addOauth({ @@ -58,67 +102,45 @@ export let auth = SlateAuth.create() ], getAuthorizationUrl: async ctx => { - // MongoDB Atlas OAuth uses client_credentials flow (no user redirect needed) - // We return a token URL that the platform will handle - let params = new URLSearchParams({ - response_type: 'code', - client_id: ctx.clientId, - redirect_uri: ctx.redirectUri, - state: ctx.state, - scope: ctx.scopes.join(' ') - }); + let token = await exchangeServiceAccountToken(ctx); + let callbackUrl = new URL(ctx.redirectUri); + callbackUrl.searchParams.set('code', 'client_credentials'); + callbackUrl.searchParams.set('state', ctx.state); return { - url: `https://cloud.mongodb.com/api/oauth/authorize?${params.toString()}` + url: callbackUrl.toString(), + callbackState: token }; }, handleCallback: async ctx => { - let tokenResponse = await atlasAxios.post( - 'https://cloud.mongodb.com/api/oauth/token', - new URLSearchParams({ - grant_type: 'authorization_code', - code: ctx.code, - redirect_uri: ctx.redirectUri, - client_id: ctx.clientId, - client_secret: ctx.clientSecret - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + let callbackToken = ctx.callbackState as + | { accessToken?: string; expiresAt?: string } + | undefined; + let token = callbackToken?.accessToken + ? { + accessToken: callbackToken.accessToken, + expiresAt: callbackToken.expiresAt } - } - ); + : await exchangeServiceAccountToken(ctx); return { output: { - token: tokenResponse.data.access_token, - authMethod: 'oauth' as const + token: token.accessToken, + authMethod: 'oauth' as const, + expiresAt: token.expiresAt } }; }, handleTokenRefresh: async (ctx: any) => { - // MongoDB Atlas service accounts use client_credentials - get a new token - let tokenResponse = await atlasAxios.post( - 'https://cloud.mongodb.com/api/oauth/token', - new URLSearchParams({ - grant_type: 'client_credentials', - client_id: ctx.clientId, - client_secret: ctx.clientSecret, - scope: ctx.scopes.join(' ') - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - } - ); + let token = await exchangeServiceAccountToken(ctx); return { output: { - token: tokenResponse.data.access_token, - authMethod: 'oauth' as const + token: token.accessToken, + authMethod: 'oauth' as const, + expiresAt: token.expiresAt } }; } @@ -134,20 +156,17 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { - // Verify the credentials work by making a test request - let credentials = btoa(`${ctx.input.publicKey}:${ctx.input.privateKey}`); - - // Test the credentials - await atlasAxios.get('/api/atlas/v2/orgs', { - headers: { - Authorization: `Basic ${credentials}`, - Accept: 'application/vnd.atlas.2025-03-12+json' - } + let client = new AtlasClient({ + token: ctx.input.publicKey, + publicKey: ctx.input.publicKey, + privateKey: ctx.input.privateKey, + authMethod: 'digest' }); + await client.listOrganizations({ itemsPerPage: 1 }); return { output: { - token: credentials, + token: ctx.input.publicKey, publicKey: ctx.input.publicKey, privateKey: ctx.input.privateKey, authMethod: 'digest' as const @@ -164,14 +183,15 @@ export let auth = SlateAuth.create() }; input: { publicKey: string; privateKey: string }; }) => { - let response = await atlasAxios.get('/api/atlas/v2/orgs', { - headers: { - Authorization: `Basic ${ctx.output.token}`, - Accept: 'application/vnd.atlas.2025-03-12+json' - } + let client = new AtlasClient({ + token: ctx.output.publicKey || ctx.output.token, + publicKey: ctx.output.publicKey || ctx.input.publicKey, + privateKey: ctx.output.privateKey || ctx.input.privateKey, + authMethod: 'digest' }); - let orgs = response.data.results || []; + let response = await client.listOrganizations({ itemsPerPage: 1 }); + let orgs = response.results || []; let firstOrg = orgs[0]; return { diff --git a/integrations/mongodb/src/lib/client.ts b/integrations/mongodb/src/lib/client.ts index edfa80ccd1..a6bf75bfaf 100644 --- a/integrations/mongodb/src/lib/client.ts +++ b/integrations/mongodb/src/lib/client.ts @@ -1,32 +1,146 @@ import { createAxios } from 'slates'; +import { buildDigestHeader, parseDigestChallenge } from './digest'; +import { mongodbApiError, mongodbServiceError } from './errors'; let ATLAS_API_VERSION = '2025-03-12'; let ACCEPT_HEADER = `application/vnd.atlas.${ATLAS_API_VERSION}+json`; +let ATLAS_API_PREFIX = '/api/atlas/v2'; +let BASE_URL = 'https://cloud.mongodb.com'; + +type AtlasAuthConfig = { + token: string; + authMethod: string; + publicKey?: string; + privateKey?: string; +}; + +let appendParam = (params: URLSearchParams, key: string, value: unknown) => { + if (value === undefined || value === null) return; + + if (Array.isArray(value)) { + for (let item of value) appendParam(params, key, item); + return; + } + + params.append(key, String(value)); +}; + +let normalizeAtlasUrl = (url: string) => { + if (/^https?:\/\//i.test(url)) return url; + let path = url.startsWith('/') ? url : `/${url}`; + return path.startsWith(ATLAS_API_PREFIX) ? path : `${ATLAS_API_PREFIX}${path}`; +}; + +let appendParamsToUrl = (url: string, params?: Record) => { + if (!params) return url; + + let [rawPath, query = ''] = url.split('?'); + let path = rawPath ?? ''; + let searchParams = new URLSearchParams(query); + + for (let [key, value] of Object.entries(params)) { + appendParam(searchParams, key, value); + } + + let queryString = searchParams.toString(); + return queryString ? `${path}?${queryString}` : path; +}; + +let requestUri = (url: string) => { + if (!/^https?:\/\//i.test(url)) return url; + + let parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; +}; export class AtlasClient { private axios; + private authConfig: AtlasAuthConfig; - constructor(authConfig: { - token: string; - authMethod: string; - publicKey?: string; - privateKey?: string; - }) { - let headers: Record = { - Accept: ACCEPT_HEADER, - 'Content-Type': 'application/json' - }; - - if (authConfig.authMethod === 'oauth') { - headers.Authorization = `Bearer ${authConfig.token}`; - } else { - headers.Authorization = `Basic ${authConfig.token}`; - } + constructor(authConfig: AtlasAuthConfig) { + this.authConfig = authConfig; this.axios = createAxios({ - baseURL: 'https://cloud.mongodb.com/api/atlas/v2', - headers + baseURL: BASE_URL }); + + this.axios.interceptors.request.use((config: any) => { + let url = normalizeAtlasUrl(String(config.url || '')); + url = appendParamsToUrl(url, config.params); + + config.url = url; + config.params = undefined; + config.headers = { + ...config.headers, + Accept: ACCEPT_HEADER, + 'Content-Type': 'application/json' + }; + + if (this.authConfig.authMethod === 'oauth') { + config.headers.Authorization = `Bearer ${this.authConfig.token}`; + } + + return config; + }); + + this.axios.interceptors.response.use( + (response: any) => response, + async (error: any) => { + let response = error?.response; + let config = error?.config; + + if ( + this.authConfig.authMethod === 'digest' && + response?.status === 401 && + config && + !config.__mongodbDigestRetry + ) { + try { + let publicKey = this.authConfig.publicKey; + let privateKey = this.authConfig.privateKey; + + if (!publicKey || !privateKey) { + throw mongodbServiceError( + 'MongoDB Atlas API key authentication requires publicKey and privateKey.' + ); + } + + let wwwAuthenticate = + response.headers?.['www-authenticate'] ?? + response.headers?.get?.('www-authenticate'); + let challenge = parseDigestChallenge(String(wwwAuthenticate || '')); + + if (!challenge) { + throw mongodbServiceError( + 'MongoDB Atlas did not return a usable digest authentication challenge.' + ); + } + + let method = String(config.method || 'GET').toUpperCase(); + config.__mongodbDigestRetry = true; + config.headers = { + ...config.headers, + Authorization: buildDigestHeader({ + method, + uri: requestUri(String(config.url || '')), + username: publicKey, + password: privateKey, + challenge + }) + }; + + return await this.axios.request(config); + } catch (digestError) { + throw mongodbApiError(digestError, 'digest authentication'); + } + } + + throw mongodbApiError( + error, + `${String(config?.method || 'request').toUpperCase()} ${String(config?.url || '')}` + ); + } + ); } // ==================== Organizations ==================== diff --git a/integrations/mongodb/src/lib/digest.ts b/integrations/mongodb/src/lib/digest.ts new file mode 100644 index 0000000000..9c652f913f --- /dev/null +++ b/integrations/mongodb/src/lib/digest.ts @@ -0,0 +1,95 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { mongodbServiceError } from './errors'; + +export type DigestChallenge = { + realm: string; + nonce: string; + qop?: string; + opaque?: string; + algorithm?: string; +}; + +let md5 = (input: string) => createHash('md5').update(input, 'utf8').digest('hex'); + +let quote = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + +let chooseQop = (qop?: string) => { + if (!qop) return undefined; + + let options = qop.split(',').map(option => option.trim().toLowerCase()); + return options.includes('auth') ? 'auth' : undefined; +}; + +export let parseDigestChallenge = (header: string): DigestChallenge | undefined => { + if (!header.toLowerCase().startsWith('digest ')) return undefined; + + let params: Record = {}; + let rest = header.slice(7); + let regex = /(\w+)=(?:"([^"]*)"|([^,\s]+))/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(rest))) { + params[match[1]!] = match[2] ?? match[3] ?? ''; + } + + if (!params.realm || !params.nonce) return undefined; + + return { + realm: params.realm, + nonce: params.nonce, + qop: params.qop, + opaque: params.opaque, + algorithm: params.algorithm + }; +}; + +export let buildDigestHeader = (params: { + method: string; + uri: string; + username: string; + password: string; + challenge: DigestChallenge; +}) => { + let nc = '00000001'; + let cnonce = randomBytes(16).toString('hex'); + let algorithm = params.challenge.algorithm || 'MD5'; + + if (!['MD5', 'MD5-sess'].includes(algorithm)) { + throw mongodbServiceError(`Unsupported digest algorithm: ${algorithm}`); + } + + let ha1 = md5(`${params.username}:${params.challenge.realm}:${params.password}`); + if (algorithm === 'MD5-sess') { + ha1 = md5(`${ha1}:${params.challenge.nonce}:${cnonce}`); + } + + let ha2 = md5(`${params.method}:${params.uri}`); + let qop = chooseQop(params.challenge.qop); + let response = qop + ? md5(`${ha1}:${params.challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`) + : md5(`${ha1}:${params.challenge.nonce}:${ha2}`); + + let parts = [ + `Digest username="${quote(params.username)}"`, + `realm="${quote(params.challenge.realm)}"`, + `nonce="${quote(params.challenge.nonce)}"`, + `uri="${quote(params.uri)}"`, + `response="${response}"` + ]; + + if (qop) { + parts.push(`qop=${qop}`); + parts.push(`nc=${nc}`); + parts.push(`cnonce="${cnonce}"`); + } + + if (params.challenge.opaque) { + parts.push(`opaque="${quote(params.challenge.opaque)}"`); + } + + if (params.challenge.algorithm) { + parts.push(`algorithm=${params.challenge.algorithm}`); + } + + return parts.join(', '); +}; diff --git a/integrations/mongodb/src/lib/errors.ts b/integrations/mongodb/src/lib/errors.ts new file mode 100644 index 0000000000..00d1e126bf --- /dev/null +++ b/integrations/mongodb/src/lib/errors.ts @@ -0,0 +1,93 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushString = (messages: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !messages.includes(trimmed)) { + messages.push(trimmed); + } +}; + +let collectMongoDbMessages = (value: unknown, messages: string[]) => { + if (!isRecord(value)) { + pushString(messages, value); + return; + } + + for (let key of ['detail', 'reason', 'errorCode', 'message', 'error', 'title']) { + pushString(messages, value[key]); + } + + if (Array.isArray(value.parameters)) { + for (let parameter of value.parameters) { + collectMongoDbMessages(parameter, messages); + } + } + + if (Array.isArray(value.errors)) { + for (let error of value.errors) { + collectMongoDbMessages(error, messages); + } + } +}; + +let extractMongoDbMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let messages: string[] = []; + + collectMongoDbMessages(response?.data, messages); + + if (isRecord(error)) { + collectMongoDbMessages(error.data, messages); + } + + if (messages.length > 0) { + return messages.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let mongodbServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let mongodbApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = + response?.status ?? + (isRecord(error) && typeof error.status === 'number' ? error.status : undefined); + let statusText = + response?.statusText ?? + (isRecord(error) && typeof error.statusText === 'string' ? error.statusText : undefined); + let statusLabel = + status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : ''; + + let serviceError = mongodbServiceError( + `MongoDB Atlas API ${operation} failed: ${statusLabel}${extractMongoDbMessage(error)}` + ); + + serviceError.data.reason = 'mongodb_atlas_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/mongodb/src/tools.schema.test.ts b/integrations/mongodb/src/tools.schema.test.ts new file mode 100644 index 0000000000..cf35c381ab --- /dev/null +++ b/integrations/mongodb/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('MongoDB tool input schemas', provider.actions); diff --git a/integrations/mongodb/src/tools/get-billing.ts b/integrations/mongodb/src/tools/get-billing.ts index 6fcdf7b419..5b1dfc0bf3 100644 --- a/integrations/mongodb/src/tools/get-billing.ts +++ b/integrations/mongodb/src/tools/get-billing.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let invoiceSummarySchema = z.object({ @@ -52,7 +53,7 @@ export let getBillingTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let orgId = ctx.input.organizationId || ctx.config.organizationId; - if (!orgId) throw new Error('organizationId is required'); + if (!orgId) throw mongodbServiceError('organizationId is required'); let client = new AtlasClient(ctx.auth); @@ -79,7 +80,7 @@ export let getBillingTool = SlateTool.create(spec, { } if (ctx.input.action === 'get_invoice') { - if (!ctx.input.invoiceId) throw new Error('invoiceId is required'); + if (!ctx.input.invoiceId) throw mongodbServiceError('invoiceId is required'); let invoice = await client.getOrganizationInvoice(orgId, ctx.input.invoiceId); return { output: { invoice }, @@ -95,6 +96,6 @@ export let getBillingTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/get-metrics.ts b/integrations/mongodb/src/tools/get-metrics.ts index 6abb774a29..06bbeb2a03 100644 --- a/integrations/mongodb/src/tools/get-metrics.ts +++ b/integrations/mongodb/src/tools/get-metrics.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let measurementSchema = z.object({ @@ -82,7 +83,7 @@ export let getMetricsTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); @@ -104,7 +105,7 @@ export let getMetricsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get_measurements') { - if (!ctx.input.processId) throw new Error('processId is required'); + if (!ctx.input.processId) throw mongodbServiceError('processId is required'); let params: any = { granularity: ctx.input.granularity || 'PT1H' }; @@ -129,7 +130,7 @@ export let getMetricsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get_disk_measurements') { - if (!ctx.input.processId) throw new Error('processId is required'); + if (!ctx.input.processId) throw mongodbServiceError('processId is required'); let partition = ctx.input.partitionName || 'data'; let params: any = { granularity: ctx.input.granularity || 'PT1H' @@ -159,6 +160,6 @@ export let getMetricsTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/get-network-info.ts b/integrations/mongodb/src/tools/get-network-info.ts index ce70a7c516..627be13ef7 100644 --- a/integrations/mongodb/src/tools/get-network-info.ts +++ b/integrations/mongodb/src/tools/get-network-info.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let peeringConnectionSchema = z.object({ @@ -59,7 +60,7 @@ export let getNetworkInfoTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); @@ -90,7 +91,7 @@ export let getNetworkInfoTool = SlateTool.create(spec, { } if (ctx.input.action === 'get_peering') { - if (!ctx.input.peerId) throw new Error('peerId is required'); + if (!ctx.input.peerId) throw mongodbServiceError('peerId is required'); let p = await client.getNetworkPeeringConnection(projectId, ctx.input.peerId); return { output: { @@ -122,6 +123,6 @@ export let getNetworkInfoTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/list-clusters.ts b/integrations/mongodb/src/tools/list-clusters.ts index ad70f0a6e7..00f14d6de5 100644 --- a/integrations/mongodb/src/tools/list-clusters.ts +++ b/integrations/mongodb/src/tools/list-clusters.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let clusterSummarySchema = z.object({ @@ -46,7 +47,7 @@ export let listClustersTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); let result = await client.listClusters(projectId); diff --git a/integrations/mongodb/src/tools/list-events.ts b/integrations/mongodb/src/tools/list-events.ts index 447230d724..6cc09cfc1a 100644 --- a/integrations/mongodb/src/tools/list-events.ts +++ b/integrations/mongodb/src/tools/list-events.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let eventSchema = z.object({ @@ -70,11 +71,12 @@ export let listEventsTool = SlateTool.create(spec, { let result: any; if (ctx.input.scope === 'project') { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required for project scope'); + if (!projectId) throw mongodbServiceError('projectId is required for project scope'); result = await client.listProjectEvents(projectId, params); } else { let orgId = ctx.input.organizationId || ctx.config.organizationId; - if (!orgId) throw new Error('organizationId is required for organization scope'); + if (!orgId) + throw mongodbServiceError('organizationId is required for organization scope'); result = await client.listOrganizationEvents(orgId, params); } diff --git a/integrations/mongodb/src/tools/manage-alert-configurations.ts b/integrations/mongodb/src/tools/manage-alert-configurations.ts index 80aa8a5f9d..1a18411453 100644 --- a/integrations/mongodb/src/tools/manage-alert-configurations.ts +++ b/integrations/mongodb/src/tools/manage-alert-configurations.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let notificationSchema = z @@ -125,7 +126,7 @@ export let manageAlertConfigurationsTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); @@ -151,7 +152,7 @@ export let manageAlertConfigurationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.alertConfigId) throw new Error('alertConfigId is required'); + if (!ctx.input.alertConfigId) throw mongodbServiceError('alertConfigId is required'); let c = await client.getAlertConfiguration(projectId, ctx.input.alertConfigId); return { output: { alertConfig: mapConfig(c) }, @@ -160,7 +161,7 @@ export let manageAlertConfigurationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.eventTypeName) throw new Error('eventTypeName is required'); + if (!ctx.input.eventTypeName) throw mongodbServiceError('eventTypeName is required'); let payload: any = { eventTypeName: ctx.input.eventTypeName, enabled: ctx.input.enabled ?? true @@ -178,7 +179,7 @@ export let manageAlertConfigurationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.alertConfigId) throw new Error('alertConfigId is required'); + if (!ctx.input.alertConfigId) throw mongodbServiceError('alertConfigId is required'); let payload: any = {}; if (ctx.input.eventTypeName) payload.eventTypeName = ctx.input.eventTypeName; if (ctx.input.enabled !== undefined) payload.enabled = ctx.input.enabled; @@ -199,7 +200,7 @@ export let manageAlertConfigurationsTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.alertConfigId) throw new Error('alertConfigId is required'); + if (!ctx.input.alertConfigId) throw mongodbServiceError('alertConfigId is required'); await client.deleteAlertConfiguration(projectId, ctx.input.alertConfigId); return { output: { deleted: true }, @@ -207,6 +208,6 @@ export let manageAlertConfigurationsTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/manage-alerts.ts b/integrations/mongodb/src/tools/manage-alerts.ts index 6fc52c9b36..7badd87d9d 100644 --- a/integrations/mongodb/src/tools/manage-alerts.ts +++ b/integrations/mongodb/src/tools/manage-alerts.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let alertSchema = z.object({ @@ -68,7 +69,7 @@ export let manageAlertsTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); @@ -100,7 +101,7 @@ export let manageAlertsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.alertId) throw new Error('alertId is required'); + if (!ctx.input.alertId) throw mongodbServiceError('alertId is required'); let a = await client.getAlert(projectId, ctx.input.alertId); return { output: { @@ -125,8 +126,9 @@ export let manageAlertsTool = SlateTool.create(spec, { } if (ctx.input.action === 'acknowledge') { - if (!ctx.input.alertId) throw new Error('alertId is required'); - if (!ctx.input.acknowledgedUntil) throw new Error('acknowledgedUntil is required'); + if (!ctx.input.alertId) throw mongodbServiceError('alertId is required'); + if (!ctx.input.acknowledgedUntil) + throw mongodbServiceError('acknowledgedUntil is required'); let a = await client.acknowledgeAlert(projectId, ctx.input.alertId, { acknowledgedUntil: ctx.input.acknowledgedUntil, acknowledgementComment: ctx.input.acknowledgementComment @@ -153,6 +155,6 @@ export let manageAlertsTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/manage-backups.ts b/integrations/mongodb/src/tools/manage-backups.ts index 73634d682e..13fe5959fa 100644 --- a/integrations/mongodb/src/tools/manage-backups.ts +++ b/integrations/mongodb/src/tools/manage-backups.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let snapshotSchema = z.object({ @@ -107,7 +108,7 @@ export let manageBackupsTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); @@ -131,7 +132,7 @@ export let manageBackupsTool = SlateTool.create(spec, { } if (ctx.input.action === 'get_snapshot') { - if (!ctx.input.snapshotId) throw new Error('snapshotId is required'); + if (!ctx.input.snapshotId) throw mongodbServiceError('snapshotId is required'); let s = await client.getBackupSnapshot( projectId, ctx.input.clusterName, @@ -195,7 +196,7 @@ export let manageBackupsTool = SlateTool.create(spec, { } if (ctx.input.action === 'create_restore_job') { - if (!ctx.input.deliveryType) throw new Error('deliveryType is required'); + if (!ctx.input.deliveryType) throw mongodbServiceError('deliveryType is required'); let payload: any = { deliveryType: ctx.input.deliveryType }; if (ctx.input.snapshotId) payload.snapshotId = ctx.input.snapshotId; if (ctx.input.targetClusterName) payload.targetClusterName = ctx.input.targetClusterName; @@ -229,7 +230,7 @@ export let manageBackupsTool = SlateTool.create(spec, { } if (ctx.input.action === 'update_schedule') { - if (!ctx.input.scheduleData) throw new Error('scheduleData is required'); + if (!ctx.input.scheduleData) throw mongodbServiceError('scheduleData is required'); let schedule = await client.updateBackupSchedule( projectId, ctx.input.clusterName, @@ -241,6 +242,6 @@ export let manageBackupsTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/manage-cluster.ts b/integrations/mongodb/src/tools/manage-cluster.ts index b5cdf05668..b6f113a7af 100644 --- a/integrations/mongodb/src/tools/manage-cluster.ts +++ b/integrations/mongodb/src/tools/manage-cluster.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let replicationSpecSchema = z @@ -125,7 +126,7 @@ export let manageClusterTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); @@ -146,7 +147,8 @@ export let manageClusterTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.clusterType) throw new Error('clusterType is required for create'); + if (!ctx.input.clusterType) + throw mongodbServiceError('clusterType is required for create'); let payload: Record = { name: ctx.input.clusterName, clusterType: ctx.input.clusterType @@ -241,6 +243,6 @@ export let manageClusterTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/manage-database-user.ts b/integrations/mongodb/src/tools/manage-database-user.ts index 63f4b9d0ca..d2ceffe775 100644 --- a/integrations/mongodb/src/tools/manage-database-user.ts +++ b/integrations/mongodb/src/tools/manage-database-user.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let roleSchema = z.object({ @@ -98,7 +99,7 @@ export let manageDatabaseUserTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); @@ -118,7 +119,7 @@ export let manageDatabaseUserTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.username) throw new Error('username is required'); + if (!ctx.input.username) throw mongodbServiceError('username is required'); let authDb = ctx.input.databaseName || 'admin'; let user = await client.getDatabaseUser(projectId, authDb, ctx.input.username); return { @@ -136,9 +137,9 @@ export let manageDatabaseUserTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.username) throw new Error('username is required'); + if (!ctx.input.username) throw mongodbServiceError('username is required'); if (!ctx.input.roles || ctx.input.roles.length === 0) - throw new Error('At least one role is required'); + throw mongodbServiceError('At least one role is required'); let authDb = ctx.input.databaseName || 'admin'; let payload: any = { @@ -169,7 +170,7 @@ export let manageDatabaseUserTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.username) throw new Error('username is required'); + if (!ctx.input.username) throw mongodbServiceError('username is required'); let authDb = ctx.input.databaseName || 'admin'; let payload: any = {}; if (ctx.input.roles) payload.roles = ctx.input.roles; @@ -198,7 +199,7 @@ export let manageDatabaseUserTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.username) throw new Error('username is required'); + if (!ctx.input.username) throw mongodbServiceError('username is required'); let authDb = ctx.input.databaseName || 'admin'; await client.deleteDatabaseUser(projectId, authDb, ctx.input.username); return { @@ -214,6 +215,6 @@ export let manageDatabaseUserTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/manage-ip-access-list.ts b/integrations/mongodb/src/tools/manage-ip-access-list.ts index fa1fbba2b1..04c014e8ed 100644 --- a/integrations/mongodb/src/tools/manage-ip-access-list.ts +++ b/integrations/mongodb/src/tools/manage-ip-access-list.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let accessListEntrySchema = z.object({ @@ -68,7 +69,7 @@ export let manageIpAccessListTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); @@ -90,7 +91,7 @@ export let manageIpAccessListTool = SlateTool.create(spec, { if (ctx.input.action === 'add') { if (!ctx.input.entries || ctx.input.entries.length === 0) - throw new Error('At least one entry is required'); + throw mongodbServiceError('At least one entry is required'); let result = await client.addIpAccessListEntries(projectId, ctx.input.entries); let entries = (result.results || []).map((e: any) => ({ ipAddress: e.ipAddress, @@ -107,7 +108,8 @@ export let manageIpAccessListTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.entryValue) throw new Error('entryValue is required for delete action'); + if (!ctx.input.entryValue) + throw mongodbServiceError('entryValue is required for delete action'); await client.deleteIpAccessListEntry(projectId, ctx.input.entryValue); return { output: { deleted: true }, @@ -115,6 +117,6 @@ export let manageIpAccessListTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/manage-project.ts b/integrations/mongodb/src/tools/manage-project.ts index 9fc912523d..ee9da61984 100644 --- a/integrations/mongodb/src/tools/manage-project.ts +++ b/integrations/mongodb/src/tools/manage-project.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageProjectTool = SlateTool.create(spec, { @@ -34,7 +35,7 @@ export let manageProjectTool = SlateTool.create(spec, { if (ctx.input.action === 'get') { let pid = ctx.input.projectId || ctx.config.projectId; - if (!pid) throw new Error('projectId is required for get action'); + if (!pid) throw mongodbServiceError('projectId is required for get action'); let project = await client.getProject(pid); return { output: { @@ -49,9 +50,9 @@ export let manageProjectTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); + if (!ctx.input.name) throw mongodbServiceError('name is required for create action'); let orgId = ctx.input.organizationId || ctx.config.organizationId; - if (!orgId) throw new Error('organizationId is required for create action'); + if (!orgId) throw mongodbServiceError('organizationId is required for create action'); let project = await client.createProject({ name: ctx.input.name, orgId @@ -70,7 +71,7 @@ export let manageProjectTool = SlateTool.create(spec, { if (ctx.input.action === 'delete') { let pid = ctx.input.projectId || ctx.config.projectId; - if (!pid) throw new Error('projectId is required for delete action'); + if (!pid) throw mongodbServiceError('projectId is required for delete action'); await client.deleteProject(pid); return { output: { @@ -81,6 +82,6 @@ export let manageProjectTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/tools/manage-search-indexes.ts b/integrations/mongodb/src/tools/manage-search-indexes.ts index 78fe16908c..1a09367eb7 100644 --- a/integrations/mongodb/src/tools/manage-search-indexes.ts +++ b/integrations/mongodb/src/tools/manage-search-indexes.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; let searchIndexSchema = z.object({ @@ -63,13 +64,14 @@ export let manageSearchIndexesTool = SlateTool.create(spec, { ) .handleInvocation(async ctx => { let projectId = ctx.input.projectId || ctx.config.projectId; - if (!projectId) throw new Error('projectId is required'); + if (!projectId) throw mongodbServiceError('projectId is required'); let client = new AtlasClient(ctx.auth); if (ctx.input.action === 'list') { - if (!ctx.input.database) throw new Error('database is required for list'); - if (!ctx.input.collectionName) throw new Error('collectionName is required for list'); + if (!ctx.input.database) throw mongodbServiceError('database is required for list'); + if (!ctx.input.collectionName) + throw mongodbServiceError('collectionName is required for list'); let result = await client.listSearchIndexes( projectId, ctx.input.clusterName, @@ -95,7 +97,7 @@ export let manageSearchIndexesTool = SlateTool.create(spec, { } if (ctx.input.action === 'get') { - if (!ctx.input.indexId) throw new Error('indexId is required'); + if (!ctx.input.indexId) throw mongodbServiceError('indexId is required'); let idx = await client.getSearchIndex( projectId, ctx.input.clusterName, @@ -119,9 +121,9 @@ export let manageSearchIndexesTool = SlateTool.create(spec, { } if (ctx.input.action === 'create') { - if (!ctx.input.database) throw new Error('database is required'); - if (!ctx.input.collectionName) throw new Error('collectionName is required'); - if (!ctx.input.name) throw new Error('name is required'); + if (!ctx.input.database) throw mongodbServiceError('database is required'); + if (!ctx.input.collectionName) throw mongodbServiceError('collectionName is required'); + if (!ctx.input.name) throw mongodbServiceError('name is required'); let payload: any = { database: ctx.input.database, collectionName: ctx.input.collectionName, @@ -148,7 +150,7 @@ export let manageSearchIndexesTool = SlateTool.create(spec, { } if (ctx.input.action === 'update') { - if (!ctx.input.indexId) throw new Error('indexId is required'); + if (!ctx.input.indexId) throw mongodbServiceError('indexId is required'); let payload: any = {}; if (ctx.input.definition) payload.definition = ctx.input.definition; @@ -175,7 +177,7 @@ export let manageSearchIndexesTool = SlateTool.create(spec, { } if (ctx.input.action === 'delete') { - if (!ctx.input.indexId) throw new Error('indexId is required'); + if (!ctx.input.indexId) throw mongodbServiceError('indexId is required'); await client.deleteSearchIndex(projectId, ctx.input.clusterName, ctx.input.indexId); return { output: { deleted: true }, @@ -183,6 +185,6 @@ export let manageSearchIndexesTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw mongodbServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/mongodb/src/triggers/alert-webhook.ts b/integrations/mongodb/src/triggers/alert-webhook.ts index 99ea8f7dd2..3ae0845483 100644 --- a/integrations/mongodb/src/triggers/alert-webhook.ts +++ b/integrations/mongodb/src/triggers/alert-webhook.ts @@ -1,6 +1,7 @@ import { SlateTrigger } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; export let alertWebhookTrigger = SlateTrigger.create(spec, { @@ -60,7 +61,9 @@ export let alertWebhookTrigger = SlateTrigger.create(spec, { autoRegisterWebhook: async ctx => { let projectId = ctx.config.projectId; if (!projectId) - throw new Error('projectId is required in configuration to auto-register webhooks'); + throw mongodbServiceError( + 'projectId is required in configuration to auto-register webhooks' + ); let client = new AtlasClient(ctx.auth); let result = await client.configureWebhookIntegration(projectId, { diff --git a/integrations/mongodb/src/triggers/project-events.ts b/integrations/mongodb/src/triggers/project-events.ts index dcbbb092ee..359ba37a85 100644 --- a/integrations/mongodb/src/triggers/project-events.ts +++ b/integrations/mongodb/src/triggers/project-events.ts @@ -1,6 +1,7 @@ import { SlateDefaultPollingIntervalSeconds, SlateTrigger } from 'slates'; import { z } from 'zod'; import { AtlasClient } from '../lib/client'; +import { mongodbServiceError } from '../lib/errors'; import { spec } from '../spec'; export let projectEventsTrigger = SlateTrigger.create(spec, { @@ -46,7 +47,9 @@ export let projectEventsTrigger = SlateTrigger.create(spec, { pollEvents: async ctx => { let projectId = ctx.config.projectId; if (!projectId) - throw new Error('projectId is required in configuration for project events polling'); + throw mongodbServiceError( + 'projectId is required in configuration for project events polling' + ); let client = new AtlasClient(ctx.auth); diff --git a/integrations/mongodb/vitest.config.ts b/integrations/mongodb/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/mongodb/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/mysql/README.md b/integrations/mysql/README.md index 17316f7174..00ac48d6ac 100644 --- a/integrations/mysql/README.md +++ b/integrations/mysql/README.md @@ -28,6 +28,10 @@ List all databases on the MySQL server. Returns database names, default characte List all tables in a MySQL database. Returns table names, types, engines, row estimates, and size information. Also supports listing views. +### Manage Database + +Create or drop MySQL databases. Supports IF NOT EXISTS for creation, IF EXISTS for drops, and optional default character set and collation settings. + ### Manage Indexes Create or drop indexes on MySQL tables. Supports standard, unique, fulltext, and spatial indexes. Useful for optimizing query performance by adding appropriate indexes. diff --git a/integrations/mysql/package.json b/integrations/mysql/package.json index f191724f7e..338d0e2caa 100644 --- a/integrations/mysql/package.json +++ b/integrations/mysql/package.json @@ -4,15 +4,20 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "mysql2": "^3.22.5", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/mysql/src/auth.ts b/integrations/mysql/src/auth.ts index 94d3b1e84e..ed579240bb 100644 --- a/integrations/mysql/src/auth.ts +++ b/integrations/mysql/src/auth.ts @@ -1,5 +1,7 @@ +import { ServiceError } from '@lowerdeck/error'; import { SlateAuth } from 'slates'; import { z } from 'zod'; +import { mysqlServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -10,6 +12,11 @@ export let auth = SlateAuth.create() username: z.string().describe('Database username'), password: z.string().describe('Database password'), sslMode: z.enum(['disabled', 'preferred', 'required']).describe('SSL connection mode'), + sslCa: z.string().optional().describe('PEM-encoded CA certificate for SSL connections'), + sslRejectUnauthorized: z + .boolean() + .optional() + .describe('Whether SSL connections must reject unauthorized certificates'), connectionString: z.string().describe('Full MySQL connection URI') }) ) @@ -38,6 +45,8 @@ export let auth = SlateAuth.create() username: parsed.username, password: parsed.password, sslMode: parsed.sslMode, + sslCa: parsed.sslCa, + sslRejectUnauthorized: parsed.sslRejectUnauthorized, connectionString: connStr } }; @@ -57,11 +66,18 @@ export let auth = SlateAuth.create() sslMode: z .enum(['disabled', 'preferred', 'required']) .default('preferred') - .describe('SSL connection mode') + .describe('SSL connection mode'), + sslCa: z.string().optional().describe('PEM-encoded CA certificate for SSL connections'), + sslRejectUnauthorized: z + .boolean() + .optional() + .default(true) + .describe('Reject unauthorized SSL certificates when SSL is enabled') }), getOutput: async ctx => { - let { host, port, database, username, password, sslMode } = ctx.input; + let { host, port, database, username, password, sslMode, sslCa, sslRejectUnauthorized } = + ctx.input; let encodedPassword = encodeURIComponent(password); let encodedUsername = encodeURIComponent(username); let sslParam = sslMode !== 'disabled' ? `?ssl=${sslMode}` : ''; @@ -75,6 +91,8 @@ export let auth = SlateAuth.create() username, password, sslMode, + sslCa, + sslRejectUnauthorized, connectionString } }; @@ -90,23 +108,35 @@ let parseConnectionString = ( username: string; password: string; sslMode: 'disabled' | 'preferred' | 'required'; + sslCa?: string; + sslRejectUnauthorized: boolean; } => { let url: URL; + if (!/^mysql:\/\//i.test(connStr)) { + throw mysqlServiceError( + 'Invalid MySQL connection string format. Expected: mysql://user:password@host:port/dbname' + ); + } + try { - // Replace mysql:// with http:// for URL parsing - let normalized = connStr.replace(/^mysql:\/\//, 'http://'); + let normalized = connStr.replace(/^mysql:\/\//i, 'http://'); url = new URL(normalized); - } catch { - throw new Error( + } catch (error) { + if (error instanceof ServiceError) throw error; + throw mysqlServiceError( 'Invalid MySQL connection string format. Expected: mysql://user:password@host:port/dbname' ); } - let sslParam = url.searchParams.get('ssl') || url.searchParams.get('sslmode') || 'preferred'; - let validSslModes = ['disabled', 'preferred', 'required'] as const; - let sslMode: (typeof validSslModes)[number] = validSslModes.includes(sslParam as any) - ? (sslParam as (typeof validSslModes)[number]) - : 'preferred'; + let sslMode = parseSslMode( + url.searchParams.get('ssl') || url.searchParams.get('sslmode') || 'preferred' + ); + let sslRejectUnauthorized = parseBooleanParam( + url.searchParams.get('sslRejectUnauthorized') || + url.searchParams.get('sslrejectunauthorized'), + true, + 'sslRejectUnauthorized' + ); return { host: url.hostname || 'localhost', @@ -114,6 +144,26 @@ let parseConnectionString = ( database: url.pathname.replace(/^\//, '') || '', username: decodeURIComponent(url.username || 'root'), password: decodeURIComponent(url.password || ''), - sslMode + sslMode, + sslCa: url.searchParams.get('sslCa') || url.searchParams.get('sslca') || undefined, + sslRejectUnauthorized }; }; + +let parseSslMode = (value: string): 'disabled' | 'preferred' | 'required' => { + let normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1') return 'required'; + if (normalized === 'false' || normalized === '0') return 'disabled'; + if (['disabled', 'preferred', 'required'].includes(normalized)) { + return normalized as 'disabled' | 'preferred' | 'required'; + } + throw mysqlServiceError('ssl must be one of disabled, preferred, required, true, or false.'); +}; + +let parseBooleanParam = (value: string | null, defaultValue: boolean, label: string) => { + if (value === null) return defaultValue; + let normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes'].includes(normalized)) return true; + if (['false', '0', 'no'].includes(normalized)) return false; + throw mysqlServiceError(`${label} must be true or false.`); +}; diff --git a/integrations/mysql/src/index.ts b/integrations/mysql/src/index.ts index ad56d31a06..81933146ee 100644 --- a/integrations/mysql/src/index.ts +++ b/integrations/mysql/src/index.ts @@ -7,6 +7,7 @@ import { insertRows, listDatabases, listTables, + manageDatabase, manageIndexes, manageTable, manageUsers, @@ -23,6 +24,7 @@ export let provider = Slate.create({ insertRows, updateRows, deleteRows, + manageDatabase, manageTable, manageIndexes, listDatabases, diff --git a/integrations/mysql/src/lib/client.ts b/integrations/mysql/src/lib/client.ts index 4f7663d92e..37944f2da0 100644 --- a/integrations/mysql/src/lib/client.ts +++ b/integrations/mysql/src/lib/client.ts @@ -1,38 +1,6 @@ -import * as crypto from 'crypto'; -import * as net from 'net'; -import * as tls from 'tls'; -import { - buildComQuery, - buildComQuit, - buildHandshakeResponse41, - buildPacket, - buildSSLRequest, - CLIENT_CONNECT_WITH_DB, - CLIENT_DEPRECATE_EOF, - CLIENT_FOUND_ROWS, - CLIENT_LONG_FLAG, - CLIENT_LONG_PASSWORD, - CLIENT_PLUGIN_AUTH, - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA, - CLIENT_PROTOCOL_41, - CLIENT_SECURE_CONNECTION, - CLIENT_SSL, - type ColumnDefinition, - getFullAuthPluginData, - type HandshakeV10, - isEofPacket, - isErrPacket, - isOkPacket, - mysqlTypeToName, - parseColumnDefinition41, - parseErrPacket, - parseHandshakeV10, - parseOkPacket, - parsePackets, - parseTextResultRow, - readLenencInt, - UTF8MB4_GENERAL_CI -} from './protocol'; +import type { ConnectionOptions, FieldPacket, ResultSetHeader } from 'mysql2/promise'; +import * as mysql from 'mysql2/promise'; +import { mysqlApiError } from './errors'; export interface ConnectionConfig { host: string; @@ -41,17 +9,83 @@ export interface ConnectionConfig { username: string; password: string; sslMode: 'disabled' | 'preferred' | 'required'; + sslCa?: string; + sslRejectUnauthorized?: boolean; queryTimeout?: number; } export interface QueryResult { columns: { name: string; type: string; typeId: number }[]; - rows: Record[]; + rows: Record[]; affectedRows: number; lastInsertId: number; command: string; } +let mysqlTypeNames: Record = { + 0: 'decimal', + 1: 'tinyint', + 2: 'smallint', + 3: 'int', + 4: 'float', + 5: 'double', + 6: 'null', + 7: 'timestamp', + 8: 'bigint', + 9: 'mediumint', + 10: 'date', + 11: 'time', + 12: 'datetime', + 13: 'year', + 14: 'date', + 15: 'varchar', + 16: 'bit', + 245: 'json', + 246: 'decimal', + 247: 'enum', + 248: 'set', + 249: 'tinyblob', + 250: 'mediumblob', + 251: 'longblob', + 252: 'blob', + 253: 'varchar', + 254: 'char', + 255: 'geometry' +}; + +let guessCommand = (sql: string): string => { + let trimmed = sql.trim().toUpperCase(); + return trimmed.split(/\s+/)[0] || ''; +}; + +let fieldTypeId = (field: FieldPacket) => { + let candidate = field as FieldPacket & { columnType?: number; type?: number }; + return candidate.columnType ?? candidate.type ?? -1; +}; + +let normalizeRows = (rows: unknown): Record[] => { + if (!Array.isArray(rows)) return []; + + let first = rows[0]; + if (Array.isArray(first)) { + return first.filter(row => typeof row === 'object' && row !== null) as Record< + string, + unknown + >[]; + } + + return rows.filter(row => typeof row === 'object' && row !== null) as Record< + string, + unknown + >[]; +}; + +let isResultSetHeader = (value: unknown): value is ResultSetHeader => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + 'affectedRows' in value; + export class MySQLClient { private config: ConnectionConfig; @@ -61,629 +95,94 @@ export class MySQLClient { async query(sql: string, timeoutMs?: number): Promise { let timeout = timeoutMs || this.config.queryTimeout || 30000; - let socket = await this.connect(timeout); + let connection: mysql.Connection | undefined; try { - let result = await this.executeQuery(socket, sql, timeout); - await this.terminate(socket); - return result; - } catch (err) { - socket.destroy(); - throw err; + connection = await mysql.createConnection(this.connectionOptions(timeout)); + let [rows, fields] = await connection.query({ sql, timeout, rowsAsArray: false }); + return this.toQueryResult(sql, rows, fields); + } catch (error) { + throw mysqlApiError(error, 'query'); + } finally { + if (connection) { + await connection.end().catch(() => undefined); + } } } async multiQuery(statements: string[], timeoutMs?: number): Promise { - let timeout = timeoutMs || this.config.queryTimeout || 30000; - let socket = await this.connect(timeout); - - try { - let results: QueryResult[] = []; - for (let sql of statements) { - let result = await this.executeQuery(socket, sql, timeout); - results.push(result); - } - await this.terminate(socket); - return results; - } catch (err) { - socket.destroy(); - throw err; + let results: QueryResult[] = []; + for (let statement of statements) { + results.push(await this.query(statement, timeoutMs)); } + return results; } - private connect(timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let timer = setTimeout(() => { - socket.destroy(); - reject(new Error(`Connection timeout after ${timeoutMs}ms`)); - }, timeoutMs); - - let socket: net.Socket; - - let handleHandshake = async (sock: net.Socket) => { - try { - await this.performHandshake(sock, timeoutMs); - clearTimeout(timer); - resolve(sock); - } catch (err) { - clearTimeout(timer); - sock.destroy(); - reject(err); - } - }; - - socket = net.createConnection({ - host: this.config.host, - port: this.config.port - }); - - socket.once('error', err => { - clearTimeout(timer); - reject(new Error(`Connection failed: ${err.message}`)); - }); - - socket.once('connect', () => { - handleHandshake(socket); - }); - }); - } - - private performHandshake(socket: net.Socket, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let buffer: Uint8Array = new Uint8Array(0); - let _handshakeDone = false; - - let timer = setTimeout(() => { - cleanup(); - reject(new Error(`Handshake timeout after ${timeoutMs}ms`)); - }, timeoutMs); - - let cleanup = () => { - clearTimeout(timer); - socket.removeListener('data', onData); - socket.removeListener('error', onError); - }; - - let onError = (err: Error) => { - cleanup(); - reject(new Error(`Handshake error: ${err.message}`)); + private connectionOptions(timeout: number): ConnectionOptions { + let options: ConnectionOptions = { + host: this.config.host, + port: this.config.port, + user: this.config.username, + password: this.config.password, + database: this.config.database || undefined, + connectTimeout: timeout, + namedPlaceholders: true, + multipleStatements: false, + rowsAsArray: false, + supportBigNumbers: true, + bigNumberStrings: true, + dateStrings: true, + decimalNumbers: false, + charset: 'utf8mb4' + }; + + if (this.config.sslMode !== 'disabled') { + options.ssl = { + ca: this.config.sslCa, + rejectUnauthorized: this.config.sslRejectUnauthorized ?? true }; - - let phase: - | 'initial' - | 'ssl_upgrade' - | 'auth_response' - | 'auth_switch' - | 'auth_more_data' = 'initial'; - let handshake: HandshakeV10 | null = null; - let currentAuthPlugin = ''; - let authData: Uint8Array = new Uint8Array(0); - let sequenceId = 0; - - let onData = (data: Uint8Array) => { - let newBuf = new Uint8Array(buffer.length + data.length); - newBuf.set(buffer); - newBuf.set(data, buffer.length); - buffer = newBuf; - - let { packets, remaining } = parsePackets(buffer); - buffer = remaining; - - for (let packet of packets) { - sequenceId = packet.sequenceId + 1; - - if (phase === 'initial') { - // First packet should be the handshake - if (isErrPacket(packet.payload)) { - let err = parseErrPacket(packet.payload); - cleanup(); - reject(new Error(`MySQL error [${err.errorCode}]: ${err.errorMessage}`)); - return; - } - - handshake = parseHandshakeV10(packet.payload); - authData = getFullAuthPluginData(handshake); - currentAuthPlugin = handshake.authPluginName; - - // Determine client capability flags - let clientFlags = - CLIENT_LONG_PASSWORD | - CLIENT_FOUND_ROWS | - CLIENT_LONG_FLAG | - CLIENT_PROTOCOL_41 | - CLIENT_SECURE_CONNECTION | - CLIENT_PLUGIN_AUTH | - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | - CLIENT_DEPRECATE_EOF; - - if (this.config.database) { - clientFlags |= CLIENT_CONNECT_WITH_DB; - } - - // Handle SSL - if (this.config.sslMode !== 'disabled' && handshake.capabilityFlags & CLIENT_SSL) { - clientFlags |= CLIENT_SSL; - - // Send SSL request packet - let sslRequest = buildSSLRequest(clientFlags, 0x01000000, UTF8MB4_GENERAL_CI); - socket.write(buildPacket(sequenceId, sslRequest)); - sequenceId++; - - phase = 'ssl_upgrade'; - - // Upgrade to TLS - let tlsOptions: tls.ConnectionOptions = { - socket: socket, - rejectUnauthorized: false, - servername: this.config.host - }; - - let tlsSocket = tls.connect(tlsOptions); - tlsSocket.once('secureConnect', () => { - // Replace socket listeners - socket.removeListener('data', onData); - socket.removeListener('error', onError); - - // Send handshake response over TLS - let authResponse = this.computeAuthResponse( - currentAuthPlugin, - this.config.password, - authData - ); - let handshakeResponsePayload = buildHandshakeResponse41({ - capabilityFlags: clientFlags, - maxPacketSize: 0x01000000, - characterSet: UTF8MB4_GENERAL_CI, - username: this.config.username, - authResponse, - database: this.config.database || '', - authPluginName: currentAuthPlugin - }); - - tlsSocket.write(buildPacket(sequenceId, handshakeResponsePayload)); - sequenceId++; - phase = 'auth_response'; - - // Set up TLS socket data handler - tlsSocket.on('data', onData); - tlsSocket.on('error', onError); - - // Replace socket reference in closure for terminate/query - (socket as any).__tlsSocket = tlsSocket; - }); - - tlsSocket.once('error', err => { - if (this.config.sslMode === 'required') { - cleanup(); - reject(new Error(`SSL connection failed: ${err.message}`)); - } else { - // Preferred mode - fall back to non-SSL - // This is tricky since we already sent SSL request, so just reject - cleanup(); - reject(new Error(`SSL negotiation failed: ${err.message}`)); - } - }); - - return; // Stop processing more packets, TLS handler will take over - } - - // Non-SSL: send handshake response directly - let authResponse = this.computeAuthResponse( - currentAuthPlugin, - this.config.password, - authData - ); - let handshakeResponsePayload = buildHandshakeResponse41({ - capabilityFlags: clientFlags, - maxPacketSize: 0x01000000, - characterSet: UTF8MB4_GENERAL_CI, - username: this.config.username, - authResponse, - database: this.config.database || '', - authPluginName: currentAuthPlugin - }); - - socket.write(buildPacket(sequenceId, handshakeResponsePayload)); - sequenceId++; - phase = 'auth_response'; - } else if ( - phase === 'auth_response' || - phase === 'auth_switch' || - phase === 'auth_more_data' - ) { - if (isOkPacket(packet.payload)) { - // Authentication successful - _handshakeDone = true; - cleanup(); - resolve(); - return; - } - - if (isErrPacket(packet.payload)) { - let err = parseErrPacket(packet.payload); - cleanup(); - reject( - new Error(`MySQL authentication error [${err.errorCode}]: ${err.errorMessage}`) - ); - return; - } - - // Auth switch request (0xfe) - if (packet.payload[0] === 0xfe && phase !== 'auth_more_data') { - let { value: pluginName, nextOffset } = readNullTerminatedStringFromPayload( - packet.payload, - 1 - ); - currentAuthPlugin = pluginName; - authData = packet.payload.slice(nextOffset); - // Remove trailing null if present - if (authData.length > 0 && authData[authData.length - 1] === 0) { - authData = authData.slice(0, authData.length - 1); - } - - let authResponse = this.computeAuthResponse( - currentAuthPlugin, - this.config.password, - authData - ); - socket.write(buildPacket(sequenceId, authResponse)); - sequenceId++; - phase = 'auth_switch'; - continue; - } - - // Auth more data (0x01) - used by caching_sha2_password - if (packet.payload[0] === 0x01) { - let statusTag = packet.payload[1]; - - if (statusTag === 0x03) { - // Fast auth success - next packet should be OK - phase = 'auth_more_data'; - continue; - } - - if (statusTag === 0x04) { - // Full auth required - send password in cleartext over secure connection - // This is safe because we should be over TLS or the server uses RSA - let encoder = new TextEncoder(); - let passwordBytes = encoder.encode(this.config.password); - let payload = new Uint8Array(passwordBytes.length + 1); - payload.set(passwordBytes); - payload[passwordBytes.length] = 0; // null terminated - - socket.write(buildPacket(sequenceId, payload)); - sequenceId++; - phase = 'auth_more_data'; - } - } - } - } - }; - - socket.on('data', onData); - socket.on('error', onError); - }); - } - - private computeAuthResponse( - pluginName: string, - password: string, - authData: Uint8Array - ): Uint8Array { - if (!password) { - return new Uint8Array(0); - } - - if (pluginName === 'mysql_native_password') { - return this.nativePasswordAuth(password, authData); - } - - if (pluginName === 'caching_sha2_password') { - return this.cachingSha2PasswordAuth(password, authData); - } - - if (pluginName === 'sha256_password') { - // For sha256_password over secure connection, send cleartext - let encoder = new TextEncoder(); - let passwordBytes = encoder.encode(password); - let buf = new Uint8Array(passwordBytes.length + 1); - buf.set(passwordBytes); - buf[passwordBytes.length] = 0; - return buf; - } - - // Unknown plugin, try native password as fallback - return this.nativePasswordAuth(password, authData); - } - - private nativePasswordAuth(password: string, authData: Uint8Array): Uint8Array { - // SHA1(password) XOR SHA1(scramble + SHA1(SHA1(password))) - let passwordHash = sha1(password); - let doubleHash = sha1Bytes(passwordHash); - let combined = new Uint8Array(authData.length + doubleHash.length); - combined.set(authData); - combined.set(doubleHash, authData.length); - let scrambleHash = sha1Bytes(combined); - let result = new Uint8Array(20); - for (let i = 0; i < 20; i++) { - result[i] = passwordHash[i]! ^ scrambleHash[i]!; } - return result; - } - private cachingSha2PasswordAuth(password: string, authData: Uint8Array): Uint8Array { - // SHA256(password) XOR SHA256(SHA256(SHA256(password)) + authData) - let passwordHash = sha256(password); - let doubleHash = sha256Bytes(passwordHash); - let combined = new Uint8Array(doubleHash.length + authData.length); - combined.set(doubleHash); - combined.set(authData, doubleHash.length); - let scrambleHash = sha256Bytes(combined); - let result = new Uint8Array(passwordHash.length); - for (let i = 0; i < passwordHash.length; i++) { - result[i] = passwordHash[i]! ^ scrambleHash[i]!; - } - return result; + return options; } - private executeQuery( - socket: net.Socket, + private toQueryResult( sql: string, - timeoutMs: number - ): Promise { - // Use TLS socket if available - let activeSocket: net.Socket = (socket as any).__tlsSocket || socket; - - return new Promise((resolve, reject) => { - let buffer: Uint8Array = new Uint8Array(0); - let columns: ColumnDefinition[] = []; - let rows: (string | null)[][] = []; - let columnCount = 0; - let phase: 'column_count' | 'column_defs' | 'column_eof' | 'rows' | 'done' = - 'column_count'; - let deprecateEof = true; // assume CLIENT_DEPRECATE_EOF - - let timer = setTimeout(() => { - cleanup(); - reject(new Error(`Query timeout after ${timeoutMs}ms`)); - }, timeoutMs); - - let cleanup = () => { - clearTimeout(timer); - activeSocket.removeListener('data', onData); - activeSocket.removeListener('error', onError); - }; - - let onError = (err: Error) => { - cleanup(); - reject(new Error(`Query error: ${err.message}`)); - }; - - let processPackets = () => { - let { packets, remaining } = parsePackets(buffer); - buffer = remaining; - - for (let packet of packets) { - let payload = packet.payload; - - if (phase === 'column_count') { - // First response packet - if (isErrPacket(payload)) { - let err = parseErrPacket(payload); - cleanup(); - reject( - new Error( - `MySQL query error [${err.errorCode}] ${err.sqlState ? `(${err.sqlState})` : ''}: ${err.errorMessage}` - ) - ); - return; - } - - if (isOkPacket(payload)) { - // Non-result-set response (INSERT, UPDATE, DELETE, DDL, etc.) - let ok = parseOkPacket(payload); - let command = guessCommand(sql); - cleanup(); - resolve({ - columns: [], - rows: [], - affectedRows: ok.affectedRows, - lastInsertId: ok.lastInsertId, - command - }); - return; - } - - // Column count (lenenc int) - let { value } = readLenencInt(payload, 0); - columnCount = value; - columns = []; - rows = []; - phase = 'column_defs'; - continue; - } - - if (phase === 'column_defs') { - if (isErrPacket(payload)) { - let err = parseErrPacket(payload); - cleanup(); - reject(new Error(`MySQL query error [${err.errorCode}]: ${err.errorMessage}`)); - return; - } - - // With CLIENT_DEPRECATE_EOF, no EOF after column definitions - // Column definition packets - if (!isEofPacket(payload) && !isOkPacket(payload)) { - columns.push(parseColumnDefinition41(payload)); - if (columns.length >= columnCount) { - phase = deprecateEof ? 'rows' : 'column_eof'; - } - continue; - } - - // EOF packet (non-deprecate mode) - if (isEofPacket(payload)) { - phase = 'rows'; - continue; - } - } - - if (phase === 'column_eof') { - // Waiting for EOF after column definitions - if (isEofPacket(payload)) { - phase = 'rows'; - continue; - } - } - - if (phase === 'rows') { - if (isErrPacket(payload)) { - let err = parseErrPacket(payload); - cleanup(); - reject(new Error(`MySQL query error [${err.errorCode}]: ${err.errorMessage}`)); - return; - } - - // End of rows: EOF packet (non-deprecate) or OK packet (deprecate_eof) - if (isEofPacket(payload) || (isOkPacket(payload) && payload.length > 7)) { - // Check if this is an OK packet that signals end of result set - let ok = isOkPacket(payload) ? parseOkPacket(payload) : null; - let command = guessCommand(sql); - - let mappedColumns = columns.map(col => ({ - name: col.name, - type: mysqlTypeToName(col.columnType), - typeId: col.columnType - })); - - let mappedRows = rows.map(row => { - let obj: Record = {}; - for (let i = 0; i < columns.length; i++) { - let col = columns[i]!; - let value = row[i] ?? null; - obj[col.name] = castValue(value, col.columnType); - } - return obj; - }); - - cleanup(); - resolve({ - columns: mappedColumns, - rows: mappedRows, - affectedRows: ok?.affectedRows ?? rows.length, - lastInsertId: ok?.lastInsertId ?? 0, - command - }); - return; - } - - // Data row - rows.push(parseTextResultRow(payload, columnCount)); - } - } + rows: unknown, + fields: FieldPacket[] | FieldPacket[][] | undefined + ): QueryResult { + let command = guessCommand(sql); + + if (isResultSetHeader(rows)) { + return { + columns: [], + rows: [], + affectedRows: rows.affectedRows ?? 0, + lastInsertId: rows.insertId ?? 0, + command }; + } - let onData = (data: Uint8Array) => { - let newBuf = new Uint8Array(buffer.length + data.length); - newBuf.set(buffer); - newBuf.set(data, buffer.length); - buffer = newBuf; - processPackets(); + let flatFields = Array.isArray(fields?.[0]) + ? ((fields as FieldPacket[][])[0] ?? []) + : ((fields as FieldPacket[] | undefined) ?? []); + + let normalizedRows = normalizeRows(rows); + let columns = flatFields.map(field => { + let typeId = fieldTypeId(field); + return { + name: field.name, + type: mysqlTypeNames[typeId] ?? `type_${typeId}`, + typeId }; - - activeSocket.on('data', onData); - activeSocket.on('error', onError); - - // Send COM_QUERY - let queryPayload = buildComQuery(sql); - activeSocket.write(buildPacket(0, queryPayload)); }); - } - - private terminate(socket: net.Socket): Promise { - let activeSocket: net.Socket = (socket as any).__tlsSocket || socket; - return new Promise(resolve => { - try { - let quitPayload = buildComQuit(); - activeSocket.write(buildPacket(0, quitPayload)); - } catch { - // Ignore write errors during termination - } - activeSocket.end(); - activeSocket.once('close', () => resolve()); - setTimeout(() => { - activeSocket.destroy(); - socket.destroy(); - resolve(); - }, 1000); - }); + return { + columns, + rows: normalizedRows, + affectedRows: normalizedRows.length, + lastInsertId: 0, + command + }; } } - -// Helper to read null-terminated string from payload -let readNullTerminatedStringFromPayload = ( - payload: Uint8Array, - offset: number -): { value: string; nextOffset: number } => { - let decoder = new TextDecoder(); - let end = payload.indexOf(0, offset); - if (end === -1) end = payload.length; - let value = decoder.decode(payload.slice(offset, end)); - return { value, nextOffset: end + 1 }; -}; - -// SHA-1 helpers -let sha1 = (data: string): Uint8Array => { - let encoder = new TextEncoder(); - return new Uint8Array(crypto.createHash('sha1').update(encoder.encode(data)).digest()); -}; - -let sha1Bytes = (data: Uint8Array): Uint8Array => { - return new Uint8Array(crypto.createHash('sha1').update(data).digest()); -}; - -// SHA-256 helpers -let sha256 = (data: string): Uint8Array => { - let encoder = new TextEncoder(); - return new Uint8Array(crypto.createHash('sha256').update(encoder.encode(data)).digest()); -}; - -let sha256Bytes = (data: Uint8Array): Uint8Array => { - return new Uint8Array(crypto.createHash('sha256').update(data).digest()); -}; - -// Guess the SQL command from the query string -let guessCommand = (sql: string): string => { - let trimmed = sql.trim().toUpperCase(); - let firstWord = trimmed.split(/\s+/)[0] || ''; - return firstWord; -}; - -// Cast MySQL text protocol values to appropriate JS types -let castValue = (value: string | null, typeId: number): any => { - if (value === null) return null; - - switch (typeId) { - case 0x01: // TINY - case 0x02: // SHORT - case 0x03: // LONG - case 0x08: // LONGLONG - case 0x09: // INT24 - case 0x0d: // YEAR - return Number.parseInt(value, 10); - case 0x04: // FLOAT - case 0x05: // DOUBLE - case 0x00: // DECIMAL - case 0xf6: // NEWDECIMAL - return Number.parseFloat(value); - case 0xf5: // JSON - try { - return JSON.parse(value); - } catch { - return value; - } - default: - return value; - } -}; diff --git a/integrations/mysql/src/lib/errors.ts b/integrations/mysql/src/lib/errors.ts new file mode 100644 index 0000000000..92165244d3 --- /dev/null +++ b/integrations/mysql/src/lib/errors.ts @@ -0,0 +1,60 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorLike = { + code?: unknown; + errno?: unknown; + sqlState?: unknown; + sqlMessage?: unknown; + message?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let extractMessage = (error: unknown) => { + if (isRecord(error)) { + let candidate = error as ErrorLike; + for (let value of [candidate.sqlMessage, candidate.message]) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let mysqlServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let mysqlApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let source = isRecord(error) ? (error as ErrorLike) : {}; + let code = typeof source.code === 'string' ? source.code : undefined; + let errno = typeof source.errno === 'number' ? source.errno : undefined; + let sqlState = typeof source.sqlState === 'string' ? source.sqlState : undefined; + let labels = [code, errno !== undefined ? `errno ${errno}` : undefined, sqlState] + .filter(Boolean) + .join(', '); + let suffix = labels ? ` (${labels})` : ''; + + let serviceError = mysqlServiceError( + `MySQL ${operation} failed${suffix}: ${extractMessage(error)}` + ); + + serviceError.data.reason = 'mysql_error'; + serviceError.data.upstreamCode = code; + serviceError.data.upstreamErrno = errno; + serviceError.data.upstreamSqlState = sqlState; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/mysql/src/lib/helpers.ts b/integrations/mysql/src/lib/helpers.ts index 1c5c68905b..ea5dee4e60 100644 --- a/integrations/mysql/src/lib/helpers.ts +++ b/integrations/mysql/src/lib/helpers.ts @@ -1,4 +1,6 @@ +import * as mysql from 'mysql2'; import { type ConnectionConfig, MySQLClient } from './client'; +import { mysqlServiceError } from './errors'; export interface AuthOutput { host: string; @@ -7,6 +9,8 @@ export interface AuthOutput { username: string; password: string; sslMode: 'disabled' | 'preferred' | 'required'; + sslCa?: string; + sslRejectUnauthorized?: boolean; connectionString: string; } @@ -24,19 +28,19 @@ export let createClient = (auth: AuthOutput, config: ConfigOutput): MySQLClient username: auth.username, password: auth.password, sslMode: auth.sslMode, + sslCa: auth.sslCa, + sslRejectUnauthorized: auth.sslRejectUnauthorized ?? true, queryTimeout: config.queryTimeout }; return new MySQLClient(connectionConfig); }; -// Escape an identifier (table name, column name, etc.) for safe use in MySQL SQL export let escapeIdentifier = (name: string): string => { - return `\`${name.replace(/`/g, '``')}\``; + return mysql.escapeId(name); }; -// Escape a literal value for safe use in MySQL SQL export let escapeLiteral = (value: string): string => { - return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\0/g, '\\0')}'`; + return mysql.escape(value); }; // Build a qualified table name with optional database prefix @@ -50,8 +54,18 @@ export let qualifiedTableName = (tableName: string, databaseName?: string): stri // Format a value for SQL insertion export let formatValue = (val: any): string => { if (val === null || val === undefined) return 'NULL'; - if (typeof val === 'number') return String(val); - if (typeof val === 'boolean') return val ? '1' : '0'; - if (typeof val === 'object') return escapeLiteral(JSON.stringify(val)); - return escapeLiteral(String(val)); + if (typeof val === 'object' && !(val instanceof Date) && !Buffer.isBuffer(val)) { + return mysql.escape(JSON.stringify(val)); + } + return mysql.escape(val); +}; + +export let validateSqlOptionName = (value: string, label: string) => { + let trimmed = value.trim(); + if (!/^[A-Za-z0-9_$]+$/.test(trimmed)) { + throw mysqlServiceError( + `${label} may contain only letters, numbers, underscores, or dollar signs.` + ); + } + return trimmed; }; diff --git a/integrations/mysql/src/tools.schema.test.ts b/integrations/mysql/src/tools.schema.test.ts new file mode 100644 index 0000000000..9dad87fe17 --- /dev/null +++ b/integrations/mysql/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('MySQL tool input schemas', provider.actions); diff --git a/integrations/mysql/src/tools/delete-rows.ts b/integrations/mysql/src/tools/delete-rows.ts index 6b2d930ce1..331261deb7 100644 --- a/integrations/mysql/src/tools/delete-rows.ts +++ b/integrations/mysql/src/tools/delete-rows.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { mysqlServiceError } from '../lib/errors'; import { createClient, qualifiedTableName } from '../lib/helpers'; import { spec } from '../spec'; @@ -31,7 +32,12 @@ Requires a WHERE clause to target specific rows unless confirmDeleteAll is expli .optional() .default(false) .describe('Set to true to confirm deleting all rows when no WHERE clause is provided'), - limit: z.number().optional().describe('Maximum number of rows to delete') + limit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of rows to delete') }) ) .output( @@ -45,7 +51,7 @@ Requires a WHERE clause to target specific rows unless confirmDeleteAll is expli let fullTableName = qualifiedTableName(ctx.input.tableName, db); if (!ctx.input.where && !ctx.input.confirmDeleteAll) { - throw new Error( + throw mysqlServiceError( 'A WHERE clause is required to delete rows. Set confirmDeleteAll to true to delete all rows.' ); } diff --git a/integrations/mysql/src/tools/execute-query.ts b/integrations/mysql/src/tools/execute-query.ts index 5e35b6ce71..0cb6ad5db8 100644 --- a/integrations/mysql/src/tools/execute-query.ts +++ b/integrations/mysql/src/tools/execute-query.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { mysqlServiceError } from '../lib/errors'; import { createClient } from '../lib/helpers'; import { spec } from '../spec'; @@ -28,6 +29,8 @@ Supports complex queries with joins, subqueries, CTEs, window functions, and agg sql: z.string().describe('The SQL query to execute'), maxRows: z .number() + .int() + .nonnegative() .optional() .describe( 'Maximum number of rows to return. Overrides the default maxRows config. Only applicable for SELECT queries.' @@ -61,6 +64,10 @@ Supports complex queries with joins, subqueries, CTEs, window functions, and agg let sql = ctx.input.sql.trim(); let maxRows = ctx.input.maxRows ?? ctx.config.maxRows; + if (sql.length === 0) { + throw mysqlServiceError('sql must be a non-empty SQL statement.'); + } + let isSelect = /^\s*(SELECT|WITH)\b/i.test(sql); let hasLimit = /\bLIMIT\s+\d+/i.test(sql); diff --git a/integrations/mysql/src/tools/index.ts b/integrations/mysql/src/tools/index.ts index 98cd5ca3d2..974d0c32db 100644 --- a/integrations/mysql/src/tools/index.ts +++ b/integrations/mysql/src/tools/index.ts @@ -4,6 +4,7 @@ export { executeQuery } from './execute-query'; export { insertRows } from './insert-rows'; export { listDatabases } from './list-databases'; export { listTables } from './list-tables'; +export { manageDatabase } from './manage-database'; export { manageIndexes } from './manage-indexes'; export { manageTable } from './manage-table'; export { manageUsers } from './manage-users'; diff --git a/integrations/mysql/src/tools/insert-rows.ts b/integrations/mysql/src/tools/insert-rows.ts index 1c61ac88ef..fa59f931de 100644 --- a/integrations/mysql/src/tools/insert-rows.ts +++ b/integrations/mysql/src/tools/insert-rows.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { mysqlServiceError } from '../lib/errors'; import { createClient, escapeIdentifier, @@ -65,6 +66,10 @@ Supports inserting multiple rows in a single operation and handling duplicate ke } let columns = Array.from(columnSet); + if (columns.length === 0) { + throw mysqlServiceError('At least one column is required in each inserted row.'); + } + // Build values list let valueClauses: string[] = []; for (let row of ctx.input.rows) { @@ -81,9 +86,12 @@ Supports inserting multiple rows in a single operation and handling duplicate ke let setClauses = updateCols.map( c => `${escapeIdentifier(c)} = VALUES(${escapeIdentifier(c)})` ); - if (setClauses.length > 0) { - sql += ` ON DUPLICATE KEY UPDATE ${setClauses.join(', ')}`; + if (setClauses.length === 0) { + throw mysqlServiceError( + 'updateColumns must include at least one column when onDuplicateKey is "update".' + ); } + sql += ` ON DUPLICATE KEY UPDATE ${setClauses.join(', ')}`; } ctx.info(`Inserting ${ctx.input.rows.length} row(s) into ${fullTableName}`); diff --git a/integrations/mysql/src/tools/manage-database.ts b/integrations/mysql/src/tools/manage-database.ts new file mode 100644 index 0000000000..5f13519081 --- /dev/null +++ b/integrations/mysql/src/tools/manage-database.ts @@ -0,0 +1,95 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { mysqlServiceError } from '../lib/errors'; +import { createClient, escapeIdentifier, validateSqlOptionName } from '../lib/helpers'; +import { spec } from '../spec'; + +export let manageDatabase = SlateTool.create(spec, { + name: 'Manage Database', + key: 'manage_database', + description: `Create or drop MySQL databases. Supports IF NOT EXISTS for creation, IF EXISTS for drops, and optional default character set and collation settings.`, + instructions: [ + 'Use create to provision a database before creating tables.', + 'Use drop with confirmDrop set to true only when the database and all contained objects should be permanently removed.' + ], + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z.enum(['create', 'drop']).describe('Action to perform on the database'), + databaseName: z.string().describe('Name of the database'), + ifNotExists: z + .boolean() + .optional() + .default(false) + .describe('Add IF NOT EXISTS for create action'), + ifExists: z + .boolean() + .optional() + .default(false) + .describe('Add IF EXISTS for drop action'), + charset: z + .string() + .optional() + .describe('Default character set for create action, such as utf8mb4'), + collation: z + .string() + .optional() + .describe('Default collation for create action, such as utf8mb4_0900_ai_ci'), + confirmDrop: z + .boolean() + .optional() + .default(false) + .describe('Required confirmation for drop action') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the operation completed successfully'), + action: z.enum(['create', 'drop']).describe('Action that was performed'), + databaseName: z.string().describe('Database name'), + executedSql: z.string().describe('The SQL statement that was executed') + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx.auth, ctx.config); + let sql: string; + + if (ctx.input.action === 'create') { + let ifNotExists = ctx.input.ifNotExists ? 'IF NOT EXISTS ' : ''; + sql = `CREATE DATABASE ${ifNotExists}${escapeIdentifier(ctx.input.databaseName)}`; + + if (ctx.input.charset) { + sql += ` DEFAULT CHARACTER SET ${validateSqlOptionName(ctx.input.charset, 'charset')}`; + } + + if (ctx.input.collation) { + sql += ` COLLATE ${validateSqlOptionName(ctx.input.collation, 'collation')}`; + } + } else { + if (!ctx.input.confirmDrop) { + throw mysqlServiceError('confirmDrop must be true to drop a database.'); + } + + let ifExists = ctx.input.ifExists ? 'IF EXISTS ' : ''; + sql = `DROP DATABASE ${ifExists}${escapeIdentifier(ctx.input.databaseName)}`; + } + + ctx.info(`Executing: ${sql}`); + await client.query(sql, ctx.config.queryTimeout); + + let actionLabel = ctx.input.action === 'create' ? 'Created' : 'Dropped'; + + return { + output: { + success: true, + action: ctx.input.action, + databaseName: ctx.input.databaseName, + executedSql: sql + }, + message: `${actionLabel} database \`${ctx.input.databaseName}\`.` + }; + }) + .build(); diff --git a/integrations/mysql/src/tools/manage-indexes.ts b/integrations/mysql/src/tools/manage-indexes.ts index dc97547dcb..70ea05c3d4 100644 --- a/integrations/mysql/src/tools/manage-indexes.ts +++ b/integrations/mysql/src/tools/manage-indexes.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { mysqlServiceError } from '../lib/errors'; import { createClient, escapeIdentifier, qualifiedTableName } from '../lib/helpers'; import { spec } from '../spec'; @@ -28,7 +29,12 @@ Useful for optimizing query performance by adding appropriate indexes.`, .array( z.object({ columnName: z.string().describe('Column name to include in the index'), - length: z.number().optional().describe('Prefix length for string columns'), + length: z + .number() + .int() + .positive() + .optional() + .describe('Prefix length for string columns'), order: z.enum(['ASC', 'DESC']).optional().describe('Sort order for the column') }) ) @@ -57,7 +63,7 @@ Useful for optimizing query performance by adding appropriate indexes.`, if (ctx.input.action === 'create') { if (!ctx.input.columns || ctx.input.columns.length === 0) { - throw new Error('Columns are required for creating an index'); + throw mysqlServiceError('Columns are required for creating an index'); } let columnList = ctx.input.columns diff --git a/integrations/mysql/src/tools/manage-table.ts b/integrations/mysql/src/tools/manage-table.ts index 06f648eaa5..14dd345a23 100644 --- a/integrations/mysql/src/tools/manage-table.ts +++ b/integrations/mysql/src/tools/manage-table.ts @@ -1,6 +1,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { createClient, escapeIdentifier, qualifiedTableName } from '../lib/helpers'; +import { mysqlServiceError } from '../lib/errors'; +import { + createClient, + escapeIdentifier, + qualifiedTableName, + validateSqlOptionName +} from '../lib/helpers'; import { spec } from '../spec'; let columnDefinitionSchema = z.object({ @@ -138,7 +144,7 @@ For altering tables, supports adding columns, dropping columns, renaming columns if (ctx.input.action === 'create') { if (!ctx.input.columns || ctx.input.columns.length === 0) { - throw new Error('Column definitions are required for create action'); + throw mysqlServiceError('Column definitions are required for create action'); } let columnDefs: string[] = []; @@ -168,9 +174,15 @@ For altering tables, supports adding columns, dropping columns, renaming columns let ifNotExists = ctx.input.ifNotExists ? 'IF NOT EXISTS ' : ''; let sql = `CREATE TABLE ${ifNotExists}${fullTableName} (\n ${columnDefs.join(',\n ')}\n)`; - if (ctx.input.engine) sql += ` ENGINE=${ctx.input.engine}`; - if (ctx.input.charset) sql += ` DEFAULT CHARSET=${ctx.input.charset}`; - if (ctx.input.collation) sql += ` COLLATE=${ctx.input.collation}`; + if (ctx.input.engine) { + sql += ` ENGINE=${validateSqlOptionName(ctx.input.engine, 'engine')}`; + } + if (ctx.input.charset) { + sql += ` DEFAULT CHARSET=${validateSqlOptionName(ctx.input.charset, 'charset')}`; + } + if (ctx.input.collation) { + sql += ` COLLATE=${validateSqlOptionName(ctx.input.collation, 'collation')}`; + } statements.push(sql); } else if (ctx.input.action === 'alter') { @@ -201,7 +213,6 @@ For altering tables, supports adding columns, dropping columns, renaming columns if (ctx.input.modifyColumns) { for (let modify of ctx.input.modifyColumns) { - let _parts: string[] = []; if (modify.newDataType) { let def = `${escapeIdentifier(modify.columnName)} ${modify.newDataType}`; if (modify.nullable === false) def += ' NOT NULL'; @@ -231,7 +242,7 @@ For altering tables, supports adding columns, dropping columns, renaming columns } if (statements.length === 0) { - throw new Error( + throw mysqlServiceError( 'No alter operations specified. Provide addColumns, dropColumns, renameColumn, modifyColumns, or renameTable.' ); } diff --git a/integrations/mysql/src/tools/manage-users.ts b/integrations/mysql/src/tools/manage-users.ts index a572bdc20d..e988427c6f 100644 --- a/integrations/mysql/src/tools/manage-users.ts +++ b/integrations/mysql/src/tools/manage-users.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { mysqlServiceError } from '../lib/errors'; import { createClient, escapeLiteral } from '../lib/helpers'; import { spec } from '../spec'; @@ -101,14 +102,14 @@ Can also list existing users and their grants.`, } if (!ctx.input.userName) { - throw new Error('userName is required for this action'); + throw mysqlServiceError('userName is required for this action'); } let userSpec = `${escapeLiteral(ctx.input.userName)}@${escapeLiteral(ctx.input.host || '%')}`; if (ctx.input.action === 'create') { if (!ctx.input.password) { - throw new Error('password is required when creating a user'); + throw mysqlServiceError('password is required when creating a user'); } statements.push( `CREATE USER ${userSpec} IDENTIFIED BY ${escapeLiteral(ctx.input.password)}` @@ -118,7 +119,7 @@ Can also list existing users and their grants.`, statements.push(`DROP USER ${ifExists}${userSpec}`); } else if (ctx.input.action === 'grant') { if (!ctx.input.privileges || ctx.input.privileges.length === 0) { - throw new Error('privileges are required for grant action'); + throw mysqlServiceError('privileges are required for grant action'); } let target = ctx.input.grantTarget || '*.*'; let privs = ctx.input.privileges.join(', '); @@ -129,7 +130,7 @@ Can also list existing users and their grants.`, statements.push(sql); } else if (ctx.input.action === 'revoke') { if (!ctx.input.privileges || ctx.input.privileges.length === 0) { - throw new Error('privileges are required for revoke action'); + throw mysqlServiceError('privileges are required for revoke action'); } let target = ctx.input.grantTarget || '*.*'; let privs = ctx.input.privileges.join(', '); diff --git a/integrations/mysql/src/tools/update-rows.ts b/integrations/mysql/src/tools/update-rows.ts index 307a7ac362..e296a6e50e 100644 --- a/integrations/mysql/src/tools/update-rows.ts +++ b/integrations/mysql/src/tools/update-rows.ts @@ -1,5 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; +import { mysqlServiceError } from '../lib/errors'; import { createClient, escapeIdentifier, @@ -52,7 +53,7 @@ Requires a WHERE clause to target specific rows unless confirmUpdateAll is expli let fullTableName = qualifiedTableName(ctx.input.tableName, db); if (!ctx.input.where && !ctx.input.confirmUpdateAll) { - throw new Error( + throw mysqlServiceError( 'A WHERE clause is required to update rows. Set confirmUpdateAll to true to update all rows.' ); } @@ -62,7 +63,7 @@ Requires a WHERE clause to target specific rows unless confirmUpdateAll is expli ); if (setClauses.length === 0) { - throw new Error('No values provided to update.'); + throw mysqlServiceError('No values provided to update.'); } let sql = `UPDATE ${fullTableName} SET ${setClauses.join(', ')}`; diff --git a/integrations/mysql/vitest.config.ts b/integrations/mysql/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/mysql/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/nano-nets/package.json b/integrations/nano-nets/package.json index 4f72b132ad..0b65d199f2 100644 --- a/integrations/nano-nets/package.json +++ b/integrations/nano-nets/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/nano-nets/src/index.ts b/integrations/nano-nets/src/index.ts index c4a0cb3b5e..19ee2dcd0a 100644 --- a/integrations/nano-nets/src/index.ts +++ b/integrations/nano-nets/src/index.ts @@ -1,8 +1,10 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { + assignFiles, classifyImage, createModel, + deleteFiles, detectObjects, extractDocumentData, extractFullText, @@ -12,6 +14,7 @@ import { retryFileProcessing, reviewFile, trainModel, + updateFileFields, uploadTrainingData } from './tools'; import { documentProcessed } from './triggers'; @@ -25,6 +28,9 @@ export let provider = Slate.create({ getPredictionResults, listProcessedFiles, reviewFile, + assignFiles, + updateFileFields, + deleteFiles, trainModel, uploadTrainingData, classifyImage, diff --git a/integrations/nano-nets/src/lib/client.ts b/integrations/nano-nets/src/lib/client.ts index 584f5680b9..8546c77181 100644 --- a/integrations/nano-nets/src/lib/client.ts +++ b/integrations/nano-nets/src/lib/client.ts @@ -1,4 +1,22 @@ import { createAxios } from 'slates'; +import { nanonetsApiError } from './errors'; + +type PredictByUrlOptions = { + asyncMode?: boolean; + language?: string; + requestMetadata?: string; + pagesToProcess?: string; +}; + +let appendFormValues = (form: URLSearchParams, key: string, values: string[] | undefined) => { + for (let value of values ?? []) { + form.append(key, value); + } +}; + +let formHeaders = { + 'Content-Type': 'application/x-www-form-urlencoded' +}; export class NanonetsClient { private axiosV2: ReturnType; @@ -14,21 +32,33 @@ export class NanonetsClient { }); } + private async request(operation: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw nanonetsApiError(error, operation); + } + } + // ─────────────────────────────────────────────── // OCR Model (Workflow) Management // ─────────────────────────────────────────────── async createOcrModel(categories: string[]): Promise { - let response = await this.axiosV2.post('/OCR/Model/', { - categories, - model_type: 'ocr' + return this.request('create OCR model', async () => { + let response = await this.axiosV2.post('/OCR/Model/', { + categories, + model_type: 'ocr' + }); + return response.data; }); - return response.data; } async getOcrModel(modelId: string): Promise { - let response = await this.axiosV2.get(`/OCR/Model/${modelId}`); - return response.data; + return this.request('get OCR model', async () => { + let response = await this.axiosV2.get(`/OCR/Model/${modelId}`); + return response.data; + }); } // ─────────────────────────────────────────────── @@ -38,14 +68,32 @@ export class NanonetsClient { async predictByUrl( modelId: string, urls: string[], - asyncMode: boolean = false + options: PredictByUrlOptions = {} ): Promise { - let url = `/OCR/Model/${modelId}/LabelUrls/`; - if (asyncMode) { - url += '?async=true'; - } - let response = await this.axiosV2.post(url, { urls }); - return response.data; + return this.request('predict OCR by URL', async () => { + let form = new URLSearchParams(); + appendFormValues(form, 'urls', urls); + + if (options.requestMetadata) { + form.append('request_metadata', options.requestMetadata); + } + if (options.pagesToProcess) { + form.append('pages_to_process', options.pagesToProcess); + } + + let response = await this.axiosV2.post( + `/OCR/Model/${modelId}/LabelUrls/`, + form.toString(), + { + headers: formHeaders, + params: { + async: options.asyncMode ? true : undefined, + l: options.language + } + } + ); + return response.data; + }); } // ─────────────────────────────────────────────── @@ -53,17 +101,21 @@ export class NanonetsClient { // ─────────────────────────────────────────────── async getPredictionByFileId(modelId: string, requestFileId: string): Promise { - let response = await this.axiosV2.get( - `/Inferences/Model/${modelId}/InferenceRequestFiles/GetPredictions/${requestFileId}` - ); - return response.data; + return this.request('get prediction by file ID', async () => { + let response = await this.axiosV2.get( + `/Inferences/Model/${modelId}/InferenceRequestFiles/GetPredictions/${requestFileId}` + ); + return response.data; + }); } async getPredictionsByPage(modelId: string, pageId: string): Promise { - let response = await this.axiosV2.get( - `/Inferences/Model/${modelId}/ImageLevelInferences/${pageId}` - ); - return response.data; + return this.request('get prediction by page ID', async () => { + let response = await this.axiosV2.get( + `/Inferences/Model/${modelId}/ImageLevelInferences/${pageId}` + ); + return response.data; + }); } async getAllPredictions( @@ -71,16 +123,18 @@ export class NanonetsClient { startDayInterval: number, currentBatchDay: number ): Promise { - let response = await this.axiosV2.get( - `/Inferences/Model/${modelId}/ImageLevelInferences`, - { - params: { - start_day_interval: startDayInterval, - current_batch_day: currentBatchDay + return this.request('list prediction files', async () => { + let response = await this.axiosV2.get( + `/Inferences/Model/${modelId}/ImageLevelInferences`, + { + params: { + start_day_interval: startDayInterval, + current_batch_day: currentBatchDay + } } - } - ); - return response.data; + ); + return response.data; + }); } // ─────────────────────────────────────────────── @@ -88,12 +142,14 @@ export class NanonetsClient { // ─────────────────────────────────────────────── async fullTextOcrByUrl(urls: string[]): Promise { - let formData = new FormData(); - for (let url of urls) { - formData.append('urls', url); - } - let response = await this.axiosV2.post('/OCR/FullText', formData); - return response.data; + return this.request('extract full text by URL', async () => { + let formData = new FormData(); + for (let url of urls) { + formData.append('urls', url); + } + let response = await this.axiosV2.post('/OCR/FullText', formData); + return response.data; + }); } // ─────────────────────────────────────────────── @@ -105,17 +161,21 @@ export class NanonetsClient { urls: string[], annotations?: string ): Promise { - let body: Record = { urls }; - if (annotations) { - body.data = annotations; - } - let response = await this.axiosV2.post(`/OCR/Model/${modelId}/UploadUrls/`, body); - return response.data; + return this.request('upload OCR training URLs', async () => { + let body: Record = { urls }; + if (annotations) { + body.data = annotations; + } + let response = await this.axiosV2.post(`/OCR/Model/${modelId}/UploadUrls/`, body); + return response.data; + }); } async trainModel(modelId: string): Promise { - let response = await this.axiosV2.post(`/OCR/Model/${modelId}/Train/`); - return response.data; + return this.request('train OCR model', async () => { + let response = await this.axiosV2.post(`/OCR/Model/${modelId}/Train/`); + return response.data; + }); } // ─────────────────────────────────────────────── @@ -123,33 +183,81 @@ export class NanonetsClient { // ─────────────────────────────────────────────── async approveFile(modelId: string, requestFileId: string): Promise { - let response = await this.axiosV2.post( - `/Inferences/Model/${modelId}/ImageLevelInferences/Verify/${requestFileId}` - ); - return response.data; + return this.request('approve file', async () => { + let response = await this.axiosV2.post( + `/Inferences/Model/${modelId}/ImageLevelInferences/Verify/${requestFileId}` + ); + return response.data; + }); } async unapproveFile(modelId: string, requestFileId: string): Promise { - let response = await this.axiosV2.post( - `/Inferences/Model/${modelId}/ImageLevelInferences/UnVerify/${requestFileId}` - ); - return response.data; + return this.request('unapprove file', async () => { + let response = await this.axiosV2.post( + `/Inferences/Model/${modelId}/ImageLevelInferences/UnVerify/${requestFileId}` + ); + return response.data; + }); + } + + async assignFiles(modelId: string, fileIds: string[], memberEmail: string): Promise { + return this.request('assign files', async () => { + let response = await this.axiosV2.post(`/team/members/model/${modelId}/assign/files`, { + member: memberEmail, + file_ids: fileIds + }); + return response.data; + }); + } + + async updateFileFields( + modelId: string, + moderatedBoxes: Record[], + useUiVersion = true + ): Promise { + return this.request('update file fields', async () => { + let response = await this.axiosV2.patch( + `/Inferences/Model/${modelId}/ImageLevelInference`, + { + moderated_boxes: moderatedBoxes + }, + { + params: { + use_ui_version: useUiVersion + } + } + ); + return response.data; + }); + } + + async deleteFile(modelId: string, fileId: string): Promise { + return this.request('delete file', async () => { + let response = await this.axiosV2.delete( + `/Inferences/Model/${modelId}/InferenceRequestFiles/${fileId}` + ); + return response.data; + }); } async retryExports(modelId: string, fileIds: string[]): Promise { - let response = await this.axiosV2.post( - `/Inferences/Model/${modelId}/ImageLevelInferences/retryallexports`, - { file_ids: fileIds } - ); - return response.data; + return this.request('retry exports', async () => { + let response = await this.axiosV2.post( + `/Inferences/Model/${modelId}/ImageLevelInferences/retryallexports`, + { file_ids: fileIds } + ); + return response.data; + }); } async retryPrediction(modelId: string, fileIds: string[]): Promise { - let response = await this.axiosV2.post( - `/ObjectDetection/Model/${modelId}/RetryPrediction`, - { file_ids: fileIds } - ); - return response.data; + return this.request('retry prediction', async () => { + let response = await this.axiosV2.post( + `/ObjectDetection/Model/${modelId}/RetryPrediction`, + { file_ids: fileIds } + ); + return response.data; + }); } // ─────────────────────────────────────────────── @@ -157,36 +265,49 @@ export class NanonetsClient { // ─────────────────────────────────────────────── async createClassificationModel(categories: string[]): Promise { - let formData = new FormData(); - for (let category of categories) { - formData.append('categories', category); - } - let response = await this.axiosV2.post('/ImageCategorization/Model/', formData); - return response.data; + return this.request('create classification model', async () => { + let formData = new FormData(); + for (let category of categories) { + formData.append('categories', category); + } + let response = await this.axiosV2.post('/ImageCategorization/Model/', formData); + return response.data; + }); } async getClassificationModel(modelId: string): Promise { - let response = await this.axiosV2.get('/ImageCategorization/Model/', { - params: { modelId } + return this.request('get classification model', async () => { + let response = await this.axiosV2.get('/ImageCategorization/Model/', { + params: { modelId } + }); + return response.data; }); - return response.data; } async classifyByUrl(modelId: string, urls: string[]): Promise { - let formData = new FormData(); - formData.append('modelId', modelId); - for (let url of urls) { - formData.append('urls', url); - } - let response = await this.axiosV2.post('/ImageCategorization/LabelUrls', formData); - return response.data; + return this.request('classify images by URL', async () => { + let form = new URLSearchParams(); + form.append('modelId', modelId); + appendFormValues(form, 'urls', urls); + + let response = await this.axiosV2.post( + '/ImageCategorization/LabelUrls/', + form.toString(), + { + headers: formHeaders + } + ); + return response.data; + }); } async trainClassificationModel(modelId: string): Promise { - let response = await this.axiosV2.post('/ImageCategorization/Train/', null, { - params: { modelId } + return this.request('train classification model', async () => { + let response = await this.axiosV2.post('/ImageCategorization/Train/', null, { + params: { modelId } + }); + return response.data; }); - return response.data; } async uploadClassificationTrainingUrls( @@ -194,14 +315,16 @@ export class NanonetsClient { category: string, urls: string[] ): Promise { - let formData = new FormData(); - formData.append('modelId', modelId); - formData.append('category', category); - for (let url of urls) { - formData.append('urls', url); - } - let response = await this.axiosV2.post('/ImageCategorization/UploadUrls', formData); - return response.data; + return this.request('upload classification training URLs', async () => { + let formData = new FormData(); + formData.append('modelId', modelId); + formData.append('category', category); + for (let url of urls) { + formData.append('urls', url); + } + let response = await this.axiosV2.post('/ImageCategorization/UploadUrls', formData); + return response.data; + }); } // ─────────────────────────────────────────────── @@ -209,28 +332,34 @@ export class NanonetsClient { // ─────────────────────────────────────────────── async createObjectDetectionModel(categories: string[]): Promise { - let response = await this.axiosV2.post('/ObjectDetection/Model/', { - categories + return this.request('create object detection model', async () => { + let response = await this.axiosV2.post('/ObjectDetection/Model/', { + categories + }); + return response.data; }); - return response.data; } async detectObjectsByUrl(modelId: string, urls: string[]): Promise { - let formData = new FormData(); - formData.append('modelId', modelId); - for (let url of urls) { - formData.append('urls', url); - } - let response = await this.axiosV2.post( - `/ObjectDetection/Model/${modelId}/LabelUrls/`, - formData - ); - return response.data; + return this.request('detect objects by URL', async () => { + let formData = new FormData(); + formData.append('modelId', modelId); + for (let url of urls) { + formData.append('urls', url); + } + let response = await this.axiosV2.post( + `/ObjectDetection/Model/${modelId}/LabelUrls/`, + formData + ); + return response.data; + }); } async trainObjectDetectionModel(modelId: string): Promise { - let response = await this.axiosV2.post(`/ObjectDetection/Model/${modelId}/Train/`); - return response.data; + return this.request('train object detection model', async () => { + let response = await this.axiosV2.post(`/ObjectDetection/Model/${modelId}/Train/`); + return response.data; + }); } async uploadObjectDetectionTrainingUrls( @@ -238,11 +367,13 @@ export class NanonetsClient { urls: string[], annotations: string ): Promise { - let body = { urls, data: annotations }; - let response = await this.axiosV2.post( - `/ObjectDetection/Model/${modelId}/UploadUrls/`, - body - ); - return response.data; + return this.request('upload object detection training URLs', async () => { + let body = { urls, data: annotations }; + let response = await this.axiosV2.post( + `/ObjectDetection/Model/${modelId}/UploadUrls/`, + body + ); + return response.data; + }); } } diff --git a/integrations/nano-nets/src/lib/errors.ts b/integrations/nano-nets/src/lib/errors.ts new file mode 100644 index 0000000000..e8fd80ec85 --- /dev/null +++ b/integrations/nano-nets/src/lib/errors.ts @@ -0,0 +1,95 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) collectDetails(item, details); + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + for (let key of ['message', 'detail', 'error', 'error_description', 'code']) { + addDetail(details, value[key]); + } + + collectDetails(value.errors, details); +}; + +let extractNanonetsMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (isRecord(error)) { + collectDetails(error.data, details); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getNanonetsErrorStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let nanonetsServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let nanonetsApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getNanonetsErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = nanonetsServiceError( + `Nanonets API ${operation} failed: ${statusLabel}${extractNanonetsMessage(error)}` + ); + serviceError.data.reason = 'nanonets_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/nano-nets/src/tools.schema.test.ts b/integrations/nano-nets/src/tools.schema.test.ts new file mode 100644 index 0000000000..f01999d7d2 --- /dev/null +++ b/integrations/nano-nets/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Nanonets tool input schemas', provider.actions); diff --git a/integrations/nano-nets/src/tools/assign-files.ts b/integrations/nano-nets/src/tools/assign-files.ts new file mode 100644 index 0000000000..f1ddfbff0c --- /dev/null +++ b/integrations/nano-nets/src/tools/assign-files.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NanonetsClient } from '../lib/client'; +import { spec } from '../spec'; + +export let assignFiles = SlateTool.create(spec, { + name: 'Assign Files', + key: 'assign_files', + description: + 'Assign one or more processed Nanonets files to a team member for review or approval.', + instructions: [ + 'Use request file IDs returned by extraction or prediction result tools.', + 'The member email must belong to a user on the Nanonets team with access to the model.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + modelId: z.string().describe('ID of the model the files belong to'), + fileIds: z.array(z.string()).min(1).describe('Request file IDs to assign'), + memberEmail: z.string().email().describe('Email address of the Nanonets team member') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the assignment request was accepted'), + modelId: z.string().describe('ID of the model the files belong to'), + fileIds: z.array(z.string()).describe('Request file IDs that were assigned'), + memberEmail: z.string().describe('Email address assigned to the files'), + rawResponse: z.any().optional().describe('Raw Nanonets response') + }) + ) + .handleInvocation(async ctx => { + let client = new NanonetsClient(ctx.auth.token); + + let result = await client.assignFiles( + ctx.input.modelId, + ctx.input.fileIds, + ctx.input.memberEmail + ); + + return { + output: { + success: true, + modelId: ctx.input.modelId, + fileIds: ctx.input.fileIds, + memberEmail: ctx.input.memberEmail, + rawResponse: result + }, + message: `Assigned **${ctx.input.fileIds.length}** file(s) to ${ctx.input.memberEmail}.` + }; + }) + .build(); diff --git a/integrations/nano-nets/src/tools/delete-files.ts b/integrations/nano-nets/src/tools/delete-files.ts new file mode 100644 index 0000000000..dc9584dec6 --- /dev/null +++ b/integrations/nano-nets/src/tools/delete-files.ts @@ -0,0 +1,51 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NanonetsClient } from '../lib/client'; +import { spec } from '../spec'; + +export let deleteFiles = SlateTool.create(spec, { + name: 'Delete Files', + key: 'delete_files', + description: + 'Delete processed files from a Nanonets model. Use this to remove test uploads or files that should no longer be retained.', + instructions: ['Use request file IDs returned by extraction or prediction result tools.'], + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + modelId: z.string().describe('ID of the model the files belong to'), + fileIds: z.array(z.string()).min(1).describe('Request file IDs to delete') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether all delete requests completed'), + modelId: z.string().describe('ID of the model the files belonged to'), + deletedCount: z.number().describe('Number of files deleted'), + fileIds: z.array(z.string()).describe('Request file IDs that were deleted'), + responses: z.array(z.any()).optional().describe('Raw Nanonets delete responses') + }) + ) + .handleInvocation(async ctx => { + let client = new NanonetsClient(ctx.auth.token); + let responses: any[] = []; + + for (let fileId of ctx.input.fileIds) { + responses.push(await client.deleteFile(ctx.input.modelId, fileId)); + } + + return { + output: { + success: true, + modelId: ctx.input.modelId, + deletedCount: ctx.input.fileIds.length, + fileIds: ctx.input.fileIds, + responses + }, + message: `Deleted **${ctx.input.fileIds.length}** file(s) from model \`${ctx.input.modelId}\`.` + }; + }) + .build(); diff --git a/integrations/nano-nets/src/tools/extract-document-data.ts b/integrations/nano-nets/src/tools/extract-document-data.ts index 21f0c601d4..0cc4b12404 100644 --- a/integrations/nano-nets/src/tools/extract-document-data.ts +++ b/integrations/nano-nets/src/tools/extract-document-data.ts @@ -29,7 +29,12 @@ let pageResultSchema = z.object({ .optional() .describe('Request file ID for retrieving results later'), predictions: z.array(predictionSchema).describe('Extracted predictions for this page'), - fileUrl: z.string().optional().describe('URL of the processed file') + fileUrl: z.string().optional().describe('URL of the processed file'), + requestMetadata: z.string().optional().describe('Metadata echoed from the request'), + processingType: z + .string() + .optional() + .describe('Nanonets processing mode reported for the page') }); export let extractDocumentData = SlateTool.create(spec, { @@ -61,6 +66,22 @@ export let extractDocumentData = SlateTool.create(spec, { .default(false) .describe( 'Use async processing for large documents. Results must be retrieved separately.' + ), + language: z + .string() + .optional() + .describe( + 'Optional OCR language code. Omit unless a specific language is required; Nanonets generally recommends no language parameter for best results.' + ), + requestMetadata: z + .string() + .optional() + .describe('Optional metadata string stored with the uploaded document'), + pagesToProcess: z + .string() + .optional() + .describe( + 'Optional comma-separated page numbers to process, such as "1,2". Applies to PDF documents.' ) }) ) @@ -68,23 +89,30 @@ export let extractDocumentData = SlateTool.create(spec, { z.object({ message: z.string().describe('Processing status message'), pages: z.array(pageResultSchema).describe('Extraction results per page'), - isAsync: z.boolean().describe('Whether the request was processed asynchronously') + isAsync: z.boolean().describe('Whether the request was processed asynchronously'), + signedUrls: z + .record(z.string(), z.any()) + .optional() + .describe('Signed file URLs returned by Nanonets, when available') }) ) .handleInvocation(async ctx => { let client = new NanonetsClient(ctx.auth.token); - let result = await client.predictByUrl( - ctx.input.modelId, - ctx.input.urls, - ctx.input.asyncMode - ); + let result = await client.predictByUrl(ctx.input.modelId, ctx.input.urls, { + asyncMode: ctx.input.asyncMode, + language: ctx.input.language, + requestMetadata: ctx.input.requestMetadata, + pagesToProcess: ctx.input.pagesToProcess + }); let pages = (result.result || []).map((page: any) => ({ pageIndex: page.page ?? 0, fileId: page.id, requestFileId: page.request_file_id, fileUrl: page.file_url, + requestMetadata: page.request_metadata, + processingType: page.processing_type, predictions: (page.prediction || []).map((pred: any) => ({ predictionId: pred.id, label: pred.label, @@ -115,7 +143,8 @@ export let extractDocumentData = SlateTool.create(spec, { output: { message: result.message || 'Processing', pages, - isAsync: true + isAsync: true, + signedUrls: result.signed_urls }, message: `Submitted ${ctx.input.urls.length} document(s) for async processing. Request file IDs: ${fileIds.join(', ')}. Use "Get Prediction Results" to retrieve results when ready.` }; @@ -125,7 +154,8 @@ export let extractDocumentData = SlateTool.create(spec, { output: { message: result.message || 'Success', pages, - isAsync: false + isAsync: false, + signedUrls: result.signed_urls }, message: `Extracted **${totalPredictions}** predictions across **${pages.length}** page(s) from ${ctx.input.urls.length} document(s).` }; diff --git a/integrations/nano-nets/src/tools/get-prediction-results.ts b/integrations/nano-nets/src/tools/get-prediction-results.ts index 5a4f7f73df..60c24aea0f 100644 --- a/integrations/nano-nets/src/tools/get-prediction-results.ts +++ b/integrations/nano-nets/src/tools/get-prediction-results.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { NanonetsClient } from '../lib/client'; +import { nanonetsServiceError } from '../lib/errors'; import { spec } from '../spec'; let predictionSchema = z.object({ @@ -63,7 +64,11 @@ export let getPredictionResults = SlateTool.create(spec, { let client = new NanonetsClient(ctx.auth.token); if (!ctx.input.requestFileId && !ctx.input.pageId) { - throw new Error('Either requestFileId or pageId must be provided'); + throw nanonetsServiceError('Either requestFileId or pageId must be provided.'); + } + + if (ctx.input.requestFileId && ctx.input.pageId) { + throw nanonetsServiceError('Provide only one of requestFileId or pageId.'); } let result: any; @@ -80,42 +85,24 @@ export let getPredictionResults = SlateTool.create(spec, { let approvalStatus: string | undefined; let isModerated: boolean | undefined; - if (result?.result && Array.isArray(result.result)) { - for (let page of result.result) { - fileName = fileName || page.input || page.original_file_name; - fileUrl = fileUrl || page.file_url; - approvalStatus = approvalStatus || page.approval_status; - isModerated = isModerated ?? page.is_moderated; + let resultPages = Array.isArray(result?.result) + ? result.result + : [ + ...(result?.moderated_images || []), + ...(result?.unmoderated_images || []), + ...(result ? [result] : []) + ]; - for (let pred of page.prediction || page.predicted_boxes || []) { - predictions.push({ - label: pred.label, - extractedText: pred.ocr_text || '', - confidence: pred.score, - type: pred.type, - page: pred.page, - boundingBox: - pred.xmin != null - ? { - xmin: pred.xmin, - ymin: pred.ymin, - xmax: pred.xmax, - ymax: pred.ymax - } - : undefined - }); - } - } - } else if (result) { - fileName = result.input || result.original_file_name; - fileUrl = result.file_url; - approvalStatus = result.approval_status; - isModerated = result.is_moderated; + for (let page of resultPages) { + fileName = fileName || page.input || page.original_file_name; + fileUrl = fileUrl || page.file_url; + approvalStatus = approvalStatus || page.approval_status; + isModerated = isModerated ?? page.is_moderated; - for (let pred of result.prediction || result.predicted_boxes || []) { + for (let pred of page.prediction || page.predicted_boxes || page.moderated_boxes || []) { predictions.push({ label: pred.label, - extractedText: pred.ocr_text || '', + extractedText: pred.ocr_text || pred.text || '', confidence: pred.score, type: pred.type, page: pred.page, diff --git a/integrations/nano-nets/src/tools/index.ts b/integrations/nano-nets/src/tools/index.ts index 5744bcb7ef..b6f62d53ee 100644 --- a/integrations/nano-nets/src/tools/index.ts +++ b/integrations/nano-nets/src/tools/index.ts @@ -1,5 +1,7 @@ +export * from './assign-files'; export * from './classify-image'; export * from './create-model'; +export * from './delete-files'; export * from './detect-objects'; export * from './extract-document-data'; export * from './extract-full-text'; @@ -9,4 +11,5 @@ export * from './list-processed-files'; export * from './retry-file-processing'; export * from './review-file'; export * from './train-model'; +export * from './update-file-fields'; export * from './upload-training-data'; diff --git a/integrations/nano-nets/src/tools/update-file-fields.ts b/integrations/nano-nets/src/tools/update-file-fields.ts new file mode 100644 index 0000000000..d8627a5ea3 --- /dev/null +++ b/integrations/nano-nets/src/tools/update-file-fields.ts @@ -0,0 +1,63 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NanonetsClient } from '../lib/client'; +import { spec } from '../spec'; + +export let updateFileFields = SlateTool.create(spec, { + name: 'Update File Fields', + key: 'update_file_fields', + description: + 'Update the moderated field data for a processed Nanonets file by submitting a complete moderated_boxes array.', + instructions: [ + 'First retrieve the current page data with Get Prediction Results using a pageId.', + 'Modify the returned boxes and submit the complete moderatedBoxes array. Nanonets treats omitted boxes as deleted.', + 'Set is_new on new boxes when adding fields, following the Nanonets API contract.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + modelId: z.string().describe('ID of the model the file belongs to'), + moderatedBoxes: z + .array(z.record(z.string(), z.any())) + .min(1) + .describe( + 'Complete moderated_boxes payload for the page, including unchanged boxes that should be retained' + ), + useUiVersion: z + .boolean() + .default(true) + .describe('Use the Nanonets UI-compatible update behavior') + }) + ) + .output( + z.object({ + success: z.boolean().describe('Whether the update request was accepted'), + modelId: z.string().describe('ID of the model the file belongs to'), + updatedBoxCount: z.number().describe('Number of boxes submitted'), + rawResponse: z.any().optional().describe('Raw Nanonets response') + }) + ) + .handleInvocation(async ctx => { + let client = new NanonetsClient(ctx.auth.token); + + let result = await client.updateFileFields( + ctx.input.modelId, + ctx.input.moderatedBoxes, + ctx.input.useUiVersion + ); + + return { + output: { + success: true, + modelId: ctx.input.modelId, + updatedBoxCount: ctx.input.moderatedBoxes.length, + rawResponse: result + }, + message: `Updated **${ctx.input.moderatedBoxes.length}** moderated field box(es) in model \`${ctx.input.modelId}\`.` + }; + }) + .build(); diff --git a/integrations/nano-nets/src/tools/upload-training-data.ts b/integrations/nano-nets/src/tools/upload-training-data.ts index 1739ced104..e70bb583e1 100644 --- a/integrations/nano-nets/src/tools/upload-training-data.ts +++ b/integrations/nano-nets/src/tools/upload-training-data.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { NanonetsClient } from '../lib/client'; +import { nanonetsServiceError } from '../lib/errors'; import { spec } from '../spec'; export let uploadTrainingData = SlateTool.create(spec, { @@ -52,7 +53,7 @@ export let uploadTrainingData = SlateTool.create(spec, { if (ctx.input.modelType === 'image_classification') { if (!ctx.input.category) { - throw new Error('Category is required for image classification models'); + throw nanonetsServiceError('Category is required for image classification models.'); } await client.uploadClassificationTrainingUrls( ctx.input.modelId, diff --git a/integrations/nano-nets/vitest.config.ts b/integrations/nano-nets/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/nano-nets/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/neon/README.md b/integrations/neon/README.md index 76df351ae4..34f1442d56 100644 --- a/integrations/neon/README.md +++ b/integrations/neon/README.md @@ -1,64 +1,172 @@ # Neon -Manage serverless PostgreSQL databases on Neon. Create, update, and delete projects, branches, databases, compute endpoints, and roles. Configure autoscaling limits, suspend timeouts, and read replicas. Create branches from any point in time for development, testing, or backups. Compare schemas between branches and create anonymized data copies. Track consumption metrics including compute time, storage, and data transfer, and set quota limits. Manage organization members, permissions, and API keys. Monitor asynchronous operation status and query databases over HTTP via the Data API. +Manage serverless PostgreSQL databases on Neon. Create, update, recover, and delete projects and branches. Manage databases, compute endpoints, roles, connection URIs, supported regions, snapshots, and operation status. Configure autoscaling limits, suspend timeouts, password storage, and branch history retention. Track paid-plan consumption history for eligible accounts. ## Tools -### Create Branch +### List Regions + +Lists supported Neon regions. -Creates a new branch in a Neon project. Branches are copies of the parent branch's data at a specific point in time. Optionally creates a compute endpoint for the branch so it can accept connections. +### List Projects + +Lists Neon projects accessible to the authenticated user, including recoverable deleted projects when requested. + +### Get Project + +Retrieves detailed information about a specific Neon project. ### Create Project -Creates a new Neon project. A project is the top-level organizational unit that contains branches, databases, and compute endpoints. You can specify the region, Postgres version, and default compute settings. +Creates a Neon project with optional region, Postgres version, default branch, database, role, password-storage, and branch-history settings. -### Delete Branch +### Update Project -Deletes a branch from a Neon project. This also deletes all databases, roles, and compute endpoints associated with the branch. +Updates a Neon project's name or branch-history retention. ### Delete Project -Permanently deletes a Neon project and all its branches, databases, endpoints, and roles. The project can be recovered within the deletion grace period using the recover project tool. +Deletes a Neon project and all of its branches, databases, endpoints, and roles. -### Get Consumption +### Recover Project -Retrieves consumption metrics across all projects for the account. Tracks compute time, active time, storage, written data, and data transfer. Available on Neon paid plans. +Recovers a deleted Neon project within the deletion recovery period. -### Get Project +### List Branches -Retrieves detailed information about a specific Neon project, including its configuration, connection URI, consumption metrics, and settings. +Lists branches in a Neon project, with search, sort, pagination, and recoverable-deleted branch support. -### List Branches +### Get Branch -Lists all branches in a Neon project. Branches contain databases and can be created from any point in the project's history retention window. +Retrieves details for a specific Neon branch. -### List Operations +### Create Branch -Lists recent operations for a Neon project. Operations are asynchronous tasks like creating branches, starting compute endpoints, or applying configuration changes. Use this to track the progress and status of background tasks. +Creates a Neon branch, optionally from a parent timestamp or LSN and optionally with a compute endpoint. -### List Projects +### Update Branch + +Updates a Neon branch name, protected flag, or expiration timestamp. + +### Set Default Branch -Lists all Neon projects accessible to the authenticated user. Supports searching by name or ID and filtering by organization. Returns project metadata including region, Postgres version, and timestamps. +Sets a branch as the default branch for a Neon project. + +### Delete Branch + +Deletes a branch from a Neon project. + +### Restore Branch + +Restores a branch to a previous state using a point-in-time timestamp or LSN. + +### Recover Branch + +Recovers a soft-deleted Neon branch within the deletion recovery period when available. ### List Databases -Lists all databases on a specific branch in a Neon project. Each database belongs to a branch and has an owner role. +Lists databases on a specific branch. + +### Get Database + +Retrieves details for a specific database on a branch. + +### Create Database + +Creates a database on a branch. + +### Update Database + +Renames a database or changes its owner role. + +### Delete Database + +Deletes a database from a branch. ### List Endpoints -Lists all compute endpoints in a Neon project. Endpoints are processing instances that connect to branches and provide database connectivity. +Lists compute endpoints in a Neon project. + +### Get Endpoint + +Retrieves details for a specific compute endpoint. + +### Create Endpoint + +Creates a compute endpoint for a branch. + +### Update Endpoint + +Updates compute endpoint name, autoscaling, suspend timeout, disabled, or passwordless access settings. + +### Delete Endpoint + +Deletes a compute endpoint. + +### Control Endpoint + +Starts, suspends, or restarts a compute endpoint. ### List Roles -Lists all database roles on a specific branch. Roles control database access and permissions. They are copied to child branches upon creation. +Lists database roles on a branch. -### Restore Branch +### Get Role -Restores a branch to a previous state using a point-in-time timestamp or LSN. Optionally preserves the current state under a new branch name before restoring. +Retrieves details for a database role. -### Update Project +### Create Role + +Creates a database role on a branch. + +### Delete Role + +Deletes a database role from a branch. + +### Reset Role Password + +Rotates and returns the password for a database role. + +### Reveal Role Password + +Retrieves the stored password for a database role when password storage is enabled. + +### List Operations + +Lists recent operations for a Neon project. + +### Get Operation + +Retrieves the status of a specific Neon operation. + +### Get Connection URI + +Retrieves a PostgreSQL connection URI for a Neon database and role. + +### List Snapshots + +Lists snapshots for a Neon project. + +### Create Snapshot + +Creates a point-in-time snapshot from a Neon branch. + +### Update Snapshot + +Renames a Neon project snapshot. + +### Delete Snapshot + +Deletes a Neon project snapshot. + +### Restore Snapshot + +Restores a Neon snapshot to a branch. + +### Get Consumption -Updates an existing Neon project's settings, including its name and default endpoint configuration. +Retrieves consumption history for eligible paid Neon plans. ## License diff --git a/integrations/neon/package.json b/integrations/neon/package.json index 78b24d7722..f03c5b2f73 100644 --- a/integrations/neon/package.json +++ b/integrations/neon/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/neon/src/auth.ts b/integrations/neon/src/auth.ts index dfc947b273..2b910f16ed 100644 --- a/integrations/neon/src/auth.ts +++ b/integrations/neon/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { neonApiError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -29,11 +30,17 @@ export let auth = SlateAuth.create() baseURL: 'https://console.neon.tech/api/v2' }); - let response = await axios.get('/users/me', { - headers: { - Authorization: `Bearer ${ctx.output.token}` - } - }); + let response: any; + + try { + response = await axios.get('/users/me', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }); + } catch (error) { + throw neonApiError(error, 'profile lookup'); + } return { profile: { diff --git a/integrations/neon/src/index.ts b/integrations/neon/src/index.ts index fd5393e8af..a65407b563 100644 --- a/integrations/neon/src/index.ts +++ b/integrations/neon/src/index.ts @@ -7,57 +7,87 @@ import { createEndpoint, createProject, createRole, + createSnapshot, deleteBranch, deleteDatabase, deleteEndpoint, deleteProject, deleteRole, + deleteSnapshot, + getBranch, + getConnectionUri, getConsumption, + getDatabase, + getEndpoint, getOperation, getProject, + getRole, listBranches, listDatabases, listEndpoints, listOperations, listProjects, + listRegions, listRoles, + listSnapshots, + recoverBranch, + recoverProject, resetRolePassword, restoreBranch, + restoreSnapshot, + revealRolePassword, + setDefaultBranch, + updateBranch, updateDatabase, updateEndpoint, - updateProject + updateProject, + updateSnapshot } from './tools'; -import { inboundWebhook } from './triggers/inbound-webhook'; - export let provider = Slate.create({ spec, tools: [ + listRegions, listProjects, getProject, createProject, updateProject, deleteProject, + recoverProject, listBranches, + getBranch, createBranch, + updateBranch, + setDefaultBranch, deleteBranch, restoreBranch, + recoverBranch, listDatabases, + getDatabase, createDatabase, updateDatabase, deleteDatabase, listEndpoints, + getEndpoint, createEndpoint, updateEndpoint, deleteEndpoint, controlEndpoint, listRoles, + getRole, createRole, deleteRole, resetRolePassword, + revealRolePassword, listOperations, getOperation, + getConnectionUri, + listSnapshots, + createSnapshot, + updateSnapshot, + deleteSnapshot, + restoreSnapshot, getConsumption ], - triggers: [inboundWebhook] + triggers: [] }); diff --git a/integrations/neon/src/lib/client.ts b/integrations/neon/src/lib/client.ts index f4ac147cb3..6152579be5 100644 --- a/integrations/neon/src/lib/client.ts +++ b/integrations/neon/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { neonApiError } from './errors'; export class NeonClient { private axios; @@ -7,6 +8,11 @@ export class NeonClient { this.axios = createAxios({ baseURL: 'https://console.neon.tech/api/v2' }); + + this.axios.interceptors.response.use( + response => response, + error => Promise.reject(neonApiError(error)) + ); } private get headers() { @@ -16,6 +22,16 @@ export class NeonClient { }; } + async listRegions(params?: { orgId?: string }) { + let response = await this.axios.get('/regions', { + headers: this.headers, + params: { + org_id: params?.orgId + } + }); + return response.data; + } + // ─── Projects ────────────────────────────────────────────── async listProjects(params?: { @@ -23,6 +39,7 @@ export class NeonClient { limit?: number; search?: string; orgId?: string; + recoverable?: boolean; }) { let response = await this.axios.get('/projects', { headers: this.headers, @@ -30,7 +47,8 @@ export class NeonClient { cursor: params?.cursor, limit: params?.limit, search: params?.search, - org_id: params?.orgId + org_id: params?.orgId, + recoverable: params?.recoverable } }); return response.data; @@ -48,6 +66,11 @@ export class NeonClient { regionId?: string; pgVersion?: number; orgId?: string; + branchName?: string; + databaseName?: string; + roleName?: string; + storePasswords?: boolean; + historyRetentionSeconds?: number; defaultEndpointSettings?: Record; settings?: Record; }) { @@ -56,6 +79,13 @@ export class NeonClient { name: data.name, region_id: data.regionId, pg_version: data.pgVersion, + branch: { + name: data.branchName, + database_name: data.databaseName, + role_name: data.roleName + }, + store_passwords: data.storePasswords, + history_retention_seconds: data.historyRetentionSeconds, default_endpoint_settings: data.defaultEndpointSettings, settings: data.settings } @@ -73,6 +103,7 @@ export class NeonClient { projectId: string, data: { name?: string; + historyRetentionSeconds?: number; defaultEndpointSettings?: Record; settings?: Record; } @@ -82,6 +113,7 @@ export class NeonClient { { project: { name: data.name, + history_retention_seconds: data.historyRetentionSeconds, default_endpoint_settings: data.defaultEndpointSettings, settings: data.settings } @@ -111,6 +143,29 @@ export class NeonClient { return response.data; } + async getConnectionUri( + projectId: string, + data: { + branchId?: string; + endpointId?: string; + databaseName: string; + roleName: string; + pooled?: boolean; + } + ) { + let response = await this.axios.get(`/projects/${projectId}/connection_uri`, { + headers: this.headers, + params: { + branch_id: data.branchId, + endpoint_id: data.endpointId, + database_name: data.databaseName, + role_name: data.roleName, + pooled: data.pooled + } + }); + return response.data; + } + // ─── Branches ────────────────────────────────────────────── async listBranches( @@ -119,6 +174,9 @@ export class NeonClient { cursor?: string; limit?: number; search?: string; + sortBy?: 'name' | 'created_at' | 'updated_at'; + sortOrder?: 'asc' | 'desc'; + includeDeleted?: boolean; } ) { let response = await this.axios.get(`/projects/${projectId}/branches`, { @@ -126,7 +184,10 @@ export class NeonClient { params: { cursor: params?.cursor, limit: params?.limit, - search: params?.search + search: params?.search, + sort_by: params?.sortBy, + sort_order: params?.sortOrder, + include_deleted: params?.includeDeleted } }); return response.data; @@ -183,13 +244,17 @@ export class NeonClient { branchId: string, data: { name?: string; + protected?: boolean; + expiresAt?: string | null; } ) { let response = await this.axios.patch( `/projects/${projectId}/branches/${branchId}`, { branch: { - name: data.name + name: data.name, + protected: data.protected, + expires_at: data.expiresAt } }, { @@ -199,6 +264,28 @@ export class NeonClient { return response.data; } + async setDefaultBranch(projectId: string, branchId: string) { + let response = await this.axios.post( + `/projects/${projectId}/branches/${branchId}/set_as_default`, + {}, + { + headers: this.headers + } + ); + return response.data; + } + + async recoverBranch(projectId: string, branchId: string) { + let response = await this.axios.post( + `/projects/${projectId}/branches/${branchId}/recover`, + {}, + { + headers: this.headers + } + ); + return response.data; + } + async deleteBranch(projectId: string, branchId: string) { let response = await this.axios.delete(`/projects/${projectId}/branches/${branchId}`, { headers: this.headers @@ -329,9 +416,13 @@ export class NeonClient { data: { branchId: string; type: string; + name?: string; + regionId?: string; autoscalingLimitMinCu?: number; autoscalingLimitMaxCu?: number; suspendTimeoutSeconds?: number; + disabled?: boolean; + passwordlessAccess?: boolean; } ) { let response = await this.axios.post( @@ -340,9 +431,13 @@ export class NeonClient { endpoint: { branch_id: data.branchId, type: data.type, + name: data.name, + region_id: data.regionId, autoscaling_limit_min_cu: data.autoscalingLimitMinCu, autoscaling_limit_max_cu: data.autoscalingLimitMaxCu, - suspend_timeout_seconds: data.suspendTimeoutSeconds + suspend_timeout_seconds: data.suspendTimeoutSeconds, + disabled: data.disabled, + passwordless_access: data.passwordlessAccess } }, { @@ -356,18 +451,24 @@ export class NeonClient { projectId: string, endpointId: string, data: { + name?: string; autoscalingLimitMinCu?: number; autoscalingLimitMaxCu?: number; suspendTimeoutSeconds?: number; + disabled?: boolean; + passwordlessAccess?: boolean; } ) { let response = await this.axios.patch( `/projects/${projectId}/endpoints/${endpointId}`, { endpoint: { + name: data.name, autoscaling_limit_min_cu: data.autoscalingLimitMinCu, autoscaling_limit_max_cu: data.autoscalingLimitMaxCu, - suspend_timeout_seconds: data.suspendTimeoutSeconds + suspend_timeout_seconds: data.suspendTimeoutSeconds, + disabled: data.disabled, + passwordless_access: data.passwordlessAccess } }, { @@ -478,6 +579,16 @@ export class NeonClient { return response.data; } + async revealRolePassword(projectId: string, branchId: string, roleName: string) { + let response = await this.axios.get( + `/projects/${projectId}/branches/${branchId}/roles/${roleName}/reveal_password`, + { + headers: this.headers + } + ); + return response.data; + } + // ─── Operations ──────────────────────────────────────────── async listOperations( @@ -504,6 +615,86 @@ export class NeonClient { return response.data; } + // ─── Snapshots ───────────────────────────────────────────── + + async listSnapshots(projectId: string) { + let response = await this.axios.get(`/projects/${projectId}/snapshots`, { + headers: this.headers + }); + return response.data; + } + + async createSnapshot( + projectId: string, + branchId: string, + params?: { + name?: string; + lsn?: string; + timestamp?: string; + expiresAt?: string; + } + ) { + let response = await this.axios.post( + `/projects/${projectId}/branches/${branchId}/snapshot`, + {}, + { + headers: this.headers, + params: { + name: params?.name, + lsn: params?.lsn, + timestamp: params?.timestamp, + expires_at: params?.expiresAt + } + } + ); + return response.data; + } + + async updateSnapshot(projectId: string, snapshotId: string, data: { name: string }) { + let response = await this.axios.patch( + `/projects/${projectId}/snapshots/${snapshotId}`, + { + snapshot: { + name: data.name + } + }, + { + headers: this.headers + } + ); + return response.data; + } + + async deleteSnapshot(projectId: string, snapshotId: string) { + let response = await this.axios.delete(`/projects/${projectId}/snapshots/${snapshotId}`, { + headers: this.headers + }); + return response.data; + } + + async restoreSnapshot( + projectId: string, + snapshotId: string, + data?: { + name?: string; + targetBranchId?: string; + finalizeRestore?: boolean; + } + ) { + let response = await this.axios.post( + `/projects/${projectId}/snapshots/${snapshotId}/restore`, + { + name: data?.name, + target_branch_id: data?.targetBranchId, + finalize_restore: data?.finalizeRestore + }, + { + headers: this.headers + } + ); + return response.data; + } + // ─── Consumption ─────────────────────────────────────────── async getProjectConsumption( @@ -511,13 +702,18 @@ export class NeonClient { params?: { from?: string; to?: string; + granularity?: 'hourly' | 'daily' | 'monthly'; + metrics?: string[]; } ) { - let response = await this.axios.get(`/projects/${projectId}`, { + let response = await this.axios.get('/consumption_history/projects', { headers: this.headers, params: { + project_ids: projectId, from: params?.from, - to: params?.to + to: params?.to, + granularity: params?.granularity, + metrics: params?.metrics } }); return response.data; @@ -526,18 +722,24 @@ export class NeonClient { async getAccountConsumption(params?: { from?: string; to?: string; + granularity?: 'hourly' | 'daily' | 'monthly'; cursor?: string; limit?: number; orgId?: string; + projectIds?: string[]; + metrics?: string[]; }) { - let response = await this.axios.get('/consumption/projects', { + let response = await this.axios.get('/consumption_history/projects', { headers: this.headers, params: { from: params?.from, to: params?.to, + granularity: params?.granularity, cursor: params?.cursor, limit: params?.limit, - org_id: params?.orgId + org_id: params?.orgId, + project_ids: params?.projectIds, + metrics: params?.metrics } }); return response.data; diff --git a/integrations/neon/src/lib/errors.ts b/integrations/neon/src/lib/errors.ts new file mode 100644 index 0000000000..7a94d640a8 --- /dev/null +++ b/integrations/neon/src/lib/errors.ts @@ -0,0 +1,92 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + for (let key of ['message', 'detail', 'error', 'error_description', 'code']) { + addDetail(details, value[key]); + } + + collectDetails(value.errors, details); +}; + +let extractNeonMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +export let neonServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let neonValidationError = (message: string) => { + let error = neonServiceError(message); + error.data.reason = 'neon_validation_error'; + return error; +}; + +export let neonApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = neonServiceError( + `Neon API ${operation} failed: ${statusLabelFor(response)}${extractNeonMessage(error)}` + ); + + serviceError.data.reason = 'neon_api_error'; + serviceError.data.upstreamStatus = response?.status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/neon/src/tools.schema.test.ts b/integrations/neon/src/tools.schema.test.ts new file mode 100644 index 0000000000..4cb1586f46 --- /dev/null +++ b/integrations/neon/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Neon tool input schemas', provider.actions); diff --git a/integrations/neon/src/tools/create-branch.ts b/integrations/neon/src/tools/create-branch.ts index f9cd4807a5..7262aea6a1 100644 --- a/integrations/neon/src/tools/create-branch.ts +++ b/integrations/neon/src/tools/create-branch.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { NeonClient } from '../lib/client'; +import { neonValidationError } from '../lib/errors'; import { spec } from '../spec'; export let createBranch = SlateTool.create(spec, { @@ -53,6 +54,12 @@ export let createBranch = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.parentTimestamp && ctx.input.parentLsn) { + throw neonValidationError( + 'Provide either parentTimestamp or parentLsn when creating a branch, not both.' + ); + } + let client = new NeonClient({ token: ctx.auth.token }); let endpoints = ctx.input.createEndpoint ? [{ type: 'read_write' }] : undefined; diff --git a/integrations/neon/src/tools/create-project.ts b/integrations/neon/src/tools/create-project.ts index 6e66bb344e..635a26d00f 100644 --- a/integrations/neon/src/tools/create-project.ts +++ b/integrations/neon/src/tools/create-project.ts @@ -29,6 +29,26 @@ export let createProject = SlateTool.create(spec, { .describe( 'PostgreSQL version (e.g., 14, 15, 16, 17). Defaults to the latest supported version.' ), + branchName: z + .string() + .optional() + .describe('Name for the default branch. Defaults to main.'), + databaseName: z + .string() + .optional() + .describe('Name for the default database. Defaults to neondb.'), + roleName: z + .string() + .optional() + .describe('Name for the default role. Defaults to the database owner role.'), + storePasswords: z + .boolean() + .optional() + .describe('Whether Neon should store role passwords for retrieval features.'), + historyRetentionSeconds: z + .number() + .optional() + .describe('Seconds to retain branch history for point-in-time restore.'), orgId: z .string() .optional() @@ -61,6 +81,11 @@ export let createProject = SlateTool.create(spec, { name: ctx.input.name, regionId: ctx.input.regionId, pgVersion: ctx.input.pgVersion, + branchName: ctx.input.branchName, + databaseName: ctx.input.databaseName, + roleName: ctx.input.roleName, + storePasswords: ctx.input.storePasswords, + historyRetentionSeconds: ctx.input.historyRetentionSeconds, orgId: ctx.input.orgId }); diff --git a/integrations/neon/src/tools/get-branch.ts b/integrations/neon/src/tools/get-branch.ts new file mode 100644 index 0000000000..1a405e4679 --- /dev/null +++ b/integrations/neon/src/tools/get-branch.ts @@ -0,0 +1,32 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NeonClient } from '../lib/client'; +import { spec } from '../spec'; +import { branchSchema, mapBranch } from './shared'; + +export let getBranch = SlateTool.create(spec, { + name: 'Get Branch', + key: 'get_branch', + description: `Retrieves details for a specific Neon branch, including its parent, state, default/protected flags, and timestamps.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project containing the branch'), + branchId: z.string().describe('ID of the branch to retrieve') + }) + ) + .output(branchSchema) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.getBranch(ctx.input.projectId, ctx.input.branchId); + let branch = mapBranch(result.branch); + + return { + output: branch, + message: `Retrieved branch **${branch.name}** (${branch.branchId}).` + }; + }) + .build(); diff --git a/integrations/neon/src/tools/get-connection-uri.ts b/integrations/neon/src/tools/get-connection-uri.ts new file mode 100644 index 0000000000..a0cbd1f803 --- /dev/null +++ b/integrations/neon/src/tools/get-connection-uri.ts @@ -0,0 +1,52 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NeonClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getConnectionUri = SlateTool.create(spec, { + name: 'Get Connection URI', + key: 'get_connection_uri', + description: `Retrieves a PostgreSQL connection URI for a Neon database and role. Use pooled=true to request a pooled connection URI.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project'), + databaseName: z.string().describe('Database name to connect to'), + roleName: z.string().describe('Role name to authenticate as'), + branchId: z + .string() + .optional() + .describe('Branch ID. Defaults to the project default branch if omitted.'), + endpointId: z + .string() + .optional() + .describe('Endpoint ID. Defaults to the read-write endpoint for the branch.'), + pooled: z.boolean().optional().describe('Whether to return a pooled connection URI') + }) + ) + .output( + z.object({ + connectionUri: z.string().describe('PostgreSQL connection URI') + }) + ) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.getConnectionUri(ctx.input.projectId, { + databaseName: ctx.input.databaseName, + roleName: ctx.input.roleName, + branchId: ctx.input.branchId, + endpointId: ctx.input.endpointId, + pooled: ctx.input.pooled + }); + + return { + output: { + connectionUri: result.uri + }, + message: `Retrieved ${ctx.input.pooled ? 'pooled ' : ''}connection URI for database **${ctx.input.databaseName}**.` + }; + }) + .build(); diff --git a/integrations/neon/src/tools/get-consumption.ts b/integrations/neon/src/tools/get-consumption.ts index 941685d1d7..4b8420b301 100644 --- a/integrations/neon/src/tools/get-consumption.ts +++ b/integrations/neon/src/tools/get-consumption.ts @@ -5,13 +5,19 @@ import { spec } from '../spec'; let projectConsumptionSchema = z.object({ projectId: z.string().describe('ID of the project'), - periodId: z.string().optional().describe('Billing period identifier'), - computeTime: z.number().optional().describe('Total compute time in seconds'), - activeTime: z.number().optional().describe('Total active time in seconds'), - dataStorageBytesHour: z.number().optional().describe('Data storage usage in byte-hours'), - syntheticStorageSize: z.number().optional().describe('Synthetic storage size in bytes'), - writtenData: z.number().optional().describe('Total data written in bytes'), - dataTransfer: z.number().optional().describe('Total data transferred in bytes') + periods: z + .array( + z.object({ + periodId: z.string().describe('Billing period identifier'), + periodPlan: z.string().describe('Billing plan for the period'), + periodStart: z.string().describe('Start timestamp for the billing period'), + periodEnd: z.string().optional().describe('End timestamp for the billing period'), + consumption: z + .array(z.record(z.string(), z.unknown())) + .describe('Consumption timeframes and requested metrics') + }) + ) + .describe('Consumption periods for the project') }); export let getConsumption = SlateTool.create(spec, { @@ -20,7 +26,8 @@ export let getConsumption = SlateTool.create(spec, { description: `Retrieves consumption metrics across all projects for the account. Tracks compute time, active time, storage, written data, and data transfer. Available on Neon paid plans.`, instructions: [ 'Requires a paid Neon plan. Will fail on free-tier accounts.', - 'The from/to parameters use ISO 8601 date format.' + 'The from/to parameters use ISO 8601 date-time format.', + 'Consumption history is available only for eligible paid plans.' ], tags: { readOnly: true @@ -28,12 +35,20 @@ export let getConsumption = SlateTool.create(spec, { }) .input( z.object({ - from: z - .string() - .optional() - .describe('Start date for the metrics period in ISO 8601 format'), - to: z.string().optional().describe('End date for the metrics period in ISO 8601 format'), + from: z.string().describe('Start date-time for the metrics period in ISO 8601 format'), + to: z.string().describe('End date-time for the metrics period in ISO 8601 format'), + granularity: z + .enum(['hourly', 'daily', 'monthly']) + .describe('Metric granularity for the requested date range'), orgId: z.string().optional().describe('Organization ID to filter consumption by'), + projectIds: z + .array(z.string()) + .optional() + .describe('Project IDs to filter consumption by'), + metrics: z + .array(z.string()) + .optional() + .describe('Consumption metric names to include. Defaults to Neon legacy metrics.'), limit: z .number() .optional() @@ -52,20 +67,23 @@ export let getConsumption = SlateTool.create(spec, { let result = await client.getAccountConsumption({ from: ctx.input.from, to: ctx.input.to, + granularity: ctx.input.granularity, orgId: ctx.input.orgId, + projectIds: ctx.input.projectIds, + metrics: ctx.input.metrics, limit: ctx.input.limit, cursor: ctx.input.cursor }); let projects = (result.projects || []).map((p: any) => ({ projectId: p.project_id, - periodId: p.period_id, - computeTime: p.compute_time, - activeTime: p.active_time, - dataStorageBytesHour: p.data_storage_bytes_hour, - syntheticStorageSize: p.synthetic_storage_size, - writtenData: p.written_data, - dataTransfer: p.data_transfer + periods: (p.periods || []).map((period: any) => ({ + periodId: period.period_id, + periodPlan: period.period_plan, + periodStart: period.period_start, + periodEnd: period.period_end, + consumption: period.consumption || [] + })) })); return { diff --git a/integrations/neon/src/tools/get-database.ts b/integrations/neon/src/tools/get-database.ts new file mode 100644 index 0000000000..55659db7b2 --- /dev/null +++ b/integrations/neon/src/tools/get-database.ts @@ -0,0 +1,37 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NeonClient } from '../lib/client'; +import { spec } from '../spec'; +import { databaseSchema, mapDatabase } from './shared'; + +export let getDatabase = SlateTool.create(spec, { + name: 'Get Database', + key: 'get_database', + description: `Retrieves details for a specific database on a Neon branch, including owner role and timestamps.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project'), + branchId: z.string().describe('ID of the branch containing the database'), + databaseName: z.string().describe('Name of the database to retrieve') + }) + ) + .output(databaseSchema) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.getDatabase( + ctx.input.projectId, + ctx.input.branchId, + ctx.input.databaseName + ); + let database = mapDatabase(result.database); + + return { + output: database, + message: `Retrieved database **${database.name}** on branch \`${database.branchId}\`.` + }; + }) + .build(); diff --git a/integrations/neon/src/tools/get-endpoint.ts b/integrations/neon/src/tools/get-endpoint.ts new file mode 100644 index 0000000000..0e04397809 --- /dev/null +++ b/integrations/neon/src/tools/get-endpoint.ts @@ -0,0 +1,32 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NeonClient } from '../lib/client'; +import { spec } from '../spec'; +import { endpointSchema, mapEndpoint } from './shared'; + +export let getEndpoint = SlateTool.create(spec, { + name: 'Get Endpoint', + key: 'get_endpoint', + description: `Retrieves details for a specific Neon compute endpoint, including state, host, autoscaling limits, suspend timeout, and access flags.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project'), + endpointId: z.string().describe('ID of the endpoint to retrieve') + }) + ) + .output(endpointSchema) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.getEndpoint(ctx.input.projectId, ctx.input.endpointId); + let endpoint = mapEndpoint(result.endpoint); + + return { + output: endpoint, + message: `Retrieved endpoint **${endpoint.endpointId}** (${endpoint.currentState ?? 'unknown state'}).` + }; + }) + .build(); diff --git a/integrations/neon/src/tools/get-role.ts b/integrations/neon/src/tools/get-role.ts new file mode 100644 index 0000000000..9bf94372c6 --- /dev/null +++ b/integrations/neon/src/tools/get-role.ts @@ -0,0 +1,74 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NeonClient } from '../lib/client'; +import { spec } from '../spec'; +import { mapRole, roleSchema } from './shared'; + +export let getRole = SlateTool.create(spec, { + name: 'Get Role', + key: 'get_role', + description: `Retrieves details for a database role on a Neon branch, including protection and authentication metadata.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project'), + branchId: z.string().describe('ID of the branch containing the role'), + roleName: z.string().describe('Name of the role to retrieve') + }) + ) + .output(roleSchema) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.getRole( + ctx.input.projectId, + ctx.input.branchId, + ctx.input.roleName + ); + let role = mapRole(result.role); + + return { + output: role, + message: `Retrieved role **${role.name}** on branch \`${role.branchId}\`.` + }; + }) + .build(); + +export let revealRolePassword = SlateTool.create(spec, { + name: 'Reveal Role Password', + key: 'reveal_role_password', + description: `Retrieves the stored password for a Neon database role when password storage is enabled. Use reset_role_password when the password should be rotated instead.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project'), + branchId: z.string().describe('ID of the branch containing the role'), + roleName: z.string().describe('Name of the role whose password to reveal') + }) + ) + .output( + z.object({ + password: z.string().describe('Stored role password') + }) + ) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.revealRolePassword( + ctx.input.projectId, + ctx.input.branchId, + ctx.input.roleName + ); + + return { + output: { + password: result.password + }, + message: `Retrieved stored password for role **${ctx.input.roleName}**.` + }; + }) + .build(); diff --git a/integrations/neon/src/tools/index.ts b/integrations/neon/src/tools/index.ts index 7fcb0add45..e935c705c9 100644 --- a/integrations/neon/src/tools/index.ts +++ b/integrations/neon/src/tools/index.ts @@ -2,13 +2,22 @@ export * from './create-branch'; export * from './create-project'; export * from './delete-branch'; export * from './delete-project'; +export * from './get-branch'; +export * from './get-connection-uri'; export * from './get-consumption'; +export * from './get-database'; +export * from './get-endpoint'; export * from './get-project'; +export * from './get-role'; export * from './list-branches'; export * from './list-operations'; export * from './list-projects'; +export * from './list-regions'; export * from './manage-database'; export * from './manage-endpoint'; export * from './manage-role'; +export * from './manage-snapshot'; +export * from './recover-project'; export * from './restore-branch'; +export * from './update-branch'; export * from './update-project'; diff --git a/integrations/neon/src/tools/list-branches.ts b/integrations/neon/src/tools/list-branches.ts index 8a3e6f0511..63d7f41d60 100644 --- a/integrations/neon/src/tools/list-branches.ts +++ b/integrations/neon/src/tools/list-branches.ts @@ -31,7 +31,16 @@ export let listBranches = SlateTool.create(spec, { projectId: z.string().describe('ID of the project to list branches for'), search: z.string().optional().describe('Search term to filter branches by name'), limit: z.number().optional().describe('Maximum number of branches to return'), - cursor: z.string().optional().describe('Pagination cursor for fetching next page') + cursor: z.string().optional().describe('Pagination cursor for fetching next page'), + sortBy: z + .enum(['name', 'created_at', 'updated_at']) + .optional() + .describe('Field to sort branches by'), + sortOrder: z.enum(['asc', 'desc']).optional().describe('Branch sort order'), + includeDeleted: z + .boolean() + .optional() + .describe('Whether to include recoverable deleted branches') }) ) .output( @@ -46,7 +55,10 @@ export let listBranches = SlateTool.create(spec, { let result = await client.listBranches(ctx.input.projectId, { search: ctx.input.search, limit: ctx.input.limit, - cursor: ctx.input.cursor + cursor: ctx.input.cursor, + sortBy: ctx.input.sortBy, + sortOrder: ctx.input.sortOrder, + includeDeleted: ctx.input.includeDeleted }); let branches = (result.branches || []).map((b: any) => ({ @@ -55,7 +67,7 @@ export let listBranches = SlateTool.create(spec, { name: b.name, parentId: b.parent_id, parentTimestamp: b.parent_timestamp, - primary: b.primary, + primary: b.primary ?? b.default, currentState: b.current_state, createdAt: b.created_at, updatedAt: b.updated_at diff --git a/integrations/neon/src/tools/list-projects.ts b/integrations/neon/src/tools/list-projects.ts index ee5cb2b6a2..59ea7ec2d5 100644 --- a/integrations/neon/src/tools/list-projects.ts +++ b/integrations/neon/src/tools/list-projects.ts @@ -26,6 +26,10 @@ export let listProjects = SlateTool.create(spec, { z.object({ search: z.string().optional().describe('Search term to filter projects by name or ID'), orgId: z.string().optional().describe('Organization ID to filter projects by'), + recoverable: z + .boolean() + .optional() + .describe('Whether to list deleted projects still within the recovery window'), limit: z .number() .optional() @@ -48,6 +52,7 @@ export let listProjects = SlateTool.create(spec, { let result = await client.listProjects({ search: ctx.input.search, orgId: ctx.input.orgId, + recoverable: ctx.input.recoverable, limit: ctx.input.limit, cursor: ctx.input.cursor }); diff --git a/integrations/neon/src/tools/list-regions.ts b/integrations/neon/src/tools/list-regions.ts new file mode 100644 index 0000000000..fdbc4cec46 --- /dev/null +++ b/integrations/neon/src/tools/list-regions.ts @@ -0,0 +1,52 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NeonClient } from '../lib/client'; +import { spec } from '../spec'; + +let regionSchema = z.object({ + regionId: z.string().describe('Region ID used by project and endpoint APIs'), + name: z.string().describe('Human-readable region name'), + default: z.boolean().describe('Whether this region is the default for new projects'), + geoLat: z.string().describe('Approximate region latitude'), + geoLong: z.string().describe('Approximate region longitude') +}); + +export let listRegions = SlateTool.create(spec, { + name: 'List Regions', + key: 'list_regions', + description: `Lists supported Neon regions. Use this before creating projects to choose a valid region ID. Pass orgId to see the regions available to a specific Neon organization.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + orgId: z + .string() + .optional() + .describe('Organization ID to return only regions available to that organization') + }) + ) + .output( + z.object({ + regions: z.array(regionSchema).describe('Supported Neon regions') + }) + ) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.listRegions({ orgId: ctx.input.orgId }); + + let regions = (result.regions || []).map((region: any) => ({ + regionId: region.region_id, + name: region.name, + default: region.default, + geoLat: region.geo_lat, + geoLong: region.geo_long + })); + + return { + output: { regions }, + message: `Found **${regions.length}** supported Neon region(s).` + }; + }) + .build(); diff --git a/integrations/neon/src/tools/manage-database.ts b/integrations/neon/src/tools/manage-database.ts index da87e49bf2..3a16580713 100644 --- a/integrations/neon/src/tools/manage-database.ts +++ b/integrations/neon/src/tools/manage-database.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { NeonClient } from '../lib/client'; +import { neonValidationError } from '../lib/errors'; import { spec } from '../spec'; let databaseSchema = z.object({ @@ -75,6 +76,10 @@ export let createDatabase = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.name === undefined && ctx.input.ownerName === undefined) { + throw neonValidationError('Provide a new database name or ownerName to update.'); + } + let client = new NeonClient({ token: ctx.auth.token }); let result = await client.createDatabase(ctx.input.projectId, ctx.input.branchId, { name: ctx.input.name, diff --git a/integrations/neon/src/tools/manage-endpoint.ts b/integrations/neon/src/tools/manage-endpoint.ts index 54afa943f3..cb2cdbabea 100644 --- a/integrations/neon/src/tools/manage-endpoint.ts +++ b/integrations/neon/src/tools/manage-endpoint.ts @@ -1,18 +1,22 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { NeonClient } from '../lib/client'; +import { neonValidationError } from '../lib/errors'; import { spec } from '../spec'; let endpointSchema = z.object({ endpointId: z.string().describe('Unique identifier of the compute endpoint'), projectId: z.string().describe('Project the endpoint belongs to'), branchId: z.string().describe('Branch the endpoint is connected to'), + name: z.string().optional().describe('Optional endpoint name'), type: z.string().describe('Endpoint type: read_write or read_only'), host: z.string().optional().describe('Hostname for connecting to the endpoint'), currentState: z .string() .optional() .describe('Current state of the endpoint (init, active, idle, suspended)'), + pendingState: z.string().optional().describe('Pending endpoint state'), + regionId: z.string().optional().describe('Region where the endpoint runs'), autoscalingLimitMinCu: z .number() .optional() @@ -25,6 +29,11 @@ let endpointSchema = z.object({ .number() .optional() .describe('Seconds of inactivity before the endpoint is suspended'), + disabled: z.boolean().optional().describe('Whether connections are disabled'), + passwordlessAccess: z + .boolean() + .optional() + .describe('Whether passwordless access is enabled'), createdAt: z.string().describe('Timestamp when the endpoint was created'), updatedAt: z.string().describe('Timestamp when the endpoint was last updated') }); @@ -55,12 +64,17 @@ export let listEndpoints = SlateTool.create(spec, { endpointId: e.id, projectId: e.project_id, branchId: e.branch_id, + name: e.name, type: e.type, host: e.host, currentState: e.current_state, + pendingState: e.pending_state, + regionId: e.region_id, autoscalingLimitMinCu: e.autoscaling_limit_min_cu, autoscalingLimitMaxCu: e.autoscaling_limit_max_cu, suspendTimeoutSeconds: e.suspend_timeout_seconds, + disabled: e.disabled, + passwordlessAccess: e.passwordless_access, createdAt: e.created_at, updatedAt: e.updated_at })); @@ -88,6 +102,11 @@ export let createEndpoint = SlateTool.create(spec, { type: z .enum(['read_write', 'read_only']) .describe('Endpoint type: read_write for primary or read_only for replicas'), + name: z.string().optional().describe('Optional endpoint name'), + regionId: z + .string() + .optional() + .describe('Region where the endpoint should run. Must be the project region.'), autoscalingLimitMinCu: z .number() .optional() @@ -101,7 +120,12 @@ export let createEndpoint = SlateTool.create(spec, { .optional() .describe( 'Seconds of inactivity before the endpoint suspends (0 to disable auto-suspend)' - ) + ), + disabled: z.boolean().optional().describe('Whether connections should be disabled'), + passwordlessAccess: z + .boolean() + .optional() + .describe('Whether passwordless access should be enabled') }) ) .output(endpointSchema) @@ -110,9 +134,13 @@ export let createEndpoint = SlateTool.create(spec, { let result = await client.createEndpoint(ctx.input.projectId, { branchId: ctx.input.branchId, type: ctx.input.type, + name: ctx.input.name, + regionId: ctx.input.regionId, autoscalingLimitMinCu: ctx.input.autoscalingLimitMinCu, autoscalingLimitMaxCu: ctx.input.autoscalingLimitMaxCu, - suspendTimeoutSeconds: ctx.input.suspendTimeoutSeconds + suspendTimeoutSeconds: ctx.input.suspendTimeoutSeconds, + disabled: ctx.input.disabled, + passwordlessAccess: ctx.input.passwordlessAccess }); let e = result.endpoint; @@ -122,12 +150,17 @@ export let createEndpoint = SlateTool.create(spec, { endpointId: e.id, projectId: e.project_id, branchId: e.branch_id, + name: e.name, type: e.type, host: e.host, currentState: e.current_state, + pendingState: e.pending_state, + regionId: e.region_id, autoscalingLimitMinCu: e.autoscaling_limit_min_cu, autoscalingLimitMaxCu: e.autoscaling_limit_max_cu, suspendTimeoutSeconds: e.suspend_timeout_seconds, + disabled: e.disabled, + passwordlessAccess: e.passwordless_access, createdAt: e.created_at, updatedAt: e.updated_at }, @@ -145,21 +178,41 @@ export let updateEndpoint = SlateTool.create(spec, { z.object({ projectId: z.string().describe('ID of the project'), endpointId: z.string().describe('ID of the endpoint to update'), + name: z.string().optional().describe('New endpoint name'), autoscalingLimitMinCu: z.number().optional().describe('New minimum compute units'), autoscalingLimitMaxCu: z.number().optional().describe('New maximum compute units'), suspendTimeoutSeconds: z .number() .optional() - .describe('New suspend timeout in seconds (0 to disable)') + .describe('New suspend timeout in seconds (0 to disable)'), + disabled: z.boolean().optional().describe('Whether connections should be disabled'), + passwordlessAccess: z + .boolean() + .optional() + .describe('Whether passwordless access should be enabled') }) ) .output(endpointSchema) .handleInvocation(async ctx => { + if ( + ctx.input.name === undefined && + ctx.input.autoscalingLimitMinCu === undefined && + ctx.input.autoscalingLimitMaxCu === undefined && + ctx.input.suspendTimeoutSeconds === undefined && + ctx.input.disabled === undefined && + ctx.input.passwordlessAccess === undefined + ) { + throw neonValidationError('Provide at least one endpoint field to update.'); + } + let client = new NeonClient({ token: ctx.auth.token }); let result = await client.updateEndpoint(ctx.input.projectId, ctx.input.endpointId, { + name: ctx.input.name, autoscalingLimitMinCu: ctx.input.autoscalingLimitMinCu, autoscalingLimitMaxCu: ctx.input.autoscalingLimitMaxCu, - suspendTimeoutSeconds: ctx.input.suspendTimeoutSeconds + suspendTimeoutSeconds: ctx.input.suspendTimeoutSeconds, + disabled: ctx.input.disabled, + passwordlessAccess: ctx.input.passwordlessAccess }); let e = result.endpoint; @@ -169,12 +222,17 @@ export let updateEndpoint = SlateTool.create(spec, { endpointId: e.id, projectId: e.project_id, branchId: e.branch_id, + name: e.name, type: e.type, host: e.host, currentState: e.current_state, + pendingState: e.pending_state, + regionId: e.region_id, autoscalingLimitMinCu: e.autoscaling_limit_min_cu, autoscalingLimitMaxCu: e.autoscaling_limit_max_cu, suspendTimeoutSeconds: e.suspend_timeout_seconds, + disabled: e.disabled, + passwordlessAccess: e.passwordless_access, createdAt: e.created_at, updatedAt: e.updated_at }, @@ -251,12 +309,17 @@ export let controlEndpoint = SlateTool.create(spec, { endpointId: e.id, projectId: e.project_id, branchId: e.branch_id, + name: e.name, type: e.type, host: e.host, currentState: e.current_state, + pendingState: e.pending_state, + regionId: e.region_id, autoscalingLimitMinCu: e.autoscaling_limit_min_cu, autoscalingLimitMaxCu: e.autoscaling_limit_max_cu, suspendTimeoutSeconds: e.suspend_timeout_seconds, + disabled: e.disabled, + passwordlessAccess: e.passwordless_access, createdAt: e.created_at, updatedAt: e.updated_at }, diff --git a/integrations/neon/src/tools/manage-role.ts b/integrations/neon/src/tools/manage-role.ts index 68402a6f0f..59e7b2acc4 100644 --- a/integrations/neon/src/tools/manage-role.ts +++ b/integrations/neon/src/tools/manage-role.ts @@ -7,6 +7,7 @@ let roleSchema = z.object({ branchId: z.string().describe('Branch the role belongs to'), name: z.string().describe('Name of the role'), protected: z.boolean().optional().describe('Whether the role is protected from deletion'), + authenticationMethod: z.string().optional().describe('Configured authentication method'), createdAt: z.string().describe('Timestamp when the role was created'), updatedAt: z.string().describe('Timestamp when the role was last updated') }); @@ -38,6 +39,7 @@ export let listRoles = SlateTool.create(spec, { branchId: r.branch_id, name: r.name, protected: r.protected, + authenticationMethod: r.authentication_method, createdAt: r.created_at, updatedAt: r.updated_at })); diff --git a/integrations/neon/src/tools/manage-snapshot.ts b/integrations/neon/src/tools/manage-snapshot.ts new file mode 100644 index 0000000000..ffdd628aea --- /dev/null +++ b/integrations/neon/src/tools/manage-snapshot.ts @@ -0,0 +1,207 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NeonClient } from '../lib/client'; +import { neonValidationError } from '../lib/errors'; +import { spec } from '../spec'; +import { + branchSchema, + mapBranch, + mapOperation, + mapSnapshot, + operationSchema, + snapshotSchema +} from './shared'; + +export let listSnapshots = SlateTool.create(spec, { + name: 'List Snapshots', + key: 'list_snapshots', + description: `Lists snapshots for a Neon project. Snapshots are point-in-time backups of project data.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project to list snapshots for') + }) + ) + .output( + z.object({ + snapshots: z.array(snapshotSchema).describe('Snapshots for the project') + }) + ) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.listSnapshots(ctx.input.projectId); + let snapshots = (result.snapshots || []).map(mapSnapshot); + + return { + output: { snapshots }, + message: `Found **${snapshots.length}** snapshot(s) for project \`${ctx.input.projectId}\`.` + }; + }) + .build(); + +export let createSnapshot = SlateTool.create(spec, { + name: 'Create Snapshot', + key: 'create_snapshot', + description: `Creates a point-in-time snapshot from a Neon branch. Provide either lsn or timestamp to snapshot a specific historical point, not both.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project'), + branchId: z.string().describe('ID of the branch to snapshot'), + name: z.string().optional().describe('Name for the snapshot'), + lsn: z.string().optional().describe('Log Sequence Number to snapshot from'), + timestamp: z.string().optional().describe('ISO 8601 timestamp to snapshot from'), + expiresAt: z + .string() + .optional() + .describe('ISO 8601 timestamp when the snapshot should be automatically deleted') + }) + ) + .output( + z.object({ + snapshot: snapshotSchema.describe('Created snapshot'), + operations: z.array(operationSchema).describe('Operations created by snapshot creation') + }) + ) + .handleInvocation(async ctx => { + if (ctx.input.lsn && ctx.input.timestamp) { + throw neonValidationError( + 'Provide either lsn or timestamp when creating a snapshot, not both.' + ); + } + + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.createSnapshot(ctx.input.projectId, ctx.input.branchId, { + name: ctx.input.name, + lsn: ctx.input.lsn, + timestamp: ctx.input.timestamp, + expiresAt: ctx.input.expiresAt + }); + let snapshot = mapSnapshot(result.snapshot); + let operations = (result.operations || []).map(mapOperation); + + return { + output: { snapshot, operations }, + message: `Created snapshot **${snapshot.name}** (${snapshot.snapshotId}).` + }; + }) + .build(); + +export let updateSnapshot = SlateTool.create(spec, { + name: 'Update Snapshot', + key: 'update_snapshot', + description: `Renames a Neon project snapshot.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project'), + snapshotId: z.string().describe('ID of the snapshot to update'), + name: z.string().describe('New snapshot name') + }) + ) + .output(snapshotSchema) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.updateSnapshot(ctx.input.projectId, ctx.input.snapshotId, { + name: ctx.input.name + }); + let snapshot = mapSnapshot(result.snapshot); + + return { + output: snapshot, + message: `Updated snapshot **${snapshot.name}** (${snapshot.snapshotId}).` + }; + }) + .build(); + +export let deleteSnapshot = SlateTool.create(spec, { + name: 'Delete Snapshot', + key: 'delete_snapshot', + description: `Deletes a Neon project snapshot.`, + tags: { + destructive: true + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project'), + snapshotId: z.string().describe('ID of the snapshot to delete') + }) + ) + .output( + z.object({ + snapshotId: z.string().describe('ID of the deleted snapshot'), + deleted: z.boolean().describe('Whether the delete request was accepted'), + operations: z.array(operationSchema).describe('Operations created by deletion') + }) + ) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.deleteSnapshot(ctx.input.projectId, ctx.input.snapshotId); + let operations = (result.operations || []).map(mapOperation); + + return { + output: { + snapshotId: ctx.input.snapshotId, + deleted: true, + operations + }, + message: `Deleted snapshot **${ctx.input.snapshotId}**.` + }; + }) + .build(); + +export let restoreSnapshot = SlateTool.create(spec, { + name: 'Restore Snapshot', + key: 'restore_snapshot', + description: `Restores a Neon snapshot to a branch. By default Neon creates a restored branch for preview; set finalizeRestore only when you intend to replace the target branch.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project'), + snapshotId: z.string().describe('ID of the snapshot to restore'), + name: z.string().optional().describe('Name for the newly restored branch'), + targetBranchId: z + .string() + .optional() + .describe('Branch ID to restore into. Defaults to the snapshot source branch.'), + finalizeRestore: z + .boolean() + .optional() + .describe('Whether to finalize restore immediately and replace the target branch') + }) + ) + .output( + z.object({ + branch: branchSchema.describe('Branch restored from the snapshot'), + operations: z.array(operationSchema).describe('Operations created by restore') + }) + ) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.restoreSnapshot(ctx.input.projectId, ctx.input.snapshotId, { + name: ctx.input.name, + targetBranchId: ctx.input.targetBranchId, + finalizeRestore: ctx.input.finalizeRestore + }); + let branch = mapBranch(result.branch); + let operations = (result.operations || []).map(mapOperation); + + return { + output: { branch, operations }, + message: `Restored snapshot **${ctx.input.snapshotId}** to branch **${branch.name}** (${branch.branchId}).` + }; + }) + .build(); diff --git a/integrations/neon/src/tools/recover-project.ts b/integrations/neon/src/tools/recover-project.ts new file mode 100644 index 0000000000..ae7487cd3b --- /dev/null +++ b/integrations/neon/src/tools/recover-project.ts @@ -0,0 +1,46 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NeonClient } from '../lib/client'; +import { spec } from '../spec'; + +export let recoverProject = SlateTool.create(spec, { + name: 'Recover Project', + key: 'recover_project', + description: `Recovers a deleted Neon project within the deletion recovery period. Restores branches, endpoints, settings, and connection strings when Neon can still recover the project.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the deleted project to recover') + }) + ) + .output( + z.object({ + projectId: z.string().describe('Unique identifier of the recovered project'), + name: z.string().describe('Name of the recovered project'), + regionId: z.string().describe('Region where the project is hosted'), + pgVersion: z.number().describe('PostgreSQL version number'), + defaultBranchId: z.string().optional().describe('ID of the default branch'), + updatedAt: z.string().describe('Timestamp when the project was last updated') + }) + ) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.recoverProject(ctx.input.projectId); + let p = result.project; + + return { + output: { + projectId: p.id, + name: p.name, + regionId: p.region_id, + pgVersion: p.pg_version, + defaultBranchId: p.default_branch_id, + updatedAt: p.updated_at + }, + message: `Recovered project **${p.name}** (${p.id}).` + }; + }) + .build(); diff --git a/integrations/neon/src/tools/restore-branch.ts b/integrations/neon/src/tools/restore-branch.ts index 31dccdf0bf..44bcd6b616 100644 --- a/integrations/neon/src/tools/restore-branch.ts +++ b/integrations/neon/src/tools/restore-branch.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { NeonClient } from '../lib/client'; +import { neonValidationError } from '../lib/errors'; import { spec } from '../spec'; export let restoreBranch = SlateTool.create(spec, { @@ -31,6 +32,12 @@ export let restoreBranch = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.sourceTimestamp && ctx.input.sourceLsn) { + throw neonValidationError( + 'Provide either sourceTimestamp or sourceLsn when restoring a branch, not both.' + ); + } + let client = new NeonClient({ token: ctx.auth.token }); let result = await client.restoreBranch(ctx.input.projectId, ctx.input.branchId, { diff --git a/integrations/neon/src/tools/shared.ts b/integrations/neon/src/tools/shared.ts new file mode 100644 index 0000000000..2d880693d7 --- /dev/null +++ b/integrations/neon/src/tools/shared.ts @@ -0,0 +1,175 @@ +import { z } from 'zod'; + +export let operationSchema = z.object({ + operationId: z.string().describe('Unique identifier of the operation'), + projectId: z.string().describe('Project the operation belongs to'), + branchId: z.string().optional().describe('Branch associated with the operation'), + endpointId: z.string().optional().describe('Endpoint associated with the operation'), + action: z.string().describe('Type of operation'), + status: z.string().describe('Operation status'), + createdAt: z.string().describe('Timestamp when the operation was created'), + updatedAt: z.string().describe('Timestamp when the operation was last updated'), + totalDurationMs: z + .number() + .optional() + .describe('Total duration of the operation in milliseconds') +}); + +export let branchSchema = z.object({ + branchId: z.string().describe('Unique identifier of the branch'), + projectId: z.string().describe('Project the branch belongs to'), + name: z.string().describe('Name of the branch'), + parentId: z.string().optional().describe('ID of the parent branch'), + parentLsn: z.string().optional().describe('Parent branch LSN'), + parentTimestamp: z + .string() + .optional() + .describe('Timestamp of the parent branch point-in-time'), + default: z.boolean().optional().describe('Whether this is the default branch'), + protected: z.boolean().optional().describe('Whether the branch is protected'), + currentState: z.string().optional().describe('Current state of the branch'), + pendingState: z.string().optional().describe('Pending state of the branch'), + createdAt: z.string().describe('Timestamp when the branch was created'), + updatedAt: z.string().describe('Timestamp when the branch was last updated'), + expiresAt: z.string().optional().describe('Timestamp when the branch expires') +}); + +export let databaseSchema = z.object({ + databaseId: z.number().describe('Numeric identifier of the database'), + branchId: z.string().describe('Branch the database belongs to'), + name: z.string().describe('Name of the database'), + ownerName: z.string().describe('Role that owns the database'), + createdAt: z.string().describe('Timestamp when the database was created'), + updatedAt: z.string().describe('Timestamp when the database was last updated') +}); + +export let endpointSchema = z.object({ + endpointId: z.string().describe('Unique identifier of the compute endpoint'), + projectId: z.string().describe('Project the endpoint belongs to'), + branchId: z.string().describe('Branch the endpoint is connected to'), + name: z.string().optional().describe('Optional endpoint name'), + type: z.string().describe('Endpoint type: read_write or read_only'), + host: z.string().optional().describe('Hostname for connecting to the endpoint'), + currentState: z.string().optional().describe('Current state of the endpoint'), + pendingState: z.string().optional().describe('Pending state of the endpoint'), + regionId: z.string().optional().describe('Region where the endpoint runs'), + autoscalingLimitMinCu: z + .number() + .optional() + .describe('Minimum compute units for autoscaling'), + autoscalingLimitMaxCu: z + .number() + .optional() + .describe('Maximum compute units for autoscaling'), + suspendTimeoutSeconds: z + .number() + .optional() + .describe('Seconds of inactivity before the endpoint is suspended'), + disabled: z.boolean().optional().describe('Whether connections are disabled'), + passwordlessAccess: z + .boolean() + .optional() + .describe('Whether passwordless access is enabled'), + createdAt: z.string().describe('Timestamp when the endpoint was created'), + updatedAt: z.string().describe('Timestamp when the endpoint was last updated') +}); + +export let roleSchema = z.object({ + branchId: z.string().describe('Branch the role belongs to'), + name: z.string().describe('Name of the role'), + protected: z.boolean().optional().describe('Whether the role is protected from deletion'), + authenticationMethod: z.string().optional().describe('Configured authentication method'), + createdAt: z.string().describe('Timestamp when the role was created'), + updatedAt: z.string().describe('Timestamp when the role was last updated') +}); + +export let snapshotSchema = z.object({ + snapshotId: z.string().describe('Unique identifier of the snapshot'), + name: z.string().describe('Snapshot name'), + lsn: z.string().optional().describe('Snapshot Log Sequence Number'), + timestamp: z.string().optional().describe('Snapshot timestamp'), + sourceBranchId: z.string().optional().describe('Branch the snapshot was created from'), + createdAt: z.string().describe('Timestamp when the snapshot was created'), + expiresAt: z.string().optional().describe('Timestamp when the snapshot expires'), + manual: z.boolean().optional().describe('Whether this is a manual snapshot'), + fullSize: z.number().optional().describe('Full logical size in bytes'), + diffSize: z.number().optional().describe('Incremental storage size in bytes') +}); + +export let mapOperation = (op: any) => ({ + operationId: op.id, + projectId: op.project_id, + branchId: op.branch_id, + endpointId: op.endpoint_id, + action: op.action, + status: op.status, + createdAt: op.created_at, + updatedAt: op.updated_at, + totalDurationMs: op.total_duration_ms +}); + +export let mapBranch = (b: any) => ({ + branchId: b.id, + projectId: b.project_id, + name: b.name, + parentId: b.parent_id, + parentLsn: b.parent_lsn, + parentTimestamp: b.parent_timestamp, + default: b.default ?? b.primary, + protected: b.protected, + currentState: b.current_state, + pendingState: b.pending_state, + createdAt: b.created_at, + updatedAt: b.updated_at, + expiresAt: b.expires_at +}); + +export let mapDatabase = (d: any) => ({ + databaseId: d.id, + branchId: d.branch_id, + name: d.name, + ownerName: d.owner_name, + createdAt: d.created_at, + updatedAt: d.updated_at +}); + +export let mapEndpoint = (e: any) => ({ + endpointId: e.id, + projectId: e.project_id, + branchId: e.branch_id, + name: e.name, + type: e.type, + host: e.host, + currentState: e.current_state, + pendingState: e.pending_state, + regionId: e.region_id, + autoscalingLimitMinCu: e.autoscaling_limit_min_cu, + autoscalingLimitMaxCu: e.autoscaling_limit_max_cu, + suspendTimeoutSeconds: e.suspend_timeout_seconds, + disabled: e.disabled, + passwordlessAccess: e.passwordless_access, + createdAt: e.created_at, + updatedAt: e.updated_at +}); + +export let mapRole = (r: any) => ({ + branchId: r.branch_id, + name: r.name, + protected: r.protected, + authenticationMethod: r.authentication_method, + createdAt: r.created_at, + updatedAt: r.updated_at +}); + +export let mapSnapshot = (s: any) => ({ + snapshotId: s.id, + name: s.name, + lsn: s.lsn, + timestamp: s.timestamp, + sourceBranchId: s.source_branch_id, + createdAt: s.created_at, + expiresAt: s.expires_at, + manual: s.manual, + fullSize: s.full_size, + diffSize: s.diff_size +}); diff --git a/integrations/neon/src/tools/update-branch.ts b/integrations/neon/src/tools/update-branch.ts new file mode 100644 index 0000000000..731b3b4dc5 --- /dev/null +++ b/integrations/neon/src/tools/update-branch.ts @@ -0,0 +1,124 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NeonClient } from '../lib/client'; +import { neonValidationError } from '../lib/errors'; +import { spec } from '../spec'; +import { branchSchema, mapBranch, mapOperation, operationSchema } from './shared'; + +export let updateBranch = SlateTool.create(spec, { + name: 'Update Branch', + key: 'update_branch', + description: `Updates a Neon branch name, protected flag, or expiration timestamp. Set expiresAt to null to remove the expiration timestamp.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project containing the branch'), + branchId: z.string().describe('ID of the branch to update'), + name: z.string().optional().describe('New branch name'), + protected: z.boolean().optional().describe('Whether the branch should be protected'), + expiresAt: z + .string() + .nullable() + .optional() + .describe('RFC3339 expiration timestamp, or null to remove expiration') + }) + ) + .output( + z.object({ + branch: branchSchema.describe('Updated branch'), + operations: z.array(operationSchema).describe('Operations created by the update') + }) + ) + .handleInvocation(async ctx => { + if ( + ctx.input.name === undefined && + ctx.input.protected === undefined && + ctx.input.expiresAt === undefined + ) { + throw neonValidationError('Provide at least one branch field to update.'); + } + + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.updateBranch(ctx.input.projectId, ctx.input.branchId, { + name: ctx.input.name, + protected: ctx.input.protected, + expiresAt: ctx.input.expiresAt + }); + let branch = mapBranch(result.branch); + let operations = (result.operations || []).map(mapOperation); + + return { + output: { branch, operations }, + message: `Updated branch **${branch.name}** (${branch.branchId}).` + }; + }) + .build(); + +export let setDefaultBranch = SlateTool.create(spec, { + name: 'Set Default Branch', + key: 'set_default_branch', + description: `Sets a branch as the default branch for a Neon project. The previous default branch is automatically unset.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project containing the branch'), + branchId: z.string().describe('ID of the branch to make the default') + }) + ) + .output( + z.object({ + branch: branchSchema.describe('Branch that is now default'), + operations: z.array(operationSchema).describe('Operations created by the update') + }) + ) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.setDefaultBranch(ctx.input.projectId, ctx.input.branchId); + let branch = mapBranch(result.branch); + let operations = (result.operations || []).map(mapOperation); + + return { + output: { branch, operations }, + message: `Set branch **${branch.name}** (${branch.branchId}) as the project default.` + }; + }) + .build(); + +export let recoverBranch = SlateTool.create(spec, { + name: 'Recover Branch', + key: 'recover_branch', + description: `Recovers a soft-deleted Neon branch within the deletion recovery period when branch recovery is available for the project.`, + tags: { + destructive: false + } +}) + .input( + z.object({ + projectId: z.string().describe('ID of the project containing the deleted branch'), + branchId: z.string().describe('ID of the soft-deleted branch to recover') + }) + ) + .output( + z.object({ + branch: branchSchema.describe('Recovered branch'), + operations: z.array(operationSchema).describe('Operations created by recovery') + }) + ) + .handleInvocation(async ctx => { + let client = new NeonClient({ token: ctx.auth.token }); + let result = await client.recoverBranch(ctx.input.projectId, ctx.input.branchId); + let branch = mapBranch(result.branch); + let operations = (result.operations || []).map(mapOperation); + + return { + output: { branch, operations }, + message: `Recovered branch **${branch.name}** (${branch.branchId}).` + }; + }) + .build(); diff --git a/integrations/neon/src/tools/update-project.ts b/integrations/neon/src/tools/update-project.ts index 699c764669..b6610cbb82 100644 --- a/integrations/neon/src/tools/update-project.ts +++ b/integrations/neon/src/tools/update-project.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { NeonClient } from '../lib/client'; +import { neonValidationError } from '../lib/errors'; import { spec } from '../spec'; export let updateProject = SlateTool.create(spec, { @@ -14,7 +15,11 @@ export let updateProject = SlateTool.create(spec, { .input( z.object({ projectId: z.string().describe('ID of the project to update'), - name: z.string().optional().describe('New name for the project') + name: z.string().optional().describe('New name for the project'), + historyRetentionSeconds: z + .number() + .optional() + .describe('Seconds to retain branch history for point-in-time restore') }) ) .output( @@ -25,10 +30,15 @@ export let updateProject = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if (ctx.input.name === undefined && ctx.input.historyRetentionSeconds === undefined) { + throw neonValidationError('Provide at least one project field to update.'); + } + let client = new NeonClient({ token: ctx.auth.token }); let result = await client.updateProject(ctx.input.projectId, { - name: ctx.input.name + name: ctx.input.name, + historyRetentionSeconds: ctx.input.historyRetentionSeconds }); let p = result.project; diff --git a/integrations/neon/src/triggers/inbound-webhook.ts b/integrations/neon/src/triggers/inbound-webhook.ts deleted file mode 100644 index a4ba5368cc..0000000000 --- a/integrations/neon/src/triggers/inbound-webhook.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { SlateTrigger } from 'slates'; -import { z } from 'zod'; -import { spec } from '../spec'; - -/** - * Generic inbound webhook for providers without a tailored webhook trigger yet. - * POST JSON is parsed into `payload` (non-objects are wrapped as { _value }). - * Refine in the workflow mapper or replace with a provider-specific trigger. - */ -export let inboundWebhook = SlateTrigger.create(spec, { - name: 'Inbound Webhook', - key: 'inbound_webhook', - description: - 'Receives HTTP POST at the Slates webhook URL. Parses JSON into payload (or stores raw body if not JSON). Configure your provider to POST here when supported.' -}) - .input( - z.object({ - payload: z - .record(z.string(), z.any()) - .describe('Parsed JSON object from the request body'), - rawBody: z.string().optional().describe('Raw body when JSON parsing failed'), - contentType: z.string().optional().describe('Content-Type header') - }) - ) - .output( - z.object({ - payload: z.record(z.string(), z.any()), - rawBody: z.string().optional() - }) - ) - .webhook({ - handleRequest: async ctx => { - let contentType = ctx.request.headers.get('content-type') ?? ''; - let text = await ctx.request.text(); - if (!text?.trim()) { - return { - inputs: [{ payload: {}, contentType }] - }; - } - try { - let parsed = JSON.parse(text); - let payload = - parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) - ? parsed - : { _value: parsed }; - return { - inputs: [{ payload, contentType }] - }; - } catch { - return { - inputs: [{ payload: {}, rawBody: text, contentType }] - }; - } - }, - - handleEvent: async ctx => { - return { - type: 'webhook.inbound', - id: `inbound-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - output: { - payload: ctx.input.payload, - rawBody: ctx.input.rawBody - } - }; - } - }) - .build(); diff --git a/integrations/neon/src/triggers/index.ts b/integrations/neon/src/triggers/index.ts index f2a59a9ce6..b5106b55fd 100644 --- a/integrations/neon/src/triggers/index.ts +++ b/integrations/neon/src/triggers/index.ts @@ -1,3 +1,2 @@ // Neon does not support webhooks or event subscriptions. // No triggers are available for this provider. -export * from './inbound-webhook'; diff --git a/integrations/neon/vitest.config.ts b/integrations/neon/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/neon/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/new-relic/README.md b/integrations/new-relic/README.md index d30c46cacb..0178b72dec 100644 --- a/integrations/new-relic/README.md +++ b/integrations/new-relic/README.md @@ -1,6 +1,6 @@ # New Relic -Query telemetry data using NRQL across metrics, events, logs, and traces. Create and manage alert conditions, policies, and notification workflows. Build and update dashboards with custom widgets and charts. Search, tag, and manage monitored entities such as applications, hosts, and services. Configure synthetic monitors to proactively detect issues. Ingest custom metrics, events, logs, and distributed tracing spans via dedicated APIs. Track deployments and change events. Manage workloads, user groups, roles, and API keys. Set up alert notifications to destinations like Slack, PagerDuty, Jira, email, and webhooks. +Query telemetry data using NRQL across metrics, events, logs, and traces. Create and manage alert policies and NRQL alert conditions, and list alert issues. Build and update dashboards with custom widgets and charts. Search, tag, and manage monitored entities such as applications, hosts, and services. Configure synthetic monitors to proactively detect issues. Ingest custom metrics, events, logs, and distributed tracing spans via dedicated APIs. Track deployments and change markers. ## Tools @@ -16,6 +16,14 @@ Send custom telemetry data to New Relic. Supports ingesting **metrics**, **event Create, update, or delete NRQL-based alert conditions. Alert conditions define thresholds that trigger incidents. Supports both **static** (fixed threshold) and **baseline** (anomaly detection) condition types. +### List Alert Issues + +List and filter New Relic alert issues for the configured account. Use this to inspect active, deactivated, and closed issue state from incident intelligence. + +### Manage Alert Policy + +List, get, create, update, or delete New Relic alert policies. Policies group alert conditions and define how New Relic opens incidents. + ### Manage Dashboard Create, read, update, or delete New Relic dashboards. Dashboards consist of pages containing widgets (charts, tables, billboards, markdown), each driven by NRQL queries. @@ -26,7 +34,7 @@ Add, replace, or delete tags on a New Relic entity. Tags help organize and filte ### Manage Synthetic Monitor -Create or delete synthetic monitors. Synthetics simulate user interactions or API calls to proactively detect availability and performance issues. Supports ping monitors, simple browser monitors, scripted browser tests, and scripted API tests. +Create, update, or delete synthetic monitors. Synthetics simulate user interactions or API calls to proactively detect availability and performance issues. Supports ping monitors, simple browser monitors, scripted browser tests, and scripted API tests. ### Run NRQL Query diff --git a/integrations/new-relic/package.json b/integrations/new-relic/package.json index b09f0db22a..152f249499 100644 --- a/integrations/new-relic/package.json +++ b/integrations/new-relic/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.7" } diff --git a/integrations/new-relic/slate.json b/integrations/new-relic/slate.json index a77679fd23..f237261b0a 100644 --- a/integrations/new-relic/slate.json +++ b/integrations/new-relic/slate.json @@ -1,18 +1,17 @@ { "name": "@metorial/new-relic", - "description": "Query telemetry data using NRQL across metrics, events, logs, and traces. Create and manage alert conditions, policies, and notification workflows. Build and update dashboards with custom widgets and charts. Search, tag, and manage monitored entities such as applications, hosts, and services. Configure synthetic monitors to proactively detect issues. Ingest custom metrics, events, logs, and distributed tracing spans via dedicated APIs. Track deployments and change events. Manage workloads, user groups, roles, and API keys. Set up alert notifications to destinations like Slack, PagerDuty, Jira, email, and webhooks.", + "description": "Query telemetry data using NRQL across metrics, events, logs, and traces. Create and manage alert policies and NRQL alert conditions, and list alert issues. Build and update dashboards with custom widgets and charts. Search, tag, and manage monitored entities such as applications, hosts, and services. Configure synthetic monitors to proactively detect issues. Ingest custom metrics, events, logs, and distributed tracing spans via dedicated APIs. Track deployments and change markers.", "categories": ["apis-and-http-requests"], "skills": [ "query telemetry data", + "manage alert policies", "manage alert conditions", + "list alert issues", "create and update dashboards", "search and tag entities", "ingest custom metrics and logs", "configure synthetic monitors", - "track deployments and changes", - "manage notification workflows", - "manage user groups and roles", - "configure workloads" + "track deployments and changes" ], "logoUrl": "https://provider-logos.metorial-cdn.com/new-relic.png" } diff --git a/integrations/new-relic/src/auth.ts b/integrations/new-relic/src/auth.ts index 051f0da450..d00ee13da2 100644 --- a/integrations/new-relic/src/auth.ts +++ b/integrations/new-relic/src/auth.ts @@ -1,5 +1,6 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { newRelicApiError, newRelicGraphqlErrors } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -48,9 +49,18 @@ export let auth = SlateAuth.create() } }); - let response = await http.post('/graphql', { - query: `{ actor { user { email name id } } }` - }); + let response: any; + try { + response = await http.post('/graphql', { + query: `{ actor { user { email name id } } }` + }); + } catch (error) { + throw newRelicApiError(error, 'profile lookup'); + } + + if (response.data?.errors?.length) { + throw newRelicGraphqlErrors('profile lookup', response.data.errors); + } let user = response.data?.data?.actor?.user; diff --git a/integrations/new-relic/src/index.ts b/integrations/new-relic/src/index.ts index 56cad41cd2..7662d65617 100644 --- a/integrations/new-relic/src/index.ts +++ b/integrations/new-relic/src/index.ts @@ -3,7 +3,9 @@ import { spec } from './spec'; import { createChangeTrackingMarker, ingestData, + listAlertIssues, manageAlertCondition, + manageAlertPolicy, manageDashboard, manageEntityTags, manageSyntheticMonitor, @@ -17,6 +19,8 @@ export let provider = Slate.create({ tools: [ runNrqlQuery, searchEntities, + manageAlertPolicy, + listAlertIssues, manageAlertCondition, manageDashboard, manageSyntheticMonitor, diff --git a/integrations/new-relic/src/lib/client.ts b/integrations/new-relic/src/lib/client.ts index fbd0342b3a..2805ce3af8 100644 --- a/integrations/new-relic/src/lib/client.ts +++ b/integrations/new-relic/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { newRelicApiError, newRelicGraphqlErrors } from './errors'; export type Region = 'us' | 'eu'; @@ -32,6 +33,25 @@ export interface ClientConfig { licenseKey?: string; } +let assertNoPayloadErrors = (operation: string, errors?: unknown[] | null) => { + if (errors?.length) { + throw newRelicGraphqlErrors(operation, errors); + } +}; + +let defaultRuntime = (monitorType: string) => + monitorType === 'SCRIPT_API' + ? { + runtimeType: 'NODE_API', + runtimeTypeVersion: '22.20.0', + scriptLanguage: 'JAVASCRIPT' + } + : { + runtimeType: 'CHROME_BROWSER', + runtimeTypeVersion: 'LATEST', + scriptLanguage: 'JAVASCRIPT' + }; + export class NerdGraphClient { private http: ReturnType; private accountId: string; @@ -48,18 +68,20 @@ export class NerdGraphClient { } async query(graphqlQuery: string, variables?: Record): Promise { - let response = await this.http.post('', { - query: graphqlQuery, - variables: variables || {} - }); + try { + let response = await this.http.post('', { + query: graphqlQuery, + variables: variables || {} + }); + + if (response.data?.errors?.length) { + throw newRelicGraphqlErrors('NerdGraph request', response.data.errors); + } - if (response.data?.errors?.length) { - throw new Error( - `NerdGraph error: ${response.data.errors.map((e: any) => e.message).join(', ')}` - ); + return response.data?.data; + } catch (error) { + throw newRelicApiError(error, 'NerdGraph request'); } - - return response.data?.data; } async runNrql(nrql: string, timeout?: number): Promise { @@ -160,9 +182,7 @@ export class NerdGraphClient { ); let result = data?.taggingAddTagsToEntity; - if (result?.errors?.length) { - throw new Error(`Tag error: ${result.errors.map((e: any) => e.message).join(', ')}`); - } + assertNoPayloadErrors('tag add', result?.errors); return result; } @@ -177,11 +197,7 @@ export class NerdGraphClient { ); let result = data?.taggingDeleteTagFromEntity; - if (result?.errors?.length) { - throw new Error( - `Tag deletion error: ${result.errors.map((e: any) => e.message).join(', ')}` - ); - } + assertNoPayloadErrors('tag deletion', result?.errors); return result; } @@ -199,14 +215,135 @@ export class NerdGraphClient { ); let result = data?.taggingReplaceTagsOnEntity; - if (result?.errors?.length) { - throw new Error( - `Tag replace error: ${result.errors.map((e: any) => e.message).join(', ')}` - ); - } + assertNoPayloadErrors('tag replace', result?.errors); return result; } + async listAlertPolicies(params?: { + cursor?: string; + ids?: string[]; + name?: string; + nameLike?: string; + }): Promise { + let searchCriteria: Record = {}; + if (params?.ids?.length) searchCriteria.ids = params.ids; + if (params?.name) searchCriteria.name = params.name; + if (params?.nameLike) searchCriteria.nameLike = params.nameLike; + + let data = await this.query( + `query($accountId: Int!, $cursor: String, $searchCriteria: AlertsPoliciesSearchCriteriaInput) { + actor { + account(id: $accountId) { + alerts { + policiesSearch(cursor: $cursor, searchCriteria: $searchCriteria) { + policies { + id + name + incidentPreference + } + nextCursor + totalCount + } + } + } + } + }`, + { + accountId: Number.parseInt(this.accountId, 10), + cursor: params?.cursor, + searchCriteria: Object.keys(searchCriteria).length > 0 ? searchCriteria : undefined + } + ); + + return data?.actor?.account?.alerts?.policiesSearch; + } + + async getAlertPolicy(policyId: string): Promise { + let data = await this.query( + `query($accountId: Int!, $id: ID!) { + actor { + account(id: $accountId) { + alerts { + policy(id: $id) { + id + name + incidentPreference + } + } + } + } + }`, + { accountId: Number.parseInt(this.accountId, 10), id: policyId } + ); + + return data?.actor?.account?.alerts?.policy; + } + + async createAlertPolicy(params: { name: string; incidentPreference: string }): Promise { + let data = await this.query( + `mutation($accountId: Int!, $policy: AlertsPolicyInput!) { + alertsPolicyCreate(accountId: $accountId, policy: $policy) { + id + name + incidentPreference + } + }`, + { + accountId: Number.parseInt(this.accountId, 10), + policy: { + name: params.name, + incidentPreference: params.incidentPreference + } + } + ); + + return data?.alertsPolicyCreate; + } + + async updateAlertPolicy( + policyId: string, + params: { + name?: string; + incidentPreference?: string; + } + ): Promise { + let policy: Record = {}; + if (params.name !== undefined) policy.name = params.name; + if (params.incidentPreference !== undefined) { + policy.incidentPreference = params.incidentPreference; + } + + let data = await this.query( + `mutation($accountId: Int!, $id: ID!, $policy: AlertsPolicyUpdateInput!) { + alertsPolicyUpdate(accountId: $accountId, id: $id, policy: $policy) { + id + name + incidentPreference + } + }`, + { + accountId: Number.parseInt(this.accountId, 10), + id: policyId, + policy + } + ); + + return data?.alertsPolicyUpdate; + } + + async deleteAlertPolicy(policyId: string): Promise { + let data = await this.query( + `mutation($accountId: Int!, $id: ID!) { + alertsPolicyDelete(accountId: $accountId, id: $id) { + id + } + }`, + { accountId: Number.parseInt(this.accountId, 10), id: policyId } + ); + + return data?.alertsPolicyDelete; + } + async createNrqlAlertCondition( policyId: string, params: { @@ -238,6 +375,8 @@ export class NerdGraphClient { expirationDuration?: number; openViolationOnExpiration?: boolean; }; + baselineDirection?: string; + violationTimeLimitSeconds?: number; description?: string; } ): Promise { @@ -259,9 +398,14 @@ export class NerdGraphClient { aggregationMethod: 'EVENT_FLOW', aggregationDelay: 120 }, - description: params.description || '' + description: params.description || '', + violationTimeLimitSeconds: params.violationTimeLimitSeconds || 86400 }; + if (params.type === 'BASELINE') { + conditionInput.baselineDirection = params.baselineDirection || 'UPPER_ONLY'; + } + if (params.expiration) { conditionInput.expiration = params.expiration; } @@ -309,6 +453,8 @@ export class NerdGraphClient { operator: string; thresholdOccurrences: string; }; + baselineDirection?: string; + violationTimeLimitSeconds?: number; description?: string; } ): Promise { @@ -317,6 +463,12 @@ export class NerdGraphClient { if (params.nrql !== undefined) conditionInput.nrql = { query: params.nrql }; if (params.enabled !== undefined) conditionInput.enabled = params.enabled; if (params.description !== undefined) conditionInput.description = params.description; + if (params.violationTimeLimitSeconds !== undefined) { + conditionInput.violationTimeLimitSeconds = params.violationTimeLimitSeconds; + } + if (params.type === 'BASELINE' && params.baselineDirection !== undefined) { + conditionInput.baselineDirection = params.baselineDirection; + } let terms: any[] = []; if (params.critical) terms.push({ ...params.critical, priority: 'CRITICAL' }); @@ -405,11 +557,7 @@ export class NerdGraphClient { ); let result = data?.dashboardCreate; - if (result?.errors?.length) { - throw new Error( - `Dashboard create error: ${result.errors.map((e: any) => e.description).join(', ')}` - ); - } + assertNoPayloadErrors('dashboard create', result?.errors); return result?.entityResult; } @@ -462,11 +610,7 @@ export class NerdGraphClient { ); let result = data?.dashboardUpdate; - if (result?.errors?.length) { - throw new Error( - `Dashboard update error: ${result.errors.map((e: any) => e.description).join(', ')}` - ); - } + assertNoPayloadErrors('dashboard update', result?.errors); return result?.entityResult; } @@ -482,11 +626,7 @@ export class NerdGraphClient { ); let result = data?.dashboardDelete; - if (result?.errors?.length) { - throw new Error( - `Dashboard delete error: ${result.errors.map((e: any) => e.description).join(', ')}` - ); - } + assertNoPayloadErrors('dashboard delete', result?.errors); return result; } @@ -524,10 +664,38 @@ export class NerdGraphClient { status: string; locations: { public: string[] }; script?: string; + runtimeTypeVersion?: string; + browsers?: string[]; + devices?: string[]; + apdexTarget?: number; + advancedOptions?: Record; }): Promise { let monitorType = params.type.toUpperCase(); + let monitorInput: Record = { + name: params.name, + period: params.period, + status: params.status, + locations: params.locations + }; + if (params.apdexTarget !== undefined) monitorInput.apdexTarget = params.apdexTarget; + if (params.advancedOptions !== undefined) + monitorInput.advancedOptions = params.advancedOptions; + if (monitorType === 'SIMPLE_BROWSER') { + monitorInput = { + ...monitorInput, + uri: params.uri, + browsers: params.browsers || ['CHROME'], + devices: params.devices || ['DESKTOP'], + runtime: { + ...defaultRuntime(monitorType), + ...(params.runtimeTypeVersion + ? { runtimeTypeVersion: params.runtimeTypeVersion } + : {}) + } + }; + let data = await this.query( `mutation($accountId: Int!, $monitor: SyntheticsCreateSimpleBrowserMonitorInput!) { syntheticsCreateSimpleBrowserMonitor(accountId: $accountId, monitor: $monitor) { @@ -537,21 +705,11 @@ export class NerdGraphClient { }`, { accountId: Number.parseInt(this.accountId, 10), - monitor: { - name: params.name, - uri: params.uri, - period: params.period, - status: params.status, - locations: params.locations - } + monitor: monitorInput } ); let result = data?.syntheticsCreateSimpleBrowserMonitor; - if (result?.errors?.length) { - throw new Error( - `Synthetics error: ${result.errors.map((e: any) => e.description).join(', ')}` - ); - } + assertNoPayloadErrors('synthetic monitor create', result?.errors); return result?.monitor; } @@ -564,6 +722,23 @@ export class NerdGraphClient { monitorType === 'SCRIPT_BROWSER' ? 'SyntheticsCreateScriptBrowserMonitorInput' : 'SyntheticsCreateScriptApiMonitorInput'; + monitorInput = { + ...monitorInput, + script: params.script, + runtime: { + ...defaultRuntime(monitorType), + ...(params.runtimeTypeVersion + ? { runtimeTypeVersion: params.runtimeTypeVersion } + : {}) + }, + ...(monitorType === 'SCRIPT_BROWSER' + ? { + browsers: params.browsers || ['CHROME'], + devices: params.devices || ['DESKTOP'] + } + : {}), + ...(params.uri ? { uri: params.uri } : {}) + }; let data = await this.query( `mutation($accountId: Int!, $monitor: ${inputType}!) { @@ -574,26 +749,20 @@ export class NerdGraphClient { }`, { accountId: Number.parseInt(this.accountId, 10), - monitor: { - name: params.name, - period: params.period, - status: params.status, - locations: params.locations, - script: params.script, - ...(params.uri ? { uri: params.uri } : {}) - } + monitor: monitorInput } ); let result = data?.[mutationName]; - if (result?.errors?.length) { - throw new Error( - `Synthetics error: ${result.errors.map((e: any) => e.description).join(', ')}` - ); - } + assertNoPayloadErrors('synthetic monitor create', result?.errors); return result?.monitor; } // Default: simple ping monitor + monitorInput = { + ...monitorInput, + uri: params.uri + }; + let data = await this.query( `mutation($accountId: Int!, $monitor: SyntheticsCreateSimpleMonitorInput!) { syntheticsCreateSimpleMonitor(accountId: $accountId, monitor: $monitor) { @@ -603,24 +772,87 @@ export class NerdGraphClient { }`, { accountId: Number.parseInt(this.accountId, 10), - monitor: { - name: params.name, - uri: params.uri, - period: params.period, - status: params.status, - locations: params.locations - } + monitor: monitorInput } ); let result = data?.syntheticsCreateSimpleMonitor; - if (result?.errors?.length) { - throw new Error( - `Synthetics error: ${result.errors.map((e: any) => e.description).join(', ')}` - ); - } + assertNoPayloadErrors('synthetic monitor create', result?.errors); return result?.monitor; } + async updateSyntheticMonitor( + monitorGuid: string, + params: { + type: string; + name?: string; + uri?: string; + period?: string; + status?: string; + locations?: { public: string[] }; + script?: string; + runtimeTypeVersion?: string; + browsers?: string[]; + devices?: string[]; + apdexTarget?: number; + advancedOptions?: Record; + } + ): Promise { + let monitorType = params.type.toUpperCase(); + let monitorInput: Record = {}; + if (params.name !== undefined) monitorInput.name = params.name; + if (params.uri !== undefined) monitorInput.uri = params.uri; + if (params.period !== undefined) monitorInput.period = params.period; + if (params.status !== undefined) monitorInput.status = params.status; + if (params.locations !== undefined) monitorInput.locations = params.locations; + if (params.script !== undefined) monitorInput.script = params.script; + if (params.apdexTarget !== undefined) monitorInput.apdexTarget = params.apdexTarget; + if (params.advancedOptions !== undefined) + monitorInput.advancedOptions = params.advancedOptions; + + if (params.runtimeTypeVersion !== undefined) { + monitorInput.runtime = { + ...defaultRuntime(monitorType), + runtimeTypeVersion: params.runtimeTypeVersion + }; + } + + if (monitorType === 'SIMPLE_BROWSER' || monitorType === 'SCRIPT_BROWSER') { + if (params.browsers !== undefined) monitorInput.browsers = params.browsers; + if (params.devices !== undefined) monitorInput.devices = params.devices; + } + + let mutationName = + monitorType === 'SIMPLE_BROWSER' + ? 'syntheticsUpdateSimpleBrowserMonitor' + : monitorType === 'SCRIPT_BROWSER' + ? 'syntheticsUpdateScriptBrowserMonitor' + : monitorType === 'SCRIPT_API' + ? 'syntheticsUpdateScriptApiMonitor' + : 'syntheticsUpdateSimpleMonitor'; + let inputType = + monitorType === 'SIMPLE_BROWSER' + ? 'SyntheticsUpdateSimpleBrowserMonitorInput' + : monitorType === 'SCRIPT_BROWSER' + ? 'SyntheticsUpdateScriptBrowserMonitorInput' + : monitorType === 'SCRIPT_API' + ? 'SyntheticsUpdateScriptApiMonitorInput' + : 'SyntheticsUpdateSimpleMonitorInput'; + + let data = await this.query( + `mutation($guid: EntityGuid!, $monitor: ${inputType}!) { + ${mutationName}(guid: $guid, monitor: $monitor) { + monitor { guid name status period uri locations { public } } + errors { description type } + } + }`, + { guid: monitorGuid, monitor: monitorInput } + ); + + let result = data?.[mutationName]; + assertNoPayloadErrors('synthetic monitor update', result?.errors); + return result?.monitor || { guid: monitorGuid }; + } + async deleteSyntheticMonitor(monitorGuid: string): Promise { let data = await this.query( `mutation($guid: EntityGuid!) { @@ -672,12 +904,21 @@ export class NerdGraphClient { } async listAlertIssues(params?: { - filter?: { states?: string[]; priorities?: string[] }; + filter?: { + states?: string[]; + priorities?: string[]; + entityGuids?: string[]; + entityTypes?: string[]; + issueIds?: string[]; + }; cursor?: string; }): Promise { let filterInput: any = {}; if (params?.filter?.states) filterInput.states = params.filter.states; - if (params?.filter?.priorities) filterInput.priorities = params.filter.priorities; + if (params?.filter?.priorities) filterInput.priority = params.filter.priorities; + if (params?.filter?.entityGuids) filterInput.entityGuids = params.filter.entityGuids; + if (params?.filter?.entityTypes) filterInput.entityTypes = params.filter.entityTypes; + if (params?.filter?.issueIds) filterInput.ids = params.filter.issueIds; let data = await this.query( `query($accountId: Int!, $cursor: String, $filter: AiIssuesFilterInput) { @@ -690,15 +931,16 @@ export class NerdGraphClient { title state priority + createdAt activatedAt closedAt acknowledgedAt updatedAt entityGuids entityNames - conditionName - policyName + entityTypes sources + totalIncidents } nextCursor } @@ -757,8 +999,12 @@ export class IngestClient { } ]; - let response = await http.post('', payload); - return response.data; + try { + let response = await http.post('', payload); + return response.data; + } catch (error) { + throw newRelicApiError(error, 'metric ingest'); + } } async ingestEvents(events: Record[]): Promise { @@ -770,8 +1016,12 @@ export class IngestClient { } }); - let response = await http.post('', events); - return response.data; + try { + let response = await http.post('', events); + return response.data; + } catch (error) { + throw newRelicApiError(error, 'event ingest'); + } } async ingestLogs( @@ -799,8 +1049,12 @@ export class IngestClient { } ]; - let response = await http.post('', payload); - return response.data; + try { + let response = await http.post('', payload); + return response.data; + } catch (error) { + throw newRelicApiError(error, 'log ingest'); + } } async ingestTraces( @@ -842,7 +1096,11 @@ export class IngestClient { } ]; - let response = await http.post('', payload); - return response.data; + try { + let response = await http.post('', payload); + return response.data; + } catch (error) { + throw newRelicApiError(error, 'trace ingest'); + } } } diff --git a/integrations/new-relic/src/lib/errors.ts b/integrations/new-relic/src/lib/errors.ts new file mode 100644 index 0000000000..c4b45f8cba --- /dev/null +++ b/integrations/new-relic/src/lib/errors.ts @@ -0,0 +1,39 @@ +import { buildApiServiceError, createApiServiceError, extractApiErrorMessage } from 'slates'; + +let newRelicMessageOptions = { + detailKeys: ['message', 'description', 'details', 'error_description', 'error', 'type'], + includeNumbers: false, + nestedKeys: ['errors'] +}; + +export let newRelicApiError = (error: unknown, operation = 'request') => + buildApiServiceError(error, { + providerLabel: 'New Relic', + operation, + reason: 'new_relic_api_error', + ...newRelicMessageOptions, + extractMessage: (input, helpers) => { + let response = helpers.getResponse(input); + return extractApiErrorMessage(input, { + ...newRelicMessageOptions, + response: { + ...response, + data: response?.data ?? input + } + }); + }, + formatMessage: ({ operation: apiOperation, statusLabel, message }) => + `New Relic ${apiOperation} failed: ${statusLabel}${message}` + }); + +export let newRelicGraphqlErrors = (operation: string, errors: unknown[]) => { + let message = extractApiErrorMessage(errors, { + ...newRelicMessageOptions, + response: { + data: errors + } + }); + return createApiServiceError(`New Relic ${operation} failed: ${message}`, { + reason: 'new_relic_graphql_error' + }); +}; diff --git a/integrations/new-relic/src/tools.schema.test.ts b/integrations/new-relic/src/tools.schema.test.ts new file mode 100644 index 0000000000..d12b47a2d9 --- /dev/null +++ b/integrations/new-relic/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('New Relic tool input schemas', provider.actions); diff --git a/integrations/new-relic/src/tools/index.ts b/integrations/new-relic/src/tools/index.ts index fa53e025a6..a2a0a5204b 100644 --- a/integrations/new-relic/src/tools/index.ts +++ b/integrations/new-relic/src/tools/index.ts @@ -1,6 +1,8 @@ export * from './create-change-tracking-marker'; export * from './ingest-data'; +export * from './list-alert-issues'; export * from './manage-alert-condition'; +export * from './manage-alert-policy'; export * from './manage-dashboard'; export * from './manage-entity-tags'; export * from './manage-synthetic-monitor'; diff --git a/integrations/new-relic/src/tools/ingest-data.ts b/integrations/new-relic/src/tools/ingest-data.ts index fc7ba93cdb..54472ff6cb 100644 --- a/integrations/new-relic/src/tools/ingest-data.ts +++ b/integrations/new-relic/src/tools/ingest-data.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { IngestClient } from '../lib/client'; import { spec } from '../spec'; @@ -91,7 +91,7 @@ Requires a License Key to be configured in authentication.`, ) .handleInvocation(async ctx => { if (!ctx.auth.licenseKey) { - throw new Error( + throw createApiServiceError( 'License Key is required for data ingestion. Please configure it in authentication settings.' ); } @@ -106,7 +106,7 @@ Requires a License Key to be configured in authentication.`, if (dataType === 'metrics') { if (!ctx.input.metrics?.length) - throw new Error('metrics array is required when dataType is "metrics"'); + throw createApiServiceError('metrics array is required when dataType is "metrics"'); ctx.progress(`Ingesting ${ctx.input.metrics.length} metric(s)...`); let result = await ingestClient.ingestMetrics(ctx.input.metrics); return { @@ -121,7 +121,7 @@ Requires a License Key to be configured in authentication.`, if (dataType === 'events') { if (!ctx.input.events?.length) - throw new Error('events array is required when dataType is "events"'); + throw createApiServiceError('events array is required when dataType is "events"'); ctx.progress(`Ingesting ${ctx.input.events.length} event(s)...`); let result = await ingestClient.ingestEvents(ctx.input.events); return { @@ -136,7 +136,7 @@ Requires a License Key to be configured in authentication.`, if (dataType === 'logs') { if (!ctx.input.logs?.length) - throw new Error('logs array is required when dataType is "logs"'); + throw createApiServiceError('logs array is required when dataType is "logs"'); ctx.progress(`Ingesting ${ctx.input.logs.length} log(s)...`); let result = await ingestClient.ingestLogs(ctx.input.logs); return { @@ -151,7 +151,7 @@ Requires a License Key to be configured in authentication.`, // traces if (!ctx.input.traces?.length) - throw new Error('traces array is required when dataType is "traces"'); + throw createApiServiceError('traces array is required when dataType is "traces"'); ctx.progress(`Ingesting ${ctx.input.traces.length} span(s)...`); let result = await ingestClient.ingestTraces(ctx.input.traces); return { diff --git a/integrations/new-relic/src/tools/list-alert-issues.ts b/integrations/new-relic/src/tools/list-alert-issues.ts new file mode 100644 index 0000000000..95cc2a19af --- /dev/null +++ b/integrations/new-relic/src/tools/list-alert-issues.ts @@ -0,0 +1,123 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { NerdGraphClient } from '../lib/client'; +import { spec } from '../spec'; + +let alertIssueSchema = z.object({ + issueId: z.string().describe('Alert issue ID'), + title: z.string().optional().describe('Primary issue title'), + titles: z.array(z.string()).optional().describe('All issue titles returned by New Relic'), + state: z + .string() + .optional() + .describe('Issue state, such as CREATED, ACTIVATED, DEACTIVATED, or CLOSED'), + priority: z + .string() + .optional() + .describe('Issue priority, such as LOW, MEDIUM, HIGH, or CRITICAL'), + createdAt: z.number().optional().describe('Issue creation time in epoch milliseconds'), + activatedAt: z.number().optional().describe('Issue activation time in epoch milliseconds'), + closedAt: z.number().optional().describe('Issue close time in epoch milliseconds'), + acknowledgedAt: z + .number() + .optional() + .describe('Issue acknowledgement time in epoch milliseconds'), + updatedAt: z.number().optional().describe('Issue update time in epoch milliseconds'), + entityGuids: z.array(z.string()).optional().describe('Related entity GUIDs'), + entityNames: z.array(z.string()).optional().describe('Related entity names'), + entityTypes: z.array(z.string()).optional().describe('Related entity types'), + sources: z.array(z.string()).optional().describe('Issue sources'), + totalIncidents: z + .number() + .optional() + .describe('Number of incidents correlated into the issue') +}); + +let toStringArray = (value: unknown) => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + +export let listAlertIssues = SlateTool.create(spec, { + name: 'List Alert Issues', + key: 'list_alert_issues', + description: + 'List and filter New Relic alert issues for the configured account. Use this to inspect active, deactivated, and closed issue state from incident intelligence.', + instructions: [ + 'Leave filters empty to list recent issues.', + 'Use `states`, `priorities`, `entityGuids`, `entityTypes`, or `issueIds` to narrow results.', + 'Use `nextCursor` from the output as `cursor` to fetch another page.' + ], + tags: { + readOnly: true + } +}) + .input( + z.object({ + states: z + .array(z.enum(['CREATED', 'ACTIVATED', 'DEACTIVATED', 'CLOSED'])) + .optional() + .describe('Issue states to include'), + priorities: z + .array(z.enum(['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'])) + .optional() + .describe('Issue priorities to include'), + entityGuids: z.array(z.string()).optional().describe('Entity GUIDs to filter issues by'), + entityTypes: z.array(z.string()).optional().describe('Entity types to filter issues by'), + issueIds: z.array(z.string()).optional().describe('Specific issue IDs to retrieve'), + cursor: z.string().optional().describe('Pagination cursor') + }) + ) + .output( + z.object({ + issues: z.array(alertIssueSchema).describe('Alert issues returned by New Relic'), + nextCursor: z.string().optional().describe('Cursor for the next page') + }) + ) + .handleInvocation(async ctx => { + let client = new NerdGraphClient({ + token: ctx.auth.token, + region: ctx.config.region, + accountId: ctx.config.accountId + }); + + ctx.progress('Listing alert issues...'); + let result = await client.listAlertIssues({ + cursor: ctx.input.cursor, + filter: { + states: ctx.input.states, + priorities: ctx.input.priorities, + entityGuids: ctx.input.entityGuids, + entityTypes: ctx.input.entityTypes, + issueIds: ctx.input.issueIds + } + }); + + let issues = (result?.issues || []).map((issue: any) => { + let titles = toStringArray(issue.title); + return { + issueId: issue.issueId?.toString(), + title: titles[0], + titles, + state: issue.state, + priority: issue.priority, + createdAt: issue.createdAt, + activatedAt: issue.activatedAt, + closedAt: issue.closedAt, + acknowledgedAt: issue.acknowledgedAt, + updatedAt: issue.updatedAt, + entityGuids: issue.entityGuids, + entityNames: issue.entityNames, + entityTypes: issue.entityTypes, + sources: issue.sources, + totalIncidents: issue.totalIncidents + }; + }); + + return { + output: { + issues, + nextCursor: result?.nextCursor || undefined + }, + message: `Found **${issues.length}** alert issue(s).` + }; + }) + .build(); diff --git a/integrations/new-relic/src/tools/manage-alert-condition.ts b/integrations/new-relic/src/tools/manage-alert-condition.ts index 26942d0846..0bac1c0fb4 100644 --- a/integrations/new-relic/src/tools/manage-alert-condition.ts +++ b/integrations/new-relic/src/tools/manage-alert-condition.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { NerdGraphClient } from '../lib/client'; import { spec } from '../spec'; @@ -63,6 +63,14 @@ Supports both **static** (fixed threshold) and **baseline** (anomaly detection) .optional() .default('STATIC') .describe('Condition type: STATIC (fixed threshold) or BASELINE (anomaly detection)'), + baselineDirection: z + .enum(['UPPER_ONLY', 'LOWER_ONLY', 'UPPER_AND_LOWER']) + .optional() + .describe('Baseline direction for BASELINE conditions'), + violationTimeLimitSeconds: z + .number() + .optional() + .describe('Maximum violation duration in seconds before automatic close'), critical: thresholdSchema.optional().describe('Critical threshold settings'), warning: thresholdSchema.optional().describe('Warning threshold settings'), description: z.string().optional().describe('Description for the alert condition'), @@ -115,7 +123,8 @@ Supports both **static** (fixed threshold) and **baseline** (anomaly detection) let { action } = ctx.input; if (action === 'delete') { - if (!ctx.input.conditionId) throw new Error('conditionId is required for delete action'); + if (!ctx.input.conditionId) + throw createApiServiceError('conditionId is required for delete action'); ctx.progress('Deleting alert condition...'); await client.deleteAlertCondition(ctx.input.conditionId); return { @@ -125,9 +134,13 @@ Supports both **static** (fixed threshold) and **baseline** (anomaly detection) } if (action === 'create') { - if (!ctx.input.policyId) throw new Error('policyId is required for create action'); - if (!ctx.input.name) throw new Error('name is required for create action'); - if (!ctx.input.nrql) throw new Error('nrql is required for create action'); + if (!ctx.input.policyId) + throw createApiServiceError('policyId is required for create action'); + if (!ctx.input.name) throw createApiServiceError('name is required for create action'); + if (!ctx.input.nrql) throw createApiServiceError('nrql is required for create action'); + if (!ctx.input.critical && !ctx.input.warning) { + throw createApiServiceError('At least one threshold is required for create action'); + } ctx.progress('Creating alert condition...'); let result = await client.createNrqlAlertCondition(ctx.input.policyId, { @@ -137,6 +150,8 @@ Supports both **static** (fixed threshold) and **baseline** (anomaly detection) type: ctx.input.conditionType || 'STATIC', critical: ctx.input.critical, warning: ctx.input.warning, + baselineDirection: ctx.input.baselineDirection, + violationTimeLimitSeconds: ctx.input.violationTimeLimitSeconds, signal: ctx.input.signal, expiration: ctx.input.expiration, description: ctx.input.description @@ -156,7 +171,8 @@ Supports both **static** (fixed threshold) and **baseline** (anomaly detection) } // update - if (!ctx.input.conditionId) throw new Error('conditionId is required for update action'); + if (!ctx.input.conditionId) + throw createApiServiceError('conditionId is required for update action'); ctx.progress('Updating alert condition...'); let result = await client.updateNrqlAlertCondition(ctx.input.conditionId, { @@ -166,6 +182,8 @@ Supports both **static** (fixed threshold) and **baseline** (anomaly detection) type: ctx.input.conditionType || 'STATIC', critical: ctx.input.critical, warning: ctx.input.warning, + baselineDirection: ctx.input.baselineDirection, + violationTimeLimitSeconds: ctx.input.violationTimeLimitSeconds, description: ctx.input.description }); diff --git a/integrations/new-relic/src/tools/manage-alert-policy.ts b/integrations/new-relic/src/tools/manage-alert-policy.ts new file mode 100644 index 0000000000..764e0dfa19 --- /dev/null +++ b/integrations/new-relic/src/tools/manage-alert-policy.ts @@ -0,0 +1,180 @@ +import { createApiServiceError, SlateTool } from 'slates'; +import { z } from 'zod'; +import { NerdGraphClient } from '../lib/client'; +import { spec } from '../spec'; + +let incidentPreferenceSchema = z + .enum(['PER_POLICY', 'PER_CONDITION', 'PER_CONDITION_AND_TARGET']) + .describe('How New Relic groups incidents opened by this policy'); + +let alertPolicySchema = z.object({ + policyId: z.string().describe('Alert policy ID'), + name: z.string().optional().describe('Alert policy name'), + incidentPreference: z.string().optional().describe('Incident grouping preference') +}); + +export let manageAlertPolicy = SlateTool.create(spec, { + name: 'Manage Alert Policy', + key: 'manage_alert_policy', + description: + 'List, get, create, update, or delete New Relic alert policies. Policies group alert conditions and define how New Relic opens incidents.', + instructions: [ + 'To list: provide `action: "list"` and optionally `name`, `nameLike`, `policyIds`, or `cursor`.', + 'To get: provide `action: "get"` and `policyId`.', + 'To create: provide `action: "create"`, `name`, and optionally `incidentPreference`.', + 'To update: provide `action: "update"`, `policyId`, and the fields to change.', + 'To delete: provide `action: "delete"` and `policyId`.' + ], + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z + .enum(['list', 'get', 'create', 'update', 'delete']) + .describe('Action to perform'), + policyId: z + .string() + .optional() + .describe('Alert policy ID for get/update/delete actions'), + policyIds: z + .array(z.string()) + .optional() + .describe('Policy IDs to filter when action is list'), + name: z + .string() + .optional() + .describe('Policy name for create/update, or exact name filter for list'), + nameLike: z + .string() + .optional() + .describe('Case-insensitive partial name filter for list'), + incidentPreference: incidentPreferenceSchema + .optional() + .describe('Incident grouping preference for create/update actions'), + cursor: z.string().optional().describe('Pagination cursor for list action') + }) + ) + .output( + z.object({ + policy: alertPolicySchema.optional().describe('Single policy for get/create/update'), + policies: z.array(alertPolicySchema).optional().describe('Policies returned by list'), + totalCount: z.number().optional().describe('Total matching policy count'), + nextCursor: z.string().optional().describe('Cursor for the next page'), + deletedPolicyId: z.string().optional().describe('Deleted policy ID') + }) + ) + .handleInvocation(async ctx => { + let client = new NerdGraphClient({ + token: ctx.auth.token, + region: ctx.config.region, + accountId: ctx.config.accountId + }); + + if (ctx.input.action === 'list') { + ctx.progress('Listing alert policies...'); + let result = await client.listAlertPolicies({ + cursor: ctx.input.cursor, + ids: ctx.input.policyIds, + name: ctx.input.name, + nameLike: ctx.input.nameLike + }); + let policies = (result?.policies || []).map((policy: any) => ({ + policyId: policy.id?.toString(), + name: policy.name, + incidentPreference: policy.incidentPreference + })); + + return { + output: { + policies, + totalCount: result?.totalCount, + nextCursor: result?.nextCursor || undefined + }, + message: `Found **${result?.totalCount ?? policies.length}** alert policy/policies. Returned **${policies.length}** in this page.` + }; + } + + if (ctx.input.action === 'get') { + if (!ctx.input.policyId) + throw createApiServiceError('policyId is required for get action'); + ctx.progress('Fetching alert policy...'); + let policy = await client.getAlertPolicy(ctx.input.policyId); + + if (!policy) { + return { + output: {}, + message: `No alert policy found with ID **${ctx.input.policyId}**.` + }; + } + + return { + output: { + policy: { + policyId: policy.id?.toString(), + name: policy.name, + incidentPreference: policy.incidentPreference + } + }, + message: `Alert policy **${policy.name}** retrieved successfully.` + }; + } + + if (ctx.input.action === 'delete') { + if (!ctx.input.policyId) + throw createApiServiceError('policyId is required for delete action'); + ctx.progress('Deleting alert policy...'); + let result = await client.deleteAlertPolicy(ctx.input.policyId); + let deletedPolicyId = result?.id?.toString() || ctx.input.policyId; + + return { + output: { deletedPolicyId }, + message: `Alert policy **${deletedPolicyId}** deleted successfully.` + }; + } + + if (ctx.input.action === 'create') { + if (!ctx.input.name) throw createApiServiceError('name is required for create action'); + ctx.progress('Creating alert policy...'); + let policy = await client.createAlertPolicy({ + name: ctx.input.name, + incidentPreference: ctx.input.incidentPreference || 'PER_POLICY' + }); + + return { + output: { + policy: { + policyId: policy?.id?.toString(), + name: policy?.name, + incidentPreference: policy?.incidentPreference + } + }, + message: `Alert policy **${policy?.name}** created successfully.` + }; + } + + if (!ctx.input.policyId) + throw createApiServiceError('policyId is required for update action'); + if (ctx.input.name === undefined && ctx.input.incidentPreference === undefined) { + throw createApiServiceError('Provide name or incidentPreference for update action'); + } + + ctx.progress('Updating alert policy...'); + let policy = await client.updateAlertPolicy(ctx.input.policyId, { + name: ctx.input.name, + incidentPreference: ctx.input.incidentPreference + }); + + return { + output: { + policy: { + policyId: policy?.id?.toString(), + name: policy?.name, + incidentPreference: policy?.incidentPreference + } + }, + message: `Alert policy **${policy?.name}** updated successfully.` + }; + }) + .build(); diff --git a/integrations/new-relic/src/tools/manage-dashboard.ts b/integrations/new-relic/src/tools/manage-dashboard.ts index 20fee58441..282f14fb6b 100644 --- a/integrations/new-relic/src/tools/manage-dashboard.ts +++ b/integrations/new-relic/src/tools/manage-dashboard.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { NerdGraphClient } from '../lib/client'; import { spec } from '../spec'; @@ -92,7 +92,7 @@ export let manageDashboard = SlateTool.create(spec, { if (action === 'get') { if (!ctx.input.dashboardGuid) - throw new Error('dashboardGuid is required for get action'); + throw createApiServiceError('dashboardGuid is required for get action'); ctx.progress('Fetching dashboard...'); let dashboard = await client.getDashboard(ctx.input.dashboardGuid); @@ -113,7 +113,7 @@ export let manageDashboard = SlateTool.create(spec, { if (action === 'delete') { if (!ctx.input.dashboardGuid) - throw new Error('dashboardGuid is required for delete action'); + throw createApiServiceError('dashboardGuid is required for delete action'); ctx.progress('Deleting dashboard...'); await client.deleteDashboard(ctx.input.dashboardGuid); return { @@ -123,9 +123,9 @@ export let manageDashboard = SlateTool.create(spec, { } if (action === 'create') { - if (!ctx.input.name) throw new Error('name is required for create action'); + if (!ctx.input.name) throw createApiServiceError('name is required for create action'); if (!ctx.input.pages?.length) - throw new Error('At least one page is required for create action'); + throw createApiServiceError('At least one page is required for create action'); ctx.progress('Creating dashboard...'); let result = await client.createDashboard({ @@ -152,7 +152,7 @@ export let manageDashboard = SlateTool.create(spec, { // update if (!ctx.input.dashboardGuid) - throw new Error('dashboardGuid is required for update action'); + throw createApiServiceError('dashboardGuid is required for update action'); ctx.progress('Updating dashboard...'); let result = await client.updateDashboard(ctx.input.dashboardGuid, { diff --git a/integrations/new-relic/src/tools/manage-entity-tags.ts b/integrations/new-relic/src/tools/manage-entity-tags.ts index 6f88e9270b..627cff76b8 100644 --- a/integrations/new-relic/src/tools/manage-entity-tags.ts +++ b/integrations/new-relic/src/tools/manage-entity-tags.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { NerdGraphClient } from '../lib/client'; import { spec } from '../spec'; @@ -51,7 +51,8 @@ export let manageEntityTags = SlateTool.create(spec, { let { action, entityGuid } = ctx.input; if (action === 'add') { - if (!ctx.input.tags?.length) throw new Error('tags are required for add action'); + if (!ctx.input.tags?.length) + throw createApiServiceError('tags are required for add action'); ctx.progress('Adding tags...'); await client.addEntityTags(entityGuid, ctx.input.tags); return { @@ -61,7 +62,8 @@ export let manageEntityTags = SlateTool.create(spec, { } if (action === 'replace') { - if (!ctx.input.tags?.length) throw new Error('tags are required for replace action'); + if (!ctx.input.tags?.length) + throw createApiServiceError('tags are required for replace action'); ctx.progress('Replacing tags...'); await client.replaceEntityTags(entityGuid, ctx.input.tags); return { @@ -71,7 +73,8 @@ export let manageEntityTags = SlateTool.create(spec, { } // delete - if (!ctx.input.tagKeys?.length) throw new Error('tagKeys are required for delete action'); + if (!ctx.input.tagKeys?.length) + throw createApiServiceError('tagKeys are required for delete action'); ctx.progress('Deleting tags...'); await client.deleteEntityTags(entityGuid, ctx.input.tagKeys); return { diff --git a/integrations/new-relic/src/tools/manage-synthetic-monitor.ts b/integrations/new-relic/src/tools/manage-synthetic-monitor.ts index 06eabaf603..316db71264 100644 --- a/integrations/new-relic/src/tools/manage-synthetic-monitor.ts +++ b/integrations/new-relic/src/tools/manage-synthetic-monitor.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { NerdGraphClient } from '../lib/client'; import { spec } from '../spec'; @@ -19,10 +19,11 @@ let monitorOutputSchema = z.object({ export let manageSyntheticMonitor = SlateTool.create(spec, { name: 'Manage Synthetic Monitor', key: 'manage_synthetic_monitor', - description: `Create or delete synthetic monitors. Synthetics simulate user interactions or API calls to proactively detect availability and performance issues. + description: `Create, update, or delete synthetic monitors. Synthetics simulate user interactions or API calls to proactively detect availability and performance issues. Supports ping monitors, simple browser monitors, scripted browser tests, and scripted API tests.`, instructions: [ 'To create: provide `action: "create"`, `name`, `monitorType`, `period`, `status`, and `locations`.', + 'To update: provide `action: "update"`, `monitorGuid`, `monitorType`, and the fields to change.', 'To delete: provide `action: "delete"` and the `monitorGuid`.', 'Monitor types: `SIMPLE` (ping), `SIMPLE_BROWSER`, `SCRIPT_BROWSER`, `SCRIPT_API`.', 'Periods: `EVERY_MINUTE`, `EVERY_5_MINUTES`, `EVERY_10_MINUTES`, `EVERY_15_MINUTES`, `EVERY_30_MINUTES`, `EVERY_HOUR`, `EVERY_6_HOURS`, `EVERY_12_HOURS`, `EVERY_DAY`.' @@ -33,7 +34,7 @@ Supports ping monitors, simple browser monitors, scripted browser tests, and scr }) .input( z.object({ - action: z.enum(['create', 'delete']).describe('Action to perform'), + action: z.enum(['create', 'update', 'delete']).describe('Action to perform'), monitorGuid: z.string().optional().describe('Monitor entity GUID (required for delete)'), name: z.string().optional().describe('Monitor name (required for create)'), monitorType: z @@ -70,7 +71,32 @@ Supports ping monitors, simple browser monitors, scripted browser tests, and scr script: z .string() .optional() - .describe('Script content for SCRIPT_BROWSER or SCRIPT_API monitors') + .describe('Script content for SCRIPT_BROWSER or SCRIPT_API monitors'), + runtimeTypeVersion: z + .string() + .optional() + .describe('Runtime version for browser/API monitor types, e.g. LATEST or 22.20.0'), + browsers: z + .array(z.enum(['CHROME', 'FIREFOX'])) + .optional() + .describe('Browser engines for SIMPLE_BROWSER or SCRIPT_BROWSER monitors'), + devices: z + .array( + z.enum([ + 'DESKTOP', + 'MOBILE_LANDSCAPE', + 'MOBILE_PORTRAIT', + 'TABLET_LANDSCAPE', + 'TABLET_PORTRAIT' + ]) + ) + .optional() + .describe('Device profiles for SIMPLE_BROWSER or SCRIPT_BROWSER monitors'), + apdexTarget: z.number().optional().describe('Monitor Apdex target in seconds'), + advancedOptions: z + .record(z.string(), z.any()) + .optional() + .describe('Advanced synthetics options supported by the selected monitor type') }) ) .output(monitorOutputSchema) @@ -84,7 +110,8 @@ Supports ping monitors, simple browser monitors, scripted browser tests, and scr let { action } = ctx.input; if (action === 'delete') { - if (!ctx.input.monitorGuid) throw new Error('monitorGuid is required for delete action'); + if (!ctx.input.monitorGuid) + throw createApiServiceError('monitorGuid is required for delete action'); ctx.progress('Deleting synthetic monitor...'); await client.deleteSyntheticMonitor(ctx.input.monitorGuid); return { @@ -93,12 +120,61 @@ Supports ping monitors, simple browser monitors, scripted browser tests, and scr }; } - // create - if (!ctx.input.name) throw new Error('name is required for create action'); - if (!ctx.input.monitorType) throw new Error('monitorType is required for create action'); - if (!ctx.input.period) throw new Error('period is required for create action'); + if (action === 'update') { + if (!ctx.input.monitorGuid) + throw createApiServiceError('monitorGuid is required for update action'); + if (!ctx.input.monitorType) + throw createApiServiceError('monitorType is required for update action'); + + ctx.progress('Updating synthetic monitor...'); + let result = await client.updateSyntheticMonitor(ctx.input.monitorGuid, { + type: ctx.input.monitorType, + name: ctx.input.name, + uri: ctx.input.uri, + period: ctx.input.period, + status: ctx.input.status, + locations: ctx.input.locations ? { public: ctx.input.locations } : undefined, + script: ctx.input.script, + runtimeTypeVersion: ctx.input.runtimeTypeVersion, + browsers: ctx.input.browsers, + devices: ctx.input.devices, + apdexTarget: ctx.input.apdexTarget, + advancedOptions: ctx.input.advancedOptions + }); + + return { + output: { + monitorGuid: result?.guid || ctx.input.monitorGuid, + name: result?.name, + status: result?.status, + period: result?.period, + uri: result?.uri, + locations: result?.locations?.public + }, + message: `Synthetic monitor **${result?.name || ctx.input.monitorGuid}** updated successfully.` + }; + } + + if (!ctx.input.name) throw createApiServiceError('name is required for create action'); + if (!ctx.input.monitorType) + throw createApiServiceError('monitorType is required for create action'); + if (!ctx.input.period) throw createApiServiceError('period is required for create action'); if (!ctx.input.locations?.length) - throw new Error('At least one location is required for create action'); + throw createApiServiceError('At least one location is required for create action'); + if ( + (ctx.input.monitorType === 'SIMPLE' || ctx.input.monitorType === 'SIMPLE_BROWSER') && + !ctx.input.uri + ) { + throw createApiServiceError('uri is required for SIMPLE and SIMPLE_BROWSER monitors'); + } + if ( + (ctx.input.monitorType === 'SCRIPT_BROWSER' || ctx.input.monitorType === 'SCRIPT_API') && + !ctx.input.script + ) { + throw createApiServiceError( + 'script is required for SCRIPT_BROWSER and SCRIPT_API monitors' + ); + } ctx.progress('Creating synthetic monitor...'); let result = await client.createSyntheticMonitor({ @@ -108,7 +184,12 @@ Supports ping monitors, simple browser monitors, scripted browser tests, and scr period: ctx.input.period, status: ctx.input.status || 'ENABLED', locations: { public: ctx.input.locations }, - script: ctx.input.script + script: ctx.input.script, + runtimeTypeVersion: ctx.input.runtimeTypeVersion, + browsers: ctx.input.browsers, + devices: ctx.input.devices, + apdexTarget: ctx.input.apdexTarget, + advancedOptions: ctx.input.advancedOptions }); return { diff --git a/integrations/new-relic/src/triggers/alert-issues.ts b/integrations/new-relic/src/triggers/alert-issues.ts index cfd50d6c4f..eefd83f46f 100644 --- a/integrations/new-relic/src/triggers/alert-issues.ts +++ b/integrations/new-relic/src/triggers/alert-issues.ts @@ -3,6 +3,14 @@ import { z } from 'zod'; import { NerdGraphClient } from '../lib/client'; import { spec } from '../spec'; +let issueTitle = (value: unknown) => { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string').join('; '); + } + + return typeof value === 'string' ? value : undefined; +}; + export let alertIssues = SlateTrigger.create(spec, { name: 'Alert Issues', key: 'alert_issues', @@ -13,12 +21,12 @@ export let alertIssues = SlateTrigger.create(spec, { z.object({ issueId: z.string().describe('Alert issue ID'), title: z.string().optional().describe('Issue title'), - state: z.string().describe('Issue state: ACTIVATED, ACKNOWLEDGED, CLOSED'), + state: z.string().describe('Issue state: CREATED, ACTIVATED, DEACTIVATED, CLOSED'), priority: z.string().optional().describe('Issue priority'), - activatedAt: z.string().optional().describe('When the issue was first activated'), - closedAt: z.string().optional().describe('When the issue was closed'), - acknowledgedAt: z.string().optional().describe('When the issue was acknowledged'), - updatedAt: z.string().optional().describe('When the issue was last updated'), + activatedAt: z.number().optional().describe('When the issue was first activated'), + closedAt: z.number().optional().describe('When the issue was closed'), + acknowledgedAt: z.number().optional().describe('When the issue was acknowledged'), + updatedAt: z.number().optional().describe('When the issue was last updated'), entityGuids: z .array(z.string()) .optional() @@ -27,13 +35,13 @@ export let alertIssues = SlateTrigger.create(spec, { .array(z.string()) .optional() .describe('Entity names associated with this issue'), - conditionName: z - .string() + entityTypes: z + .array(z.string()) .optional() - .describe('Alert condition that triggered this issue'), - policyName: z.string().optional().describe('Alert policy name'), + .describe('Entity types associated with this issue'), previousState: z .string() + .nullable() .optional() .describe('Previous state of the issue (for change detection)') }) @@ -44,10 +52,10 @@ export let alertIssues = SlateTrigger.create(spec, { title: z.string().optional().describe('Issue title'), state: z.string().describe('Current issue state'), priority: z.string().optional().describe('Issue priority'), - activatedAt: z.string().optional().describe('When the issue was activated'), - closedAt: z.string().optional().describe('When the issue was closed'), - acknowledgedAt: z.string().optional().describe('When the issue was acknowledged'), - updatedAt: z.string().optional().describe('When the issue was last updated'), + activatedAt: z.number().optional().describe('When the issue was activated'), + closedAt: z.number().optional().describe('When the issue was closed'), + acknowledgedAt: z.number().optional().describe('When the issue was acknowledged'), + updatedAt: z.number().optional().describe('When the issue was last updated'), entityGuids: z .array(z.string()) .optional() @@ -56,11 +64,10 @@ export let alertIssues = SlateTrigger.create(spec, { .array(z.string()) .optional() .describe('Entity names associated with this issue'), - conditionName: z - .string() + entityTypes: z + .array(z.string()) .optional() - .describe('Alert condition that triggered this issue'), - policyName: z.string().optional().describe('Alert policy name') + .describe('Entity types associated with this issue') }) ) .polling({ @@ -91,7 +98,7 @@ export let alertIssues = SlateTrigger.create(spec, { if (!previousState || previousState !== currentState) { inputs.push({ issueId: issue.issueId, - title: issue.title, + title: issueTitle(issue.title), state: currentState, priority: issue.priority, activatedAt: issue.activatedAt, @@ -100,8 +107,7 @@ export let alertIssues = SlateTrigger.create(spec, { updatedAt: issue.updatedAt, entityGuids: issue.entityGuids, entityNames: issue.entityNames, - conditionName: issue.conditionName, - policyName: issue.policyName, + entityTypes: issue.entityTypes, previousState: previousState || null }); } @@ -130,8 +136,6 @@ export let alertIssues = SlateTrigger.create(spec, { eventType = 'issue.activated'; } else if (state === 'CLOSED' && previousState !== 'CLOSED') { eventType = 'issue.closed'; - } else if (state === 'ACKNOWLEDGED' && previousState !== 'ACKNOWLEDGED') { - eventType = 'issue.acknowledged'; } // Dedupe ID includes state so each state change is unique @@ -151,8 +155,7 @@ export let alertIssues = SlateTrigger.create(spec, { updatedAt: ctx.input.updatedAt, entityGuids: ctx.input.entityGuids, entityNames: ctx.input.entityNames, - conditionName: ctx.input.conditionName, - policyName: ctx.input.policyName + entityTypes: ctx.input.entityTypes } }; } diff --git a/integrations/new-relic/vitest.config.ts b/integrations/new-relic/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/new-relic/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/ocr-web-service/README.md b/integrations/ocr-web-service/README.md index adcbbeecbd..215826b9ec 100644 --- a/integrations/ocr-web-service/README.md +++ b/integrations/ocr-web-service/README.md @@ -1,6 +1,6 @@ # Ocr Web Service -Extract text from scanned documents and images using optical character recognition (OCR). Convert scanned PDFs and images into editable Word, Excel, PDF, RTF, and plain text formats. Supports 46 recognition languages, zonal OCR for extracting text from specific rectangular regions, multi-page document processing with page range selection, and word coordinate extraction. Accepts PDF, TIFF, JPEG, BMP, PNG, GIF, and ZIP inputs up to 100 MB. Retrieve account information including page balance and subscription details. +Extract text from scanned documents and images using optical character recognition (OCR). Convert scanned PDFs and images into editable Word, Excel, PDF, RTF, and plain text formats returned as Slate attachments. Supports 46 recognition languages, zonal OCR for extracting text from specific rectangular regions, multi-page document processing with page range selection, and word coordinate extraction. Accepts PDF, TIFF, JPEG, BMP, PNG, GIF, and ZIP inputs up to 100 MB. Retrieve account information including page balance and subscription details. ## License diff --git a/integrations/ocr-web-service/package.json b/integrations/ocr-web-service/package.json index 22c3f4f4c6..7d45a5bb16 100644 --- a/integrations/ocr-web-service/package.json +++ b/integrations/ocr-web-service/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --dir . --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/ocr-web-service/src/auth.ts b/integrations/ocr-web-service/src/auth.ts index 1aa2ba7694..a22cb3993f 100644 --- a/integrations/ocr-web-service/src/auth.ts +++ b/integrations/ocr-web-service/src/auth.ts @@ -14,8 +14,8 @@ export let auth = SlateAuth.create() key: 'basic_auth', inputSchema: z.object({ - username: z.string().describe('Your OCR Web Service account username'), - licenseCode: z.string().describe('Your OCR Web Service license API key/password') + username: z.string().min(1).describe('Your OCR Web Service account username'), + licenseCode: z.string().min(1).describe('Your OCR Web Service license API key/password') }), getOutput: async ctx => { diff --git a/integrations/ocr-web-service/src/lib/client.ts b/integrations/ocr-web-service/src/lib/client.ts index 4b7b02e589..b1d7bd3c24 100644 --- a/integrations/ocr-web-service/src/lib/client.ts +++ b/integrations/ocr-web-service/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { ocrWebServiceApiError, ocrWebServiceServiceError } from './errors'; export interface OcrRequestParams { language?: string | string[]; @@ -13,14 +14,17 @@ export interface OcrRequestParams { } export interface OcrResponse { - ErrorMessage: string; + ErrorMessage?: string; + OCRErrorMessage?: string; AvailablePages: number; ProcessedPages: number; OCRText: string[][]; - OutputFileUrl: string; + OutputFileUrl?: string; OutputFileUrl2?: string; TaskDescription: string; - Reserved: unknown[]; + Reserved?: unknown[]; + OCRWords?: unknown[]; + OCRWSWords?: unknown[]; } export interface AccountInfoResponse { @@ -29,7 +33,14 @@ export interface AccountInfoResponse { LastProcessingTime: string; SubcriptionPlan: string; ExpirationDate: string; - ErrorMessage: string; + ErrorMessage?: string; + OCRErrorMessage?: string; +} + +export interface DownloadedFile { + contentBase64: string; + mimeType: string; + byteLength: number; } export class Client { @@ -64,7 +75,7 @@ export class Client { if (params.zones && params.zones.length > 0) { query.zone = params.zones .map(z => `${z.top}:${z.left}:${z.height}:${z.width}`) - .join(';'); + .join(','); } if (params.outputFormats && params.outputFormats.length > 0) { @@ -80,7 +91,7 @@ export class Client { } if (params.newline) { - query.newline = 'true'; + query.newline = '1'; } if (params.description) { @@ -90,89 +101,171 @@ export class Client { return query; } - async processDocument( - fileContent: string, - fileName: string, - params: OcrRequestParams - ): Promise { - let query = this.buildQueryParams(params); + private assertSuccess( + data: T, + operation: string + ): T { + let message = (data.ErrorMessage || data.OCRErrorMessage || '').trim(); + + if (message) { + let error = ocrWebServiceServiceError( + `OCR Web Service API ${operation} failed: ${message}` + ); + error.data.reason = 'ocr_web_service_api_error'; + throw error; + } - let fileBuffer = Buffer.from(fileContent, 'base64'); + return data; + } - let boundary = `----SlatesBoundary${Date.now()}`; + private decodeBase64File(fileContent: string) { + let normalized = fileContent.replace(/\s+/g, ''); - let bodyParts: Buffer[] = []; + if (!normalized || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) { + throw ocrWebServiceServiceError( + 'fileContent must be valid non-empty base64-encoded file content.' + ); + } - let header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`; + return Buffer.from(normalized, 'base64'); + } - bodyParts.push(Buffer.from(header, 'utf-8')); - bodyParts.push(fileBuffer); + private async downloadUrl( + url: string, + operation: string + ): Promise<{ + buffer: Buffer; + mimeType: string; + }> { + let response: Response; + + try { + response = await fetch(url); + } catch (error) { + throw ocrWebServiceApiError(error, operation); + } - let footer = `\r\n--${boundary}--\r\n`; + if (!response.ok) { + throw ocrWebServiceServiceError( + `OCR Web Service ${operation} failed: HTTP ${response.status} ${response.statusText}` + ); + } - bodyParts.push(Buffer.from(footer, 'utf-8')); + let buffer: Buffer; - let body = Buffer.concat(bodyParts); + try { + buffer = Buffer.from(await response.arrayBuffer()); + } catch (error) { + throw ocrWebServiceApiError(error, operation); + } - let response = await this.api.post('/processDocument', body, { - params: query, - auth: { - username: this.username, - password: this.licenseCode - }, - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - Accept: 'application/json' - } - }); + return { + buffer, + mimeType: + response.headers.get('content-type')?.split(';')[0]?.trim() || + 'application/octet-stream' + }; + } - if (response.data.ErrorMessage) { - throw new Error(`OCR Web Service error: ${response.data.ErrorMessage}`); + private inferFileNameFromUrl(fileUrl: string) { + try { + let url = new URL(fileUrl); + let fileName = url.pathname.split('/').filter(Boolean).pop(); + return fileName || 'document'; + } catch { + return 'document'; } + } - return response.data; + private async processDocumentBytes( + fileBuffer: Buffer, + params: OcrRequestParams + ): Promise { + let query = this.buildQueryParams(params); + + try { + let response = await this.api.post('/processDocument', fileBuffer, { + params: query, + auth: { + username: this.username, + password: this.licenseCode + }, + headers: { + 'Content-Type': 'application/octet-stream', + Accept: 'application/json' + } + }); + + return this.assertSuccess(response.data, 'process document'); + } catch (error) { + throw ocrWebServiceApiError(error, 'process document'); + } + } + + async processDocument( + fileContent: string, + fileName: string, + params: OcrRequestParams + ): Promise { + if (!fileName.trim()) { + throw ocrWebServiceServiceError('fileName is required when providing fileContent.'); + } + + return this.processDocumentBytes(this.decodeBase64File(fileContent), params); } async processDocumentFromUrl( fileUrl: string, + fileName: string | undefined, params: OcrRequestParams ): Promise { - let query = this.buildQueryParams(params); - - let response = await this.api.post('/processDocument', fileUrl, { - params: query, - auth: { - username: this.username, - password: this.licenseCode - }, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json' - } - }); + let source = await this.downloadUrl(fileUrl, 'source file download'); + let resolvedFileName = fileName?.trim() || this.inferFileNameFromUrl(fileUrl); - if (response.data.ErrorMessage) { - throw new Error(`OCR Web Service error: ${response.data.ErrorMessage}`); + if (!resolvedFileName) { + throw ocrWebServiceServiceError( + 'fileName could not be inferred from fileUrl. Provide fileName explicitly.' + ); } - return response.data; + return this.processDocumentBytes(source.buffer, params); } async getAccountInfo(): Promise { - let response = await this.api.get('/getAccountInformation', { - auth: { - username: this.username, - password: this.licenseCode - }, - headers: { - Accept: 'application/json' - } - }); + try { + let response = await this.api.get('/getAccountInformation', { + auth: { + username: this.username, + password: this.licenseCode + }, + headers: { + Accept: 'application/json' + } + }); + + return this.assertSuccess(response.data, 'get account information'); + } catch (error) { + throw ocrWebServiceApiError(error, 'get account information'); + } + } - if (response.data.ErrorMessage) { - throw new Error(`OCR Web Service error: ${response.data.ErrorMessage}`); + async downloadOutputFile( + outputFileUrl: string | undefined, + fallbackMimeType = 'application/octet-stream' + ): Promise { + if (!outputFileUrl) { + throw ocrWebServiceServiceError( + 'OCR Web Service did not return an output file URL for the converted document.' + ); } - return response.data; + let result = await this.downloadUrl(outputFileUrl, 'output file download'); + + return { + contentBase64: result.buffer.toString('base64'), + mimeType: + result.mimeType === 'application/octet-stream' ? fallbackMimeType : result.mimeType, + byteLength: result.buffer.byteLength + }; } } diff --git a/integrations/ocr-web-service/src/lib/errors.ts b/integrations/ocr-web-service/src/lib/errors.ts new file mode 100644 index 0000000000..9a1300743a --- /dev/null +++ b/integrations/ocr-web-service/src/lib/errors.ts @@ -0,0 +1,110 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + for (let key of [ + 'ErrorMessage', + 'OCRErrorMessage', + 'message', + 'error', + 'error_description', + 'code', + 'status' + ]) { + addDetail(details, value[key]); + } + + collectDetails(value.error, details); + collectDetails(value.details, details); +}; + +let getErrorResponse = (error: unknown) => + isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + +let getErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = getErrorResponse(error); + let data = isRecord(error.data) ? error.data : undefined; + + return response?.status ?? error.status ?? data?.status; +}; + +let extractOcrWebServiceMessage = (error: unknown) => { + let response = getErrorResponse(error); + let data = response?.data ?? (isRecord(error) ? error.data : undefined); + let details: string[] = []; + + collectDetails(data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let ocrWebServiceServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let ocrWebServiceApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = getErrorResponse(error); + let status = getErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = ocrWebServiceServiceError( + `OCR Web Service API ${operation} failed: ${statusLabel}${extractOcrWebServiceMessage(error)}` + ); + serviceError.data.reason = 'ocr_web_service_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/ocr-web-service/src/tools.schema.test.ts b/integrations/ocr-web-service/src/tools.schema.test.ts new file mode 100644 index 0000000000..a37f273a2f --- /dev/null +++ b/integrations/ocr-web-service/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('OCR Web Service tool input schemas', provider.actions); diff --git a/integrations/ocr-web-service/src/tools/convert-document.ts b/integrations/ocr-web-service/src/tools/convert-document.ts index dcdf6d4f6b..7962692057 100644 --- a/integrations/ocr-web-service/src/tools/convert-document.ts +++ b/integrations/ocr-web-service/src/tools/convert-document.ts @@ -1,10 +1,31 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { ocrWebServiceServiceError } from '../lib/errors'; import { spec } from '../spec'; let outputFormatEnum = z.enum(['pdf', 'pdfimg', 'doc', 'docx', 'xls', 'xlsx', 'rtf', 'txt']); +let mimeTypeForOutputFormat = (format: z.infer) => { + switch (format) { + case 'pdf': + case 'pdfimg': + return 'application/pdf'; + case 'doc': + return 'application/msword'; + case 'docx': + return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + case 'xls': + return 'application/vnd.ms-excel'; + case 'xlsx': + return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + case 'rtf': + return 'application/rtf'; + case 'txt': + return 'text/plain'; + } +}; + let languageEnum = z.enum([ 'english', 'afrikaans', @@ -57,9 +78,9 @@ let languageEnum = z.enum([ export let convertDocument = SlateTool.create(spec, { name: 'Convert Document', key: 'convert_document', - description: `Converts a scanned document or image into an editable document format using OCR. Returns a download URL for the converted file. Supports output formats: PDF, Word (.doc/.docx), Excel (.xls/.xlsx), RTF, and plain text. You can request up to two output formats per conversion. Optionally also returns extracted text alongside the converted file.`, + description: `Converts a scanned document or image into an editable document format using OCR. Returns converted files as Slate attachments with provider download URLs in metadata. Supports output formats: PDF, Word (.doc/.docx), Excel (.xls/.xlsx), RTF, and plain text. You can request up to two output formats per conversion. Optionally also returns extracted text alongside the converted file.`, instructions: [ - 'Provide either fileContent (base64) with fileName, or fileUrl — not both.', + 'Provide either fileContent (base64) with fileName, or fileUrl - not both.', 'You can specify up to 2 output formats per request.', 'Use "pdfimg" for a PDF that includes both the original image and the recognized text layer.' ], @@ -86,9 +107,10 @@ export let convertDocument = SlateTool.create(spec, { ), fileUrl: z .string() + .url() .optional() .describe( - 'Publicly accessible URL of the document or image to process. Use this instead of fileContent.' + 'Publicly accessible URL of the document or image to process. Slates downloads this URL and uploads the file bytes to OCR Web Service.' ), outputFormats: z .array(outputFormatEnum) @@ -114,7 +136,7 @@ export let convertDocument = SlateTool.create(spec, { .boolean() .optional() .describe( - 'Also return extracted text in the response alongside the converted file URL' + 'Also return extracted text in the response alongside the converted file attachment metadata' ) }) ) @@ -130,9 +152,24 @@ export let convertDocument = SlateTool.create(spec, { ocrText: z .array(z.array(z.string())) .optional() - .describe('Extracted text (only included if includeText was set to true)'), + .describe( + 'Extracted text organized as zones by pages (only included if includeText was set to true)' + ), processedPages: z.number().describe('Number of pages processed'), - availablePages: z.number().describe('Remaining page balance on the account') + availablePages: z.number().describe('Remaining page balance on the account'), + files: z + .array( + z.object({ + outputFormat: outputFormatEnum.describe('Requested output format'), + outputFileUrl: z + .string() + .describe('Temporary OCR Web Service download URL for this output file'), + mimeType: z.string().describe('MIME type of the returned attachment'), + byteLength: z.number().describe('Decoded byte length of the attachment') + }) + ) + .describe('Converted file metadata in the same order as returned attachments'), + attachmentCount: z.number().describe('Number of Slate attachments returned') }) ) .handleInvocation(async ctx => { @@ -145,15 +182,15 @@ export let convertDocument = SlateTool.create(spec, { let hasUrl = !!ctx.input.fileUrl; if (!hasFile && !hasUrl) { - throw new Error('Either fileContent or fileUrl must be provided.'); + throw ocrWebServiceServiceError('Either fileContent or fileUrl must be provided.'); } if (hasFile && hasUrl) { - throw new Error('Provide either fileContent or fileUrl, not both.'); + throw ocrWebServiceServiceError('Provide either fileContent or fileUrl, not both.'); } if (hasFile && !ctx.input.fileName) { - throw new Error('fileName is required when providing fileContent.'); + throw ocrWebServiceServiceError('fileName is required when providing fileContent.'); } let params = { @@ -168,7 +205,40 @@ export let convertDocument = SlateTool.create(spec, { let result = hasFile ? await client.processDocument(ctx.input.fileContent!, ctx.input.fileName!, params) - : await client.processDocumentFromUrl(ctx.input.fileUrl!, params); + : await client.processDocumentFromUrl(ctx.input.fileUrl!, ctx.input.fileName, params); + + let outputFileUrls = [result.OutputFileUrl, result.OutputFileUrl2].filter( + (url): url is string => typeof url === 'string' && url.length > 0 + ); + + if (outputFileUrls.length < ctx.input.outputFormats.length) { + throw ocrWebServiceServiceError( + 'OCR Web Service did not return a download URL for every requested output format.' + ); + } + + let downloadedFiles = await Promise.all( + outputFileUrls.map((url, index) => + client.downloadOutputFile( + url, + mimeTypeForOutputFormat(ctx.input.outputFormats[index]!) + ) + ) + ); + + let files = downloadedFiles.map((file, index) => { + let outputFormat = ctx.input.outputFormats[index]!; + let fallbackMimeType = mimeTypeForOutputFormat(outputFormat); + let mimeType = + file.mimeType === 'application/octet-stream' ? fallbackMimeType : file.mimeType; + + return { + outputFormat, + outputFileUrl: outputFileUrls[index]!, + mimeType, + byteLength: file.byteLength + }; + }); let output: { outputFileUrl: string; @@ -176,14 +246,18 @@ export let convertDocument = SlateTool.create(spec, { ocrText?: string[][]; processedPages: number; availablePages: number; + files: typeof files; + attachmentCount: number; } = { - outputFileUrl: result.OutputFileUrl, + outputFileUrl: outputFileUrls[0]!, processedPages: result.ProcessedPages, - availablePages: result.AvailablePages + availablePages: result.AvailablePages, + files, + attachmentCount: downloadedFiles.length }; - if (result.OutputFileUrl2) { - output.outputFileUrl2 = result.OutputFileUrl2; + if (outputFileUrls[1]) { + output.outputFileUrl2 = outputFileUrls[1]; } if (ctx.input.includeText && result.OCRText) { @@ -194,7 +268,10 @@ export let convertDocument = SlateTool.create(spec, { return { output, - message: `Successfully converted **${result.ProcessedPages}** page(s) to **${formatNames}**. [Download file](${result.OutputFileUrl})${result.OutputFileUrl2 ? ` | [Download file 2](${result.OutputFileUrl2})` : ''}` + attachments: downloadedFiles.map((file, index) => + createBase64Attachment(file.contentBase64, files[index]?.mimeType) + ), + message: `Successfully converted **${result.ProcessedPages}** page(s) to **${formatNames}** and returned ${downloadedFiles.length} attachment(s). [Download file](${outputFileUrls[0]})${outputFileUrls[1] ? ` | [Download file 2](${outputFileUrls[1]})` : ''}` }; }) .build(); diff --git a/integrations/ocr-web-service/src/tools/extract-text.ts b/integrations/ocr-web-service/src/tools/extract-text.ts index fbb864bbfe..d37f2645da 100644 --- a/integrations/ocr-web-service/src/tools/extract-text.ts +++ b/integrations/ocr-web-service/src/tools/extract-text.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { ocrWebServiceServiceError } from '../lib/errors'; import { spec } from '../spec'; let zoneSchema = z.object({ @@ -62,9 +63,9 @@ let languageEnum = z.enum([ export let extractText = SlateTool.create(spec, { name: 'Extract Text', key: 'extract_text', - description: `Extracts text from a scanned document or image using OCR. Supports 46 recognition languages and can process specific page ranges from multi-page documents. Optionally define rectangular zones to extract text from specific regions of the document. Provide either base64-encoded file content or a publicly accessible file URL.`, + description: `Extracts text from a scanned document or image using OCR. Supports 46 recognition languages and can process specific page ranges from multi-page documents. Optionally define rectangular zones to extract text from specific regions of the document. Provide either base64-encoded file content or a publicly accessible file URL that Slates can download and upload to OCR Web Service.`, instructions: [ - 'Provide either fileContent (base64) with fileName, or fileUrl — not both.', + 'Provide either fileContent (base64) with fileName, or fileUrl - not both.', 'For multi-language documents, specify multiple languages in the languages array.', 'Use zones for targeted text extraction from specific regions of the document.' ], @@ -91,9 +92,10 @@ export let extractText = SlateTool.create(spec, { ), fileUrl: z .string() + .url() .optional() .describe( - 'Publicly accessible URL of the document or image to process. Use this instead of fileContent.' + 'Publicly accessible URL of the document or image to process. Slates downloads this URL and uploads the file bytes to OCR Web Service.' ), languages: z .array(languageEnum) @@ -129,10 +131,16 @@ export let extractText = SlateTool.create(spec, { ocrText: z .array(z.array(z.string())) .describe( - 'Extracted text organized as a two-dimensional array: first dimension is pages, second dimension is zones' + 'Extracted text organized as a two-dimensional array: first dimension is zones, second dimension is pages' ), processedPages: z.number().describe('Number of pages processed'), - availablePages: z.number().describe('Remaining page balance on the account') + availablePages: z.number().describe('Remaining page balance on the account'), + wordCoordinates: z + .array(z.unknown()) + .optional() + .describe( + 'Provider word-coordinate payload returned when includeWordCoordinates is true' + ) }) ) .handleInvocation(async ctx => { @@ -145,15 +153,15 @@ export let extractText = SlateTool.create(spec, { let hasUrl = !!ctx.input.fileUrl; if (!hasFile && !hasUrl) { - throw new Error('Either fileContent or fileUrl must be provided.'); + throw ocrWebServiceServiceError('Either fileContent or fileUrl must be provided.'); } if (hasFile && hasUrl) { - throw new Error('Provide either fileContent or fileUrl, not both.'); + throw ocrWebServiceServiceError('Provide either fileContent or fileUrl, not both.'); } if (hasFile && !ctx.input.fileName) { - throw new Error('fileName is required when providing fileContent.'); + throw ocrWebServiceServiceError('fileName is required when providing fileContent.'); } let params = { @@ -170,15 +178,20 @@ export let extractText = SlateTool.create(spec, { let result = hasFile ? await client.processDocument(ctx.input.fileContent!, ctx.input.fileName!, params) - : await client.processDocumentFromUrl(ctx.input.fileUrl!, params); + : await client.processDocumentFromUrl(ctx.input.fileUrl!, ctx.input.fileName, params); let textPreview = (result.OCRText || []).flat().join(' ').substring(0, 200); + let wordCoordinates = + result.OCRWords ?? + result.OCRWSWords ?? + (ctx.input.includeWordCoordinates ? result.Reserved : undefined); return { output: { ocrText: result.OCRText || [], processedPages: result.ProcessedPages, - availablePages: result.AvailablePages + availablePages: result.AvailablePages, + ...(Array.isArray(wordCoordinates) ? { wordCoordinates } : {}) }, message: `Successfully extracted text from **${result.ProcessedPages}** page(s). Preview: "${textPreview}${textPreview.length >= 200 ? '...' : ''}"` }; diff --git a/integrations/ocr-web-service/vitest.config.ts b/integrations/ocr-web-service/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/ocr-web-service/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/ocrspace/docs/SPEC.md b/integrations/ocrspace/docs/SPEC.md index d74b47263d..4146ede1d6 100644 --- a/integrations/ocrspace/docs/SPEC.md +++ b/integrations/ocrspace/docs/SPEC.md @@ -31,12 +31,12 @@ Extracts text from images (JPG, PNG, GIF, BMP, TIFF) and PDF documents. Input ca Three OCR engines are available, selectable via the `OCREngine` parameter: - **Engine 1:** Fastest engine, supports the widest range of languages including Asian languages, and multi-page TIFF. -- **Engine 2:** Better for text on complex backgrounds (road signs, license plates, memes), special characters, rotated text, and single character/number recognition. Supports automatic language detection. -- **Engine 3:** Best text recognition quality with markdown-formatted output, supports 200+ languages, handwriting recognition, automatic table/layout recognition, and checkbox detection. Slower for larger files and still in development with no uptime guarantee. +- **Engine 2:** Recommended all-round choice for complex backgrounds (road signs, license plates, memes), special characters, rotated text, and single character/number recognition. Supports automatic language detection. +- **Engine 3:** Best text recognition quality with markdown-formatted output, supports 200+ languages, handwriting recognition, automatic table/layout recognition, and checkbox detection. It has lower monthly conversion quotas, is slower for larger files, and does not support searchable PDF output. ### Language Support -Supports over 25 explicitly listed languages (including Arabic, Chinese, Japanese, Korean, and major European languages) on Engines 1 and 2. Engine 3 supports 200+ languages with automatic language detection using the `language=auto` parameter. +Supports over 25 explicitly listed languages (including Arabic, Chinese, Japanese, Korean, and major European languages) on Engines 1 and 2. Engines 2 and 3 support automatic language detection using the `language=auto` parameter. ### Searchable PDF Generation @@ -45,6 +45,7 @@ Converts scanned images and PDFs into searchable (sandwich) PDFs with a text lay ### Text Overlay / Word Coordinates When `isOverlayRequired` is set to true, the API returns bounding box coordinates (position, height, width) for each recognized word, organized by lines. Useful for overlaying recognized text on top of the original image. +Engine 3 can return overlay data, but the current OCR.space documentation notes that Engine 3 coordinates are less precise than Engine 1/2 and requesting overlay data makes Engine 3 calls slower. ### Image Preprocessing Options @@ -52,10 +53,6 @@ When `isOverlayRequired` is set to true, the API returns bounding box coordinate - **Upscaling:** The `scale` parameter enables internal upscaling to improve OCR results on low-resolution scans. - **Table mode:** The `isTable` parameter optimizes recognition for table-structured documents like receipts and invoices, ensuring line-by-line output. -### Usage Tracking (PRO only) - -PRO and PRO PDF users can query their conversion counts via the `https://myapi.ocr.space/conversions` endpoint, broken down by OCR engine and time period. - ## Events The provider does not support events. diff --git a/integrations/ocrspace/package.json b/integrations/ocrspace/package.json index 7b69e2a104..b593afea01 100644 --- a/integrations/ocrspace/package.json +++ b/integrations/ocrspace/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.8" } diff --git a/integrations/ocrspace/src/index.ts b/integrations/ocrspace/src/index.ts index 11d103a970..821e09de3b 100644 --- a/integrations/ocrspace/src/index.ts +++ b/integrations/ocrspace/src/index.ts @@ -2,10 +2,8 @@ import { Slate } from 'slates'; import { spec } from './spec'; import { extractText, extractTextWithCoordinates, generateSearchablePdf } from './tools'; -import { inboundWebhook } from './triggers/inbound-webhook'; - export let provider = Slate.create({ spec, tools: [extractText, generateSearchablePdf, extractTextWithCoordinates], - triggers: [inboundWebhook] + triggers: [] }); diff --git a/integrations/ocrspace/src/lib/client.ts b/integrations/ocrspace/src/lib/client.ts index eef424341a..063a02ca97 100644 --- a/integrations/ocrspace/src/lib/client.ts +++ b/integrations/ocrspace/src/lib/client.ts @@ -1,4 +1,6 @@ -import { createAxios } from 'slates'; +import { Buffer } from 'node:buffer'; +import { createAxios, getResponseHeaderValue } from 'slates'; +import { ocrspaceApiError, ocrspaceUpstreamError } from './errors'; export interface OcrParseOptions { url?: string; @@ -52,6 +54,12 @@ export interface OcrResponse { processingTimeInMilliseconds: number; } +export interface DownloadedFile { + contentBase64: string; + mimeType: string; + byteLength: number; +} + interface RawTextOverlayWord { WordText: string; Left: number; @@ -74,22 +82,46 @@ interface RawTextOverlay { interface RawParsedResult { TextOverlay: RawTextOverlay | null; - FileParseExitCode: number; - ParsedText: string; - ErrorMessage: string | null; + FileParseExitCode: number | string; + ParsedText: string | null; + ErrorMessage: string[] | string | null; ErrorDetails: string | null; } interface RawOcrResponse { ParsedResults: RawParsedResult[]; - OCRExitCode: number; + OCRExitCode: number | string; IsErroredOnProcessing: boolean; ErrorMessage: string[] | string | null; ErrorDetails: string | null; SearchablePDFURL: string | null; - ProcessingTimeInMilliseconds: number; + ProcessingTimeInMilliseconds: number | string; } +let toNumber = (value: number | string | null | undefined, fallback = 0) => { + let number = Number(value); + return Number.isFinite(number) ? number : fallback; +}; + +let normalizeErrorMessage = (value: string[] | string | null | undefined) => { + if (Array.isArray(value)) { + let joined = value.filter(Boolean).join('; '); + return joined || null; + } + + return value || null; +}; + +let toBuffer = (value: unknown) => { + if (Buffer.isBuffer(value)) return value; + if (value instanceof ArrayBuffer) return Buffer.from(value); + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + if (typeof value === 'string') return Buffer.from(value); + return Buffer.alloc(0); +}; + let normalizeOverlayWord = (word: RawTextOverlayWord): TextOverlayWord => ({ wordText: word.WordText, left: word.Left, @@ -115,29 +147,27 @@ let normalizeOverlay = (overlay: RawTextOverlay | null): TextOverlay | null => { let normalizeParsedResult = (result: RawParsedResult): ParsedResult => ({ textOverlay: normalizeOverlay(result.TextOverlay), - fileParseExitCode: result.FileParseExitCode, - parsedText: result.ParsedText, - errorMessage: result.ErrorMessage, + fileParseExitCode: toNumber(result.FileParseExitCode), + parsedText: result.ParsedText || '', + errorMessage: normalizeErrorMessage(result.ErrorMessage), errorDetails: result.ErrorDetails }); let normalizeResponse = (raw: RawOcrResponse): OcrResponse => { - let errorMessage = raw.ErrorMessage; - let errorMessageStr = Array.isArray(errorMessage) ? errorMessage.join('; ') : errorMessage; - return { parsedResults: (raw.ParsedResults || []).map(normalizeParsedResult), - ocrExitCode: raw.OCRExitCode, + ocrExitCode: toNumber(raw.OCRExitCode), isErroredOnProcessing: raw.IsErroredOnProcessing, - errorMessage: errorMessageStr, + errorMessage: normalizeErrorMessage(raw.ErrorMessage), errorDetails: raw.ErrorDetails, searchablePdfUrl: raw.SearchablePDFURL || null, - processingTimeInMilliseconds: raw.ProcessingTimeInMilliseconds + processingTimeInMilliseconds: toNumber(raw.ProcessingTimeInMilliseconds) }; }; export class Client { - private axios; + private axios: ReturnType; + private downloadAxios: ReturnType; constructor(opts: { token: string }) { this.axios = createAxios({ @@ -146,6 +176,7 @@ export class Client { apikey: opts.token } }); + this.downloadAxios = createAxios({}); } async parseImage(options: OcrParseOptions): Promise { @@ -188,18 +219,41 @@ export class Client { ); } - let response = await this.axios.post('/parse/image', formData); - let raw = response.data as RawOcrResponse; + let raw: RawOcrResponse; + try { + let response = await this.axios.post('/parse/image', formData); + raw = response.data as RawOcrResponse; + } catch (error) { + throw ocrspaceApiError(error, 'parse image'); + } - if (raw.IsErroredOnProcessing || raw.OCRExitCode === 3 || raw.OCRExitCode === 4) { - let errorMsg = raw.ErrorMessage - ? Array.isArray(raw.ErrorMessage) - ? raw.ErrorMessage.join('; ') - : raw.ErrorMessage - : 'OCR processing failed'; - throw new Error(errorMsg); + let ocrExitCode = toNumber(raw.OCRExitCode); + if (raw.IsErroredOnProcessing || ocrExitCode === 3 || ocrExitCode === 4) { + let errorMsg = normalizeErrorMessage(raw.ErrorMessage) || 'OCR processing failed'; + throw ocrspaceUpstreamError(errorMsg, { + reason: 'ocrspace_processing_error' + }); } return normalizeResponse(raw); } + + async downloadFile(url: string, fallbackMimeType = 'application/octet-stream') { + try { + let response = await this.downloadAxios.get(url, { + responseType: 'arraybuffer' + }); + let content = toBuffer(response.data); + let mimeType = + getResponseHeaderValue(response.headers, 'content-type') || fallbackMimeType; + + return { + contentBase64: content.toString('base64'), + mimeType, + byteLength: content.byteLength + }; + } catch (error) { + throw ocrspaceApiError(error, 'download file'); + } + } } diff --git a/integrations/ocrspace/src/lib/errors.ts b/integrations/ocrspace/src/lib/errors.ts new file mode 100644 index 0000000000..901249f4c4 --- /dev/null +++ b/integrations/ocrspace/src/lib/errors.ts @@ -0,0 +1,101 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.error); + addDetail(details, value.ErrorMessage); + addDetail(details, value.ErrorDetails); +}; + +let extractMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + if (isRecord(error)) { + collectDetails(error.data, details); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let ocrspaceServiceError = (message: string) => { + let error = new ServiceError(badRequestError({ message })); + error.data.reason = 'ocrspace_validation_error'; + return error; +}; + +export let ocrspaceUpstreamError = ( + message: string, + options: { + reason?: string; + status?: number; + parent?: unknown; + } = {} +) => { + let error = ocrspaceServiceError(message); + error.data.reason = options.reason ?? 'ocrspace_api_error'; + error.data.upstreamStatus = options.status; + + if (options.parent instanceof Error) { + error.setParent(options.parent); + } + + return error; +}; + +export let ocrspaceApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let statusLabel = + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + return ocrspaceUpstreamError( + `OCR.space API ${operation} failed: ${statusLabel}${extractMessage(error)}`, + { + status: response?.status, + parent: error + } + ); +}; diff --git a/integrations/ocrspace/src/tools/extract-text-with-coordinates.ts b/integrations/ocrspace/src/tools/extract-text-with-coordinates.ts index 61c91358e4..f93a75c466 100644 --- a/integrations/ocrspace/src/tools/extract-text-with-coordinates.ts +++ b/integrations/ocrspace/src/tools/extract-text-with-coordinates.ts @@ -2,37 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; - -let languageEnum = z.enum([ - 'ara', - 'bul', - 'chs', - 'cht', - 'hrv', - 'cze', - 'dan', - 'dut', - 'eng', - 'fin', - 'fre', - 'ger', - 'gre', - 'hun', - 'kor', - 'ita', - 'jpn', - 'pol', - 'por', - 'rus', - 'slv', - 'spa', - 'swe', - 'tha', - 'tur', - 'ukr', - 'vnm', - 'auto' -]); +import { languageEnum, validateLanguageForEngine, validateSingleSource } from './shared'; let wordSchema = z.object({ wordText: z.string().describe('Recognized text of the word'), @@ -61,7 +31,7 @@ Useful for overlaying recognized text on the original image, building text annot ], constraints: [ 'Free tier: max 1 MB file size, max 3 PDF pages.', - 'Overlay data may not be available for Engine 3.' + 'Engine 3 overlay coordinates are available but less precise than Engine 1/2 and slower when requested.' ], tags: { readOnly: true, @@ -82,10 +52,12 @@ Useful for overlaying recognized text on the original image, building text annot .default('eng') .describe('Language code for OCR recognition'), ocrEngine: z - .enum(['1', '2']) + .enum(['1', '2', '3']) .optional() .default('1') - .describe('OCR engine to use: "1" (fastest) or "2" (complex backgrounds)'), + .describe( + 'OCR engine to use: "1" (fastest), "2" (complex backgrounds), or "3" (best text quality with slower overlay data)' + ), detectOrientation: z .boolean() .optional() @@ -122,9 +94,8 @@ Useful for overlaying recognized text on the original image, building text annot }) ) .handleInvocation(async ctx => { - if (!ctx.input.sourceUrl && !ctx.input.base64Image) { - throw new Error('Either "sourceUrl" or "base64Image" must be provided.'); - } + validateSingleSource(ctx.input); + validateLanguageForEngine(ctx.input.language, ctx.input.ocrEngine); let client = new Client({ token: ctx.auth.token }); @@ -134,7 +105,7 @@ Useful for overlaying recognized text on the original image, building text annot url: ctx.input.sourceUrl, base64Image: ctx.input.base64Image, language: ctx.input.language, - ocrEngine: Number(ctx.input.ocrEngine) as 1 | 2, + ocrEngine: Number(ctx.input.ocrEngine) as 1 | 2 | 3, isOverlayRequired: true, detectOrientation: ctx.input.detectOrientation, scale: ctx.input.scale, diff --git a/integrations/ocrspace/src/tools/extract-text.ts b/integrations/ocrspace/src/tools/extract-text.ts index 21bcf91088..f06bdd6365 100644 --- a/integrations/ocrspace/src/tools/extract-text.ts +++ b/integrations/ocrspace/src/tools/extract-text.ts @@ -2,37 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; - -let languageEnum = z.enum([ - 'ara', - 'bul', - 'chs', - 'cht', - 'hrv', - 'cze', - 'dan', - 'dut', - 'eng', - 'fin', - 'fre', - 'ger', - 'gre', - 'hun', - 'kor', - 'ita', - 'jpn', - 'pol', - 'por', - 'rus', - 'slv', - 'spa', - 'swe', - 'tha', - 'tur', - 'ukr', - 'vnm', - 'auto' -]); +import { languageEnum, validateLanguageForEngine, validateSingleSource } from './shared'; export let extractText = SlateTool.create(spec, { name: 'Extract Text', @@ -48,11 +18,11 @@ Enable table mode for structured documents like receipts and invoices.`, instructions: [ 'Provide exactly one of "sourceUrl" or "base64Image" as the input source.', 'For Base64 input, include the content-type prefix, e.g. "data:image/png;base64,iVBOR...".', - 'Use engine 3 with language "auto" for handwriting recognition or when language is unknown.' + 'Use language "auto" with Engine 2 or Engine 3 when the language is unknown.' ], constraints: [ 'Free tier: max 1 MB file size, max 3 PDF pages, 25,000 requests/month.', - 'Engine 3 is slower and still in development with no uptime guarantee.' + 'Engine 3 has lower monthly conversion quotas, is slower for larger files and PDFs, and does not support searchable PDF output.' ], tags: { readOnly: true, @@ -72,7 +42,7 @@ Enable table mode for structured documents like receipts and invoices.`, .optional() .default('eng') .describe( - 'Language code for OCR recognition. Use "auto" for automatic detection (Engine 3 only).' + 'Language code for OCR recognition. Use "auto" for automatic detection with Engine 2 or Engine 3.' ), ocrEngine: z .enum(['1', '2', '3']) @@ -124,9 +94,8 @@ Enable table mode for structured documents like receipts and invoices.`, }) ) .handleInvocation(async ctx => { - if (!ctx.input.sourceUrl && !ctx.input.base64Image) { - throw new Error('Either "sourceUrl" or "base64Image" must be provided.'); - } + validateSingleSource(ctx.input); + validateLanguageForEngine(ctx.input.language, ctx.input.ocrEngine); let client = new Client({ token: ctx.auth.token }); diff --git a/integrations/ocrspace/src/tools/generate-searchable-pdf.ts b/integrations/ocrspace/src/tools/generate-searchable-pdf.ts index d35ce04c94..35c008705d 100644 --- a/integrations/ocrspace/src/tools/generate-searchable-pdf.ts +++ b/integrations/ocrspace/src/tools/generate-searchable-pdf.ts @@ -1,48 +1,19 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { ocrspaceServiceError } from '../lib/errors'; import { spec } from '../spec'; - -let languageEnum = z.enum([ - 'ara', - 'bul', - 'chs', - 'cht', - 'hrv', - 'cze', - 'dan', - 'dut', - 'eng', - 'fin', - 'fre', - 'ger', - 'gre', - 'hun', - 'kor', - 'ita', - 'jpn', - 'pol', - 'por', - 'rus', - 'slv', - 'spa', - 'swe', - 'tha', - 'tur', - 'ukr', - 'vnm', - 'auto' -]); +import { languageEnum, validateLanguageForEngine, validateSingleSource } from './shared'; export let generateSearchablePdf = SlateTool.create(spec, { name: 'Generate Searchable PDF', key: 'generate_searchable_pdf', description: `Converts a scanned image or PDF into a searchable (sandwich) PDF with an embedded text layer. The generated PDF can be searched, copied from, and indexed. -Returns a download URL for the generated PDF (valid for **1 hour**) along with the extracted text.`, +Returns the generated PDF as a Slate attachment, plus metadata and extracted text.`, instructions: [ 'Provide exactly one of "sourceUrl" or "base64Image" as the input source.', - 'The download URL expires after 1 hour — download or share it promptly.', + 'The generated PDF bytes are returned as a Slate attachment; the provider download URL is temporary and only included as metadata.', 'Set "hideTextLayer" to true if you want the text layer to be invisible (useful for archival purposes).' ], constraints: [ @@ -96,7 +67,12 @@ Returns a download URL for the generated PDF (valid for **1 hour**) along with t z.object({ searchablePdfUrl: z .string() - .describe('Download URL for the generated searchable PDF (valid for 1 hour)'), + .describe( + 'Temporary OCR.space download URL used to fetch the attached searchable PDF (valid for 1 hour)' + ), + mimeType: z.string().describe('MIME type of the returned PDF attachment'), + byteLength: z.number().describe('Decoded byte length of the returned PDF attachment'), + attachmentCount: z.number().describe('Number of Slate attachments returned'), extractedText: z.string().describe('Full extracted text from all pages'), pages: z .array( @@ -110,9 +86,8 @@ Returns a download URL for the generated PDF (valid for **1 hour**) along with t }) ) .handleInvocation(async ctx => { - if (!ctx.input.sourceUrl && !ctx.input.base64Image) { - throw new Error('Either "sourceUrl" or "base64Image" must be provided.'); - } + validateSingleSource(ctx.input); + validateLanguageForEngine(ctx.input.language, ctx.input.ocrEngine); let client = new Client({ token: ctx.auth.token }); @@ -130,11 +105,13 @@ Returns a download URL for the generated PDF (valid for **1 hour**) along with t }); if (!result.searchablePdfUrl) { - throw new Error( + throw ocrspaceServiceError( 'Searchable PDF generation failed — no download URL was returned. Ensure you are not using Engine 3.' ); } + let pdf = await client.downloadFile(result.searchablePdfUrl, 'application/pdf'); + let pages = result.parsedResults.map((r, i) => ({ pageNumber: i + 1, text: r.parsedText || '' @@ -145,11 +122,15 @@ Returns a download URL for the generated PDF (valid for **1 hour**) along with t return { output: { searchablePdfUrl: result.searchablePdfUrl, + mimeType: pdf.mimeType, + byteLength: pdf.byteLength, + attachmentCount: 1, extractedText, pages, processingTimeMs: result.processingTimeInMilliseconds }, - message: `Searchable PDF generated in ${result.processingTimeInMilliseconds}ms with ${pages.length} page(s). [Download PDF](${result.searchablePdfUrl}) (link valid for 1 hour).` + attachments: [createBase64Attachment(pdf.contentBase64, pdf.mimeType)], + message: `Searchable PDF generated and returned as an attachment in ${result.processingTimeInMilliseconds}ms with ${pages.length} page(s).` }; }) .build(); diff --git a/integrations/ocrspace/src/tools/shared.ts b/integrations/ocrspace/src/tools/shared.ts new file mode 100644 index 0000000000..6fdd63acd0 --- /dev/null +++ b/integrations/ocrspace/src/tools/shared.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; +import { ocrspaceServiceError } from '../lib/errors'; + +export let languageEnum = z.enum([ + 'ara', + 'bul', + 'chs', + 'cht', + 'hrv', + 'cze', + 'dan', + 'dut', + 'eng', + 'fin', + 'fre', + 'ger', + 'gre', + 'hun', + 'kor', + 'ita', + 'jpn', + 'pol', + 'por', + 'rus', + 'slv', + 'spa', + 'swe', + 'tha', + 'tur', + 'ukr', + 'vnm', + 'auto' +]); + +let hasValue = (value: string | undefined) => + typeof value === 'string' && value.trim().length > 0; + +export let validateSingleSource = (input: { sourceUrl?: string; base64Image?: string }) => { + let sourceCount = Number(hasValue(input.sourceUrl)) + Number(hasValue(input.base64Image)); + + if (sourceCount !== 1) { + throw ocrspaceServiceError( + 'Provide exactly one input source: either "sourceUrl" or "base64Image".' + ); + } +}; + +export let validateLanguageForEngine = (language: string, ocrEngine: string) => { + if (language === 'auto' && ocrEngine === '1') { + throw ocrspaceServiceError('Language "auto" requires OCR Engine 2 or OCR Engine 3.'); + } +}; diff --git a/integrations/okta/README.md b/integrations/okta/README.md index 655e8bbf97..0585438ab9 100644 --- a/integrations/okta/README.md +++ b/integrations/okta/README.md @@ -32,6 +32,10 @@ Search and list users in your Okta organization. Supports keyword search, Okta f Assign or unassign users and groups to/from an Okta application. Also supports listing current user and group assignments for an app. +### Manage Event Hook + +Create, read, update, delete, list, and run lifecycle operations for Okta event hooks. Event hooks deliver selected Okta system events to an HTTPS endpoint. + ### Manage Group Membership Add or remove users from an Okta group, or list current group members. Use this to manage who belongs to a group. diff --git a/integrations/okta/package.json b/integrations/okta/package.json index a1946dc0c5..488e8fba93 100644 --- a/integrations/okta/package.json +++ b/integrations/okta/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/okta/src/auth.ts b/integrations/okta/src/auth.ts index 9eb63d1e6c..cb568ae611 100644 --- a/integrations/okta/src/auth.ts +++ b/integrations/okta/src/auth.ts @@ -1,10 +1,12 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { applyOktaErrorInterceptor, oktaServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( z.object({ token: z.string(), + authMethod: z.enum(['api_token', 'oauth']).optional(), refreshToken: z.string().optional(), expiresAt: z.string().optional() }) @@ -91,7 +93,12 @@ export let auth = SlateAuth.create() description: 'Access user profile via OpenID Connect', scope: 'openid' }, - { title: 'Profile', description: 'Access user profile information', scope: 'profile' } + { title: 'Profile', description: 'Access user profile information', scope: 'profile' }, + { + title: 'Offline Access', + description: 'Allow refreshing OAuth access tokens without reconnecting', + scope: 'offline_access' + } ], inputSchema: z.object({ @@ -119,6 +126,7 @@ export let auth = SlateAuth.create() handleCallback: async ctx => { let domain = ctx.input.domain.replace(/\/+$/, ''); let http = createAxios({ baseURL: domain }); + applyOktaErrorInterceptor(http); let response = await http.post( '/oauth2/v1/token', @@ -143,6 +151,7 @@ export let auth = SlateAuth.create() return { output: { token: response.data.access_token, + authMethod: 'oauth' as const, refreshToken: response.data.refresh_token, expiresAt }, @@ -152,11 +161,14 @@ export let auth = SlateAuth.create() handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - return { output: ctx.output }; + throw oktaServiceError( + 'Okta OAuth token cannot be refreshed because no refresh token was returned. Reconnect with offline_access enabled.' + ); } let domain = ctx.input.domain.replace(/\/+$/, ''); let http = createAxios({ baseURL: domain }); + applyOktaErrorInterceptor(http); let response = await http.post( '/oauth2/v1/token', @@ -181,6 +193,7 @@ export let auth = SlateAuth.create() return { output: { token: response.data.access_token, + authMethod: 'oauth' as const, refreshToken: response.data.refresh_token || ctx.output.refreshToken, expiresAt } @@ -190,6 +203,7 @@ export let auth = SlateAuth.create() getProfile: async (ctx: any) => { let domain = ctx.input.domain.replace(/\/+$/, ''); let http = createAxios({ baseURL: domain }); + applyOktaErrorInterceptor(http); let response = await http.get('/oauth2/v1/userinfo', { headers: { @@ -224,7 +238,8 @@ export let auth = SlateAuth.create() getOutput: async ctx => { return { output: { - token: ctx.input.token + token: ctx.input.token, + authMethod: 'api_token' as const } }; }, diff --git a/integrations/okta/src/index.ts b/integrations/okta/src/index.ts index 139680b773..e7459db4f3 100644 --- a/integrations/okta/src/index.ts +++ b/integrations/okta/src/index.ts @@ -8,6 +8,7 @@ import { listPoliciesTool, listUsersTool, manageAppAssignmentTool, + manageEventHookTool, manageGroupMembershipTool, manageGroupTool, manageUserFactorsTool, @@ -30,6 +31,7 @@ export let provider = Slate.create({ manageGroupMembershipTool, listApplicationsTool, manageAppAssignmentTool, + manageEventHookTool, querySystemLogTool, listPoliciesTool, manageUserFactorsTool diff --git a/integrations/okta/src/lib/client.ts b/integrations/okta/src/lib/client.ts index 598c049e59..539340da0f 100644 --- a/integrations/okta/src/lib/client.ts +++ b/integrations/okta/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { applyOktaErrorInterceptor } from './errors'; import type { OktaApplication, OktaEventHook, @@ -10,20 +11,25 @@ import type { PaginatedResponse } from './types'; +export type OktaAuthMethod = 'api_token' | 'oauth'; + export class OktaClient { private http: ReturnType; - constructor(params: { domain: string; token: string }) { + constructor(params: { domain: string; token: string; authMethod?: OktaAuthMethod }) { let domain = params.domain.replace(/\/+$/, ''); + let authorization = + params.authMethod === 'oauth' ? `Bearer ${params.token}` : `SSWS ${params.token}`; this.http = createAxios({ baseURL: `${domain}/api/v1`, headers: { - Authorization: `SSWS ${params.token}`, + Authorization: authorization, Accept: 'application/json', 'Content-Type': 'application/json' } }); + applyOktaErrorInterceptor(this.http); } // --- Pagination helper --- diff --git a/integrations/okta/src/lib/errors.ts b/integrations/okta/src/lib/errors.ts new file mode 100644 index 0000000000..e504126624 --- /dev/null +++ b/integrations/okta/src/lib/errors.ts @@ -0,0 +1,104 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; +import type { createAxios } from 'slates'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let pushDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let detail = value.trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectErrorDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectErrorDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + pushDetail(details, value); + return; + } + + for (let key of [ + 'errorSummary', + 'errorCode', + 'errorLink', + 'errorId', + 'message', + 'error_description', + 'error' + ]) { + pushDetail(details, value[key]); + } + + for (let nested of Object.values(value)) { + if (Array.isArray(nested) || isRecord(nested)) { + collectErrorDetails(nested, details); + } + } +}; + +let extractOktaMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectErrorDetails(response?.data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +export let oktaServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let oktaApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = response?.status; + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = oktaServiceError( + `Okta API ${operation} failed: ${statusLabel}${extractOktaMessage(error)}` + ); + serviceError.data.reason = 'okta_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; + +export let applyOktaErrorInterceptor = (http: ReturnType) => { + http.interceptors.response.use( + response => response, + error => Promise.reject(oktaApiError(error)) + ); +}; diff --git a/integrations/okta/src/tools/create-user.ts b/integrations/okta/src/tools/create-user.ts index 5b241748a1..dec4396471 100644 --- a/integrations/okta/src/tools/create-user.ts +++ b/integrations/okta/src/tools/create-user.ts @@ -55,7 +55,8 @@ export let createUserTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let profile: Record = { diff --git a/integrations/okta/src/tools/get-user.ts b/integrations/okta/src/tools/get-user.ts index e58b2158e1..deb0f673f7 100644 --- a/integrations/okta/src/tools/get-user.ts +++ b/integrations/okta/src/tools/get-user.ts @@ -67,7 +67,8 @@ export let getUserTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let user = await client.getUser(ctx.input.userId); diff --git a/integrations/okta/src/tools/index.ts b/integrations/okta/src/tools/index.ts index f3f7848463..0cbaf20996 100644 --- a/integrations/okta/src/tools/index.ts +++ b/integrations/okta/src/tools/index.ts @@ -5,6 +5,7 @@ export * from './list-groups'; export * from './list-policies'; export * from './list-users'; export * from './manage-app-assignment'; +export * from './manage-event-hook'; export * from './manage-group'; export * from './manage-group-membership'; export * from './manage-user-factors'; diff --git a/integrations/okta/src/tools/list-applications.ts b/integrations/okta/src/tools/list-applications.ts index b4c0681a9b..781a8c85ed 100644 --- a/integrations/okta/src/tools/list-applications.ts +++ b/integrations/okta/src/tools/list-applications.ts @@ -44,7 +44,8 @@ export let listApplicationsTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let result = await client.listApplications({ diff --git a/integrations/okta/src/tools/list-groups.ts b/integrations/okta/src/tools/list-groups.ts index 7c9e115014..229b75a613 100644 --- a/integrations/okta/src/tools/list-groups.ts +++ b/integrations/okta/src/tools/list-groups.ts @@ -43,7 +43,8 @@ export let listGroupsTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let result = await client.listGroups({ diff --git a/integrations/okta/src/tools/list-policies.ts b/integrations/okta/src/tools/list-policies.ts index 598caa82d7..230527a0cb 100644 --- a/integrations/okta/src/tools/list-policies.ts +++ b/integrations/okta/src/tools/list-policies.ts @@ -46,7 +46,8 @@ export let listPoliciesTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let policies = await client.listPolicies(ctx.input.policyType); diff --git a/integrations/okta/src/tools/list-users.ts b/integrations/okta/src/tools/list-users.ts index a0da0d8131..76d3c49dd2 100644 --- a/integrations/okta/src/tools/list-users.ts +++ b/integrations/okta/src/tools/list-users.ts @@ -60,7 +60,8 @@ export let listUsersTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let result = await client.listUsers({ diff --git a/integrations/okta/src/tools/manage-app-assignment.ts b/integrations/okta/src/tools/manage-app-assignment.ts index 2aab929821..d7916c1723 100644 --- a/integrations/okta/src/tools/manage-app-assignment.ts +++ b/integrations/okta/src/tools/manage-app-assignment.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OktaClient } from '../lib/client'; +import { oktaServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageAppAssignmentTool = SlateTool.create(spec, { @@ -67,13 +68,14 @@ export let manageAppAssignmentTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let { action, appId, userId, groupId } = ctx.input; if (action === 'assign_user') { - if (!userId) throw new Error('User ID is required'); + if (!userId) throw oktaServiceError('User ID is required'); await client.assignUserToApplication(appId, { userId }); return { output: { appId, action, success: true }, @@ -82,7 +84,7 @@ export let manageAppAssignmentTool = SlateTool.create(spec, { } if (action === 'unassign_user') { - if (!userId) throw new Error('User ID is required'); + if (!userId) throw oktaServiceError('User ID is required'); await client.removeUserFromApplication(appId, userId); return { output: { appId, action, success: true }, @@ -91,7 +93,7 @@ export let manageAppAssignmentTool = SlateTool.create(spec, { } if (action === 'assign_group') { - if (!groupId) throw new Error('Group ID is required'); + if (!groupId) throw oktaServiceError('Group ID is required'); await client.assignGroupToApplication(appId, groupId); return { output: { appId, action, success: true }, @@ -100,7 +102,7 @@ export let manageAppAssignmentTool = SlateTool.create(spec, { } if (action === 'unassign_group') { - if (!groupId) throw new Error('Group ID is required'); + if (!groupId) throw oktaServiceError('Group ID is required'); await client.removeGroupFromApplication(appId, groupId); return { output: { appId, action, success: true }, @@ -169,6 +171,6 @@ export let manageAppAssignmentTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw oktaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/okta/src/tools/manage-event-hook.ts b/integrations/okta/src/tools/manage-event-hook.ts new file mode 100644 index 0000000000..5aa4d4543e --- /dev/null +++ b/integrations/okta/src/tools/manage-event-hook.ts @@ -0,0 +1,214 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { OktaClient } from '../lib/client'; +import { oktaServiceError } from '../lib/errors'; +import type { OktaEventHook } from '../lib/types'; +import { spec } from '../spec'; + +let eventHookSchema = z.object({ + eventHookId: z.string().describe('Unique Okta event hook ID'), + name: z.string().describe('Event hook display name'), + status: z.string().describe('Current event hook status'), + url: z.string().optional().describe('Delivery URL configured for the event hook'), + eventTypes: z.array(z.string()).describe('Subscribed Okta event type identifiers'), + authSchemeType: z.string().optional().describe('Authorization scheme type, if set'), + created: z.string().optional(), + lastUpdated: z.string().optional() +}); + +let mapEventHook = (hook: OktaEventHook) => ({ + eventHookId: hook.id, + name: hook.name, + status: hook.status, + url: hook.channel.config.uri, + eventTypes: hook.events.items, + authSchemeType: hook.channel.config.authScheme?.type, + created: hook.created, + lastUpdated: hook.lastUpdated +}); + +let requireEventHookId = (eventHookId: string | undefined, action: string) => { + if (!eventHookId) { + throw oktaServiceError(`Event hook ID is required for ${action} action`); + } + + return eventHookId; +}; + +export let manageEventHookTool = SlateTool.create(spec, { + name: 'Manage Event Hook', + key: 'manage_event_hook', + description: + 'Create, read, update, delete, list, and run lifecycle operations for Okta event hooks. Event hooks deliver selected Okta system events to an HTTPS endpoint.', + instructions: [ + 'Create and update actions require an HTTPS URL and Okta event type identifiers such as `user.lifecycle.create`.', + 'The verify action requires the destination endpoint to respond to the Okta verification challenge.' + ], + tags: { + destructive: true + } +}) + .input( + z.object({ + action: z + .enum([ + 'create', + 'get', + 'update', + 'delete', + 'list', + 'activate', + 'deactivate', + 'verify' + ]) + .describe('Event hook operation to perform'), + eventHookId: z + .string() + .optional() + .describe( + 'Event hook ID. Required for get, update, delete, activate, deactivate, and verify actions' + ), + name: z + .string() + .optional() + .describe('Event hook name. Required for create and optional for update'), + url: z + .string() + .optional() + .describe('HTTPS delivery URL. Required for create and optional for update'), + eventTypes: z + .array(z.string()) + .optional() + .describe( + 'Okta event types to subscribe to. Required for create and optional for update' + ), + authorizationHeaderValue: z + .string() + .optional() + .describe('Optional Authorization header value Okta sends with event hook requests') + }) + ) + .output( + z.object({ + action: z.string(), + success: z.boolean(), + eventHook: eventHookSchema.optional().describe('Affected event hook'), + eventHooks: z.array(eventHookSchema).optional().describe('Event hooks returned by list') + }) + ) + .handleInvocation(async ctx => { + let client = new OktaClient({ + domain: ctx.config.domain, + token: ctx.auth.token, + authMethod: ctx.auth.authMethod + }); + + let { action, eventHookId, name, url, eventTypes, authorizationHeaderValue } = ctx.input; + + if (action === 'list') { + let hooks = await client.listEventHooks(); + let eventHooks = hooks.map(mapEventHook); + return { + output: { action, success: true, eventHooks }, + message: `Found **${eventHooks.length}** Okta event hook(s).` + }; + } + + if (action === 'create') { + if (!name) throw oktaServiceError('Event hook name is required for create action'); + if (!url) throw oktaServiceError('Event hook HTTPS URL is required for create action'); + if (!eventTypes?.length) { + throw oktaServiceError('At least one event type is required for create action'); + } + + let hook = await client.createEventHook({ + name, + url, + eventTypes, + authorizationHeaderValue + }); + let eventHook = mapEventHook(hook); + return { + output: { action, success: true, eventHook }, + message: `Created Okta event hook **${eventHook.name}** (\`${eventHook.eventHookId}\`).` + }; + } + + let requiredEventHookId = requireEventHookId(eventHookId, action); + + if (action === 'get') { + let hook = await client.getEventHook(requiredEventHookId); + let eventHook = mapEventHook(hook); + return { + output: { action, success: true, eventHook }, + message: `Retrieved Okta event hook **${eventHook.name}** (\`${eventHook.eventHookId}\`).` + }; + } + + if (action === 'update') { + if (!name && !url && !eventTypes?.length && !authorizationHeaderValue) { + throw oktaServiceError( + 'Provide name, url, eventTypes, or authorizationHeaderValue for update action' + ); + } + + let hook = await client.updateEventHook(requiredEventHookId, { + name, + url, + eventTypes, + authorizationHeaderValue + }); + let eventHook = mapEventHook(hook); + return { + output: { action, success: true, eventHook }, + message: `Updated Okta event hook **${eventHook.name}** (\`${eventHook.eventHookId}\`).` + }; + } + + if (action === 'delete') { + await client.deleteEventHook(requiredEventHookId); + return { + output: { + action, + success: true, + eventHook: { + eventHookId: requiredEventHookId, + name: '', + status: 'DELETED', + eventTypes: [] + } + }, + message: `Deleted Okta event hook \`${requiredEventHookId}\`.` + }; + } + + if (action === 'activate') { + let hook = await client.activateEventHook(requiredEventHookId); + let eventHook = mapEventHook(hook); + return { + output: { action, success: true, eventHook }, + message: `Activated Okta event hook **${eventHook.name}** (\`${eventHook.eventHookId}\`).` + }; + } + + if (action === 'deactivate') { + let hook = await client.deactivateEventHook(requiredEventHookId); + let eventHook = mapEventHook(hook); + return { + output: { action, success: true, eventHook }, + message: `Deactivated Okta event hook **${eventHook.name}** (\`${eventHook.eventHookId}\`).` + }; + } + + if (action === 'verify') { + let hook = await client.verifyEventHook(requiredEventHookId); + let eventHook = mapEventHook(hook); + return { + output: { action, success: true, eventHook }, + message: `Requested verification for Okta event hook **${eventHook.name}** (\`${eventHook.eventHookId}\`).` + }; + } + + throw oktaServiceError(`Unknown action: ${action}`); + }) + .build(); diff --git a/integrations/okta/src/tools/manage-group-membership.ts b/integrations/okta/src/tools/manage-group-membership.ts index b4cdbd8dd6..0336fc9cfd 100644 --- a/integrations/okta/src/tools/manage-group-membership.ts +++ b/integrations/okta/src/tools/manage-group-membership.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OktaClient } from '../lib/client'; +import { oktaServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageGroupMembershipTool = SlateTool.create(spec, { @@ -47,13 +48,14 @@ export let manageGroupMembershipTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let { action, groupId, userId } = ctx.input; if (action === 'add') { - if (!userId) throw new Error('User ID is required for add action'); + if (!userId) throw oktaServiceError('User ID is required for add action'); await client.addUserToGroup(groupId, userId); return { output: { groupId, action, success: true }, @@ -62,7 +64,7 @@ export let manageGroupMembershipTool = SlateTool.create(spec, { } if (action === 'remove') { - if (!userId) throw new Error('User ID is required for remove action'); + if (!userId) throw oktaServiceError('User ID is required for remove action'); await client.removeUserFromGroup(groupId, userId); return { output: { groupId, action, success: true }, @@ -103,6 +105,6 @@ export let manageGroupMembershipTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw oktaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/okta/src/tools/manage-group.ts b/integrations/okta/src/tools/manage-group.ts index 44deb15a4b..6e8149ce36 100644 --- a/integrations/okta/src/tools/manage-group.ts +++ b/integrations/okta/src/tools/manage-group.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OktaClient } from '../lib/client'; +import { oktaServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageGroupTool = SlateTool.create(spec, { @@ -33,13 +34,14 @@ export let manageGroupTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let { action, groupId, name, description } = ctx.input; if (action === 'create') { - if (!name) throw new Error('Group name is required for create action'); + if (!name) throw oktaServiceError('Group name is required for create action'); let group = await client.createGroup({ name, description }); return { output: { groupId: group.id, name: group.profile.name, action, success: true }, @@ -48,7 +50,7 @@ export let manageGroupTool = SlateTool.create(spec, { } if (action === 'update') { - if (!groupId) throw new Error('Group ID is required for update action'); + if (!groupId) throw oktaServiceError('Group ID is required for update action'); let group = await client.updateGroup(groupId, { name, description }); return { output: { groupId: group.id, name: group.profile.name, action, success: true }, @@ -57,7 +59,7 @@ export let manageGroupTool = SlateTool.create(spec, { } if (action === 'delete') { - if (!groupId) throw new Error('Group ID is required for delete action'); + if (!groupId) throw oktaServiceError('Group ID is required for delete action'); await client.deleteGroup(groupId); return { output: { groupId, action, success: true }, @@ -65,6 +67,6 @@ export let manageGroupTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw oktaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/okta/src/tools/manage-user-factors.ts b/integrations/okta/src/tools/manage-user-factors.ts index 29055f416d..d269197a0d 100644 --- a/integrations/okta/src/tools/manage-user-factors.ts +++ b/integrations/okta/src/tools/manage-user-factors.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OktaClient } from '../lib/client'; +import { oktaServiceError } from '../lib/errors'; import { spec } from '../spec'; let factorSchema = z.object({ @@ -64,7 +65,8 @@ export let manageUserFactorsTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let { action, userId } = ctx.input; @@ -87,8 +89,12 @@ export let manageUserFactorsTool = SlateTool.create(spec, { } if (action === 'enroll') { - if (!ctx.input.factorType) throw new Error('Factor type is required for enrollment'); - if (!ctx.input.provider) throw new Error('Provider is required for enrollment'); + if (!ctx.input.factorType) { + throw oktaServiceError('Factor type is required for enrollment'); + } + if (!ctx.input.provider) { + throw oktaServiceError('Provider is required for enrollment'); + } let factor = await client.enrollFactor(userId, { factorType: ctx.input.factorType, @@ -123,6 +129,6 @@ export let manageUserFactorsTool = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw oktaServiceError(`Unknown action: ${action}`); }) .build(); diff --git a/integrations/okta/src/tools/query-system-log.ts b/integrations/okta/src/tools/query-system-log.ts index 6d3d162eb8..bfafb8afb1 100644 --- a/integrations/okta/src/tools/query-system-log.ts +++ b/integrations/okta/src/tools/query-system-log.ts @@ -77,7 +77,8 @@ export let querySystemLogTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let result = await client.getSystemLogs({ diff --git a/integrations/okta/src/tools/update-user.ts b/integrations/okta/src/tools/update-user.ts index d690ac594b..75f5a2287b 100644 --- a/integrations/okta/src/tools/update-user.ts +++ b/integrations/okta/src/tools/update-user.ts @@ -42,7 +42,8 @@ export let updateUserTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let profile: Record = {}; diff --git a/integrations/okta/src/tools/user-lifecycle.ts b/integrations/okta/src/tools/user-lifecycle.ts index 201b883b3c..fe333d5ca5 100644 --- a/integrations/okta/src/tools/user-lifecycle.ts +++ b/integrations/okta/src/tools/user-lifecycle.ts @@ -59,7 +59,8 @@ export let userLifecycleTool = SlateTool.create(spec, { .handleInvocation(async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let { userId, action, sendEmail } = ctx.input; diff --git a/integrations/okta/src/triggers/event-hook.ts b/integrations/okta/src/triggers/event-hook.ts index 3b4b74634b..02eefd6a81 100644 --- a/integrations/okta/src/triggers/event-hook.ts +++ b/integrations/okta/src/triggers/event-hook.ts @@ -55,7 +55,8 @@ export let eventHookTrigger = SlateTrigger.create(spec, { autoRegisterWebhook: async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); // Register an event hook with Okta for common event types @@ -111,7 +112,8 @@ export let eventHookTrigger = SlateTrigger.create(spec, { autoUnregisterWebhook: async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); let hookId = ctx.input.registrationDetails?.eventHookId; diff --git a/integrations/okta/src/triggers/system-log-poll.ts b/integrations/okta/src/triggers/system-log-poll.ts index b038641118..4d206713a9 100644 --- a/integrations/okta/src/triggers/system-log-poll.ts +++ b/integrations/okta/src/triggers/system-log-poll.ts @@ -58,7 +58,8 @@ export let systemLogPollTrigger = SlateTrigger.create(spec, { pollEvents: async ctx => { let client = new OktaClient({ domain: ctx.config.domain, - token: ctx.auth.token + token: ctx.auth.token, + authMethod: ctx.auth.authMethod }); // Use the last polled timestamp or default to 10 minutes ago diff --git a/integrations/ollama/README.md b/integrations/ollama/README.md index 37ed256999..468e42b56e 100644 --- a/integrations/ollama/README.md +++ b/integrations/ollama/README.md @@ -1,6 +1,6 @@ # Ollama -Run and manage large language models locally or via Ollama cloud. Generate text completions, conduct chat conversations, and create vector embeddings. Support tool calling (function calling) and structured JSON outputs. Manage models by pulling, pushing, creating, copying, deleting, and listing them. Generate images using experimental image generation models. Provides OpenAI-compatible API endpoints for drop-in integration with existing applications. +Run and manage large language models locally or via Ollama Cloud. Generate text completions, conduct chat conversations, and create vector embeddings. Support tool calling (function calling), structured JSON outputs, log probability metadata, and thinking output where supported by the selected model. Manage models by pulling, pushing, creating, copying, deleting, listing local models, checking running models, showing model details, and reading the Ollama server version. ## License diff --git a/integrations/ollama/docs/SPEC.md b/integrations/ollama/docs/SPEC.md index 3dfc659821..c62384d989 100644 --- a/integrations/ollama/docs/SPEC.md +++ b/integrations/ollama/docs/SPEC.md @@ -1,10 +1,8 @@ -Now I have enough information to write the specification. - # Slates Specification for Ollama ## Overview -Ollama is an open-source platform for running large language models locally or via its cloud service at ollama.com. It provides a REST API for text generation, chat completions, embeddings, and model management. It also offers OpenAI-compatible and Anthropic-compatible API endpoints. +Ollama is an open-source platform for running large language models locally or via its cloud service at ollama.com. It provides a REST API for text generation, chat completions, embeddings, model status, model management, and server version checks. It also offers OpenAI-compatible and Anthropic-compatible API endpoints for applications that already speak those API shapes. ## Authentication @@ -32,11 +30,11 @@ Users can also sign in from their local installation using `ollama signin`, and ### Text Generation -Generate text completions from a prompt using a specified model. Supports configurable parameters such as temperature, system prompt, and prompt template overrides. Responses can be streamed as JSON objects or returned as a single response by setting `stream: false`. Supports multimodal inputs (images) for vision-capable models. +Generate text completions from a prompt using a specified model. Supports configurable parameters such as temperature, system prompt, prompt template overrides, structured output, multimodal image inputs for vision-capable models, thinking output, and log probability metadata. The integration requests non-streaming responses with `stream: false`. ### Chat Conversations -Maintain conversational interactions by providing a history of messages with user, assistant, system, and tool roles. Supports the same model parameters and streaming options as text generation. Multimodal content (text and images) can be included in messages. +Maintain conversational interactions by providing a history of messages with user, assistant, system, and tool roles. Supports model parameters, tool definitions, structured output, multimodal image inputs, thinking output, and log probability metadata. The integration requests non-streaming responses with `stream: false`. ### Tool Calling (Function Calling) @@ -52,24 +50,21 @@ Generate vector embeddings from text using a specified model. Useful for semanti ### Model Management -Manage models programmatically through the API: +Manage models and server state programmatically through the API: - **List models**: Retrieve all locally available models and currently running/loaded models. - **Pull models**: Download models from the Ollama library. - **Push models**: Upload custom models to the Ollama model library (requires an ollama.com account). -- **Create models**: Create new models from a Modelfile, including quantization of existing models and customization of system prompts, parameters, and templates. +- **Create models**: Create new models from an existing model, including quantization, license metadata, message history, and customization of system prompts, parameters, and templates. - **Copy models**: Duplicate a model under a new name. - **Delete models**: Remove models from local storage. -- **Show model details**: Retrieve model metadata including details, modelfile, template, and license information. +- **Show model details**: Retrieve model metadata including details, capabilities, template, license, and verbose model information. +- **Get version**: Retrieve the Ollama server version. ### OpenAI-Compatible API Ollama provides compatibility with parts of the OpenAI API to help connect existing applications to Ollama. Available at `/v1/` prefix, it supports chat completions, text completions, embeddings, and the Responses API. This enables drop-in compatibility with applications and libraries built for OpenAI's API. Tool calling is also supported through this compatibility layer. -### Image Generation - -This endpoint is experimental and may change or be removed in future versions. Generate images using image generation models via the OpenAI-compatible `/v1/images/generations` endpoint. - ## Events The provider does not support events. Ollama does not offer webhooks or purpose-built polling mechanisms for subscribing to changes or notifications. diff --git a/integrations/ollama/package.json b/integrations/ollama/package.json index 09c7b16891..c73b9797d4 100644 --- a/integrations/ollama/package.json +++ b/integrations/ollama/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/ollama/slate.json b/integrations/ollama/slate.json index 8381dcc9ad..8f5d39cf67 100644 --- a/integrations/ollama/slate.json +++ b/integrations/ollama/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/ollama", - "description": "Run and manage large language models locally or via Ollama cloud. Generate text completions, conduct chat conversations, and create vector embeddings. Support tool calling (function calling) and structured JSON outputs. Manage models by pulling, pushing, creating, copying, deleting, and listing them. Generate images using experimental image generation models. Provides OpenAI-compatible API endpoints for drop-in integration with existing applications.", + "description": "Run and manage large language models locally or via Ollama Cloud. Generate text completions, conduct chat conversations, create vector embeddings, and manage local or cloud model operations.", "categories": ["apis-and-http-requests"], "skills": [ "generate text completions", @@ -11,6 +11,7 @@ "pull and push models", "create and delete models", "list available models", - "generate images" + "list running models", + "check Ollama server version" ] } diff --git a/integrations/ollama/src/index.ts b/integrations/ollama/src/index.ts index 5f3a3d6492..0b49b69afa 100644 --- a/integrations/ollama/src/index.ts +++ b/integrations/ollama/src/index.ts @@ -7,6 +7,7 @@ import { deleteModel, generateEmbeddings, generateText, + getVersion, listModels, pullModel, pushModel, @@ -19,6 +20,7 @@ export let provider = Slate.create({ spec, tools: [ generateText, + getVersion, chat, generateEmbeddings, listModels, diff --git a/integrations/ollama/src/lib/client.ts b/integrations/ollama/src/lib/client.ts index 41793797cc..6d0f1bf720 100644 --- a/integrations/ollama/src/lib/client.ts +++ b/integrations/ollama/src/lib/client.ts @@ -1,10 +1,14 @@ import { createAxios } from 'slates'; +import { ollamaApiError, ollamaServiceError } from './errors'; export interface ClientConfig { baseUrl: string; token?: string; } +export type ThinkMode = boolean | 'high' | 'medium' | 'low'; +export type KeepAlive = string | number; + export interface ModelOptions { temperature?: number; topK?: number; @@ -28,11 +32,24 @@ export interface GenerateParams { raw?: boolean; images?: string[]; format?: string | Record; - keepAlive?: string; - think?: boolean; + keepAlive?: KeepAlive; + think?: ThinkMode; + logprobs?: boolean; + topLogprobs?: number; options?: ModelOptions; } +export interface LogprobInfo { + token: string; + logprob: number; + bytes?: number[]; + topLogprobs?: Array<{ + token: string; + logprob: number; + bytes?: number[]; + }>; +} + export interface GenerateResponse { model: string; createdAt: string; @@ -46,6 +63,7 @@ export interface GenerateResponse { promptEvalDuration?: number; evalCount?: number; evalDuration?: number; + logprobs?: LogprobInfo[]; } export interface ChatMessage { @@ -76,8 +94,10 @@ export interface ChatParams { messages: ChatMessage[]; tools?: ToolDefinition[]; format?: string | Record; - keepAlive?: string; - think?: boolean; + keepAlive?: KeepAlive; + think?: ThinkMode; + logprobs?: boolean; + topLogprobs?: number; options?: ModelOptions; } @@ -98,6 +118,7 @@ export interface ChatResponse { promptEvalDuration?: number; evalCount?: number; evalDuration?: number; + logprobs?: LogprobInfo[]; } export interface EmbedParams { @@ -120,10 +141,11 @@ export interface EmbedResponse { export interface ModelInfo { name: string; model: string; - modifiedAt: string; + modifiedAt?: string; size: number; digest: string; details: { + parentModel?: string; format?: string; family?: string; families?: string[]; @@ -139,11 +161,13 @@ export interface RunningModelInfo extends ModelInfo { } export interface ShowModelResponse { - modelfile: string; - parameters: string; - template: string; - system: string; - license: string; + modelfile?: string; + parameters?: string; + template?: string; + system?: string; + license?: string; + modifiedAt?: string; + capabilities?: string[]; details: { parentModel?: string; format?: string; @@ -164,14 +188,21 @@ export interface PullResponse { export interface CreateModelParams { model: string; - modelfile?: string; from?: string; quantize?: string; + license?: string | string[]; system?: string; template?: string; parameters?: Record; + messages?: Array<{ + role: 'system' | 'user' | 'assistant'; + content: string; + }>; } +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + let mapOptions = (options?: ModelOptions): Record | undefined => { if (!options) return undefined; return { @@ -189,6 +220,27 @@ let mapOptions = (options?: ModelOptions): Record | undefined = }; }; +let mapLogprobs = (value: unknown): LogprobInfo[] | undefined => { + if (!Array.isArray(value)) return undefined; + + return value.filter(isRecord).map(item => ({ + token: typeof item.token === 'string' ? item.token : '', + logprob: typeof item.logprob === 'number' ? item.logprob : 0, + bytes: Array.isArray(item.bytes) + ? item.bytes.filter((byte): byte is number => typeof byte === 'number') + : undefined, + topLogprobs: Array.isArray(item.top_logprobs) + ? item.top_logprobs.filter(isRecord).map(top => ({ + token: typeof top.token === 'string' ? top.token : '', + logprob: typeof top.logprob === 'number' ? top.logprob : 0, + bytes: Array.isArray(top.bytes) + ? top.bytes.filter((byte): byte is number => typeof byte === 'number') + : undefined + })) + : undefined + })); +}; + let mapMessageToApi = (msg: ChatMessage): Record => { let result: Record = { role: msg.role, @@ -208,10 +260,41 @@ let mapMessageToApi = (msg: ChatMessage): Record => { return result; }; +let mapModelDetails = (details: any) => ({ + parentModel: details?.parent_model, + format: details?.format, + family: details?.family, + families: details?.families, + parameterSize: details?.parameter_size, + quantizationLevel: details?.quantization_level +}); + +let mapModelInfo = (model: any): ModelInfo => ({ + name: model.name, + model: model.model, + modifiedAt: model.modified_at, + size: model.size, + digest: model.digest, + details: mapModelDetails(model.details) +}); + export class Client { private axios: ReturnType; constructor(config: ClientConfig) { + let baseUrl = config.baseUrl.replace(/\/+$/, ''); + try { + new URL(baseUrl); + } catch (error) { + let serviceError = ollamaServiceError( + 'Ollama baseUrl must be an absolute URL such as http://localhost:11434 or https://ollama.com.' + ); + if (error instanceof Error) { + serviceError.setParent(error); + } + throw serviceError; + } + let headers: Record = { 'Content-Type': 'application/json' }; @@ -219,11 +302,20 @@ export class Client { headers.Authorization = `Bearer ${config.token}`; } this.axios = createAxios({ - baseURL: config.baseUrl, + baseURL: baseUrl, headers }); } + private async request(operation: string, run: () => Promise<{ data: T }>): Promise { + try { + let response = await run(); + return response.data; + } catch (error) { + throw ollamaApiError(error, operation); + } + } + async generate(params: GenerateParams): Promise { let body: Record = { model: params.model, @@ -238,10 +330,13 @@ export class Client { if (params.format !== undefined) body.format = params.format; if (params.keepAlive !== undefined) body.keep_alive = params.keepAlive; if (params.think !== undefined) body.think = params.think; + if (params.logprobs !== undefined) body.logprobs = params.logprobs; + if (params.topLogprobs !== undefined) body.top_logprobs = params.topLogprobs; if (params.options) body.options = mapOptions(params.options); - let response = await this.axios.post('/api/generate', body); - let data = response.data; + let data = await this.request('generate response', () => + this.axios.post('/api/generate', body) + ); return { model: data.model, @@ -255,7 +350,8 @@ export class Client { promptEvalCount: data.prompt_eval_count, promptEvalDuration: data.prompt_eval_duration, evalCount: data.eval_count, - evalDuration: data.eval_duration + evalDuration: data.eval_duration, + logprobs: mapLogprobs(data.logprobs) }; } @@ -269,10 +365,13 @@ export class Client { if (params.format !== undefined) body.format = params.format; if (params.keepAlive !== undefined) body.keep_alive = params.keepAlive; if (params.think !== undefined) body.think = params.think; + if (params.logprobs !== undefined) body.logprobs = params.logprobs; + if (params.topLogprobs !== undefined) body.top_logprobs = params.topLogprobs; if (params.options) body.options = mapOptions(params.options); - let response = await this.axios.post('/api/chat', body); - let data = response.data; + let data = await this.request('generate chat message', () => + this.axios.post('/api/chat', body) + ); let message: ChatResponse['message'] = { role: data.message?.role || 'assistant', @@ -301,7 +400,8 @@ export class Client { promptEvalCount: data.prompt_eval_count, promptEvalDuration: data.prompt_eval_duration, evalCount: data.eval_count, - evalDuration: data.eval_duration + evalDuration: data.eval_duration, + logprobs: mapLogprobs(data.logprobs) }; } @@ -315,8 +415,9 @@ export class Client { if (params.keepAlive !== undefined) body.keep_alive = params.keepAlive; if (params.options) body.options = mapOptions(params.options); - let response = await this.axios.post('/api/embed', body); - let data = response.data; + let data = await this.request('generate embeddings', () => + this.axios.post('/api/embed', body) + ); return { model: data.model, @@ -328,40 +429,16 @@ export class Client { } async listModels(): Promise { - let response = await this.axios.get('/api/tags'); - let models = response.data.models || []; - return models.map((m: any) => ({ - name: m.name, - model: m.model, - modifiedAt: m.modified_at, - size: m.size, - digest: m.digest, - details: { - format: m.details?.format, - family: m.details?.family, - families: m.details?.families, - parameterSize: m.details?.parameter_size, - quantizationLevel: m.details?.quantization_level - } - })); + let data = await this.request('list models', () => this.axios.get('/api/tags')); + let models = data.models || []; + return models.map(mapModelInfo); } async listRunningModels(): Promise { - let response = await this.axios.get('/api/ps'); - let models = response.data.models || []; + let data = await this.request('list running models', () => this.axios.get('/api/ps')); + let models = data.models || []; return models.map((m: any) => ({ - name: m.name, - model: m.model, - modifiedAt: m.modified_at, - size: m.size, - digest: m.digest, - details: { - format: m.details?.format, - family: m.details?.family, - families: m.details?.families, - parameterSize: m.details?.parameter_size, - quantizationLevel: m.details?.quantization_level - }, + ...mapModelInfo(m), expiresAt: m.expires_at, sizeVram: m.size_vram, contextLength: m.context_length @@ -369,55 +446,51 @@ export class Client { } async showModel(modelName: string, verbose?: boolean): Promise { - let body: Record = { name: modelName }; + let body: Record = { model: modelName }; if (verbose) body.verbose = true; - let response = await this.axios.post('/api/show', body); - let data = response.data; + let data = await this.request('show model details', () => + this.axios.post('/api/show', body) + ); return { - modelfile: data.modelfile || '', - parameters: data.parameters || '', - template: data.template || '', - system: data.system || '', - license: data.license || '', - details: { - parentModel: data.details?.parent_model, - format: data.details?.format, - family: data.details?.family, - families: data.details?.families, - parameterSize: data.details?.parameter_size, - quantizationLevel: data.details?.quantization_level - }, + modelfile: data.modelfile, + parameters: data.parameters, + template: data.template, + system: data.system, + license: data.license, + modifiedAt: data.modified_at, + capabilities: data.capabilities, + details: mapModelDetails(data.details), modelInfo: data.model_info }; } async pullModel(modelName: string, insecure?: boolean): Promise { let body: Record = { - name: modelName, + model: modelName, stream: false }; if (insecure !== undefined) body.insecure = insecure; - let response = await this.axios.post('/api/pull', body); + let data = await this.request('pull model', () => this.axios.post('/api/pull', body)); return { - status: response.data.status, - digest: response.data.digest, - total: response.data.total, - completed: response.data.completed + status: data.status, + digest: data.digest, + total: data.total, + completed: data.completed }; } async pushModel(modelName: string, insecure?: boolean): Promise<{ status: string }> { let body: Record = { - name: modelName, + model: modelName, stream: false }; if (insecure !== undefined) body.insecure = insecure; - let response = await this.axios.post('/api/push', body); - return { status: response.data.status }; + let data = await this.request('push model', () => this.axios.post('/api/push', body)); + return { status: data.status }; } async createModel(params: CreateModelParams): Promise<{ status: string }> { @@ -425,27 +498,38 @@ export class Client { model: params.model, stream: false }; - if (params.modelfile !== undefined) body.modelfile = params.modelfile; if (params.from !== undefined) body.from = params.from; if (params.quantize !== undefined) body.quantize = params.quantize; + if (params.license !== undefined) body.license = params.license; if (params.system !== undefined) body.system = params.system; if (params.template !== undefined) body.template = params.template; if (params.parameters !== undefined) body.parameters = params.parameters; + if (params.messages !== undefined) body.messages = params.messages; - let response = await this.axios.post('/api/create', body); - return { status: response.data.status || 'success' }; + let data = await this.request('create model', () => + this.axios.post('/api/create', body) + ); + return { status: data.status || 'success' }; } async copyModel(source: string, destination: string): Promise { - await this.axios.post('/api/copy', { - source, - destination - }); + await this.request('copy model', () => + this.axios.post('/api/copy', { + source, + destination + }) + ); } async deleteModel(modelName: string): Promise { - await this.axios.delete('/api/delete', { - data: { name: modelName } - }); + await this.request('delete model', () => + this.axios.delete('/api/delete', { + data: { model: modelName } + }) + ); + } + + async getVersion(): Promise<{ version: string }> { + return await this.request('get version', () => this.axios.get('/api/version')); } } diff --git a/integrations/ollama/src/lib/errors.ts b/integrations/ollama/src/lib/errors.ts new file mode 100644 index 0000000000..c2f976a1b1 --- /dev/null +++ b/integrations/ollama/src/lib/errors.ts @@ -0,0 +1,81 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractOllamaMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + addDetail(details, data.error); + addDetail(details, data.message); + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getOllamaErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return response?.status ?? error.status; +}; + +export let ollamaServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let ollamaApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getOllamaErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = ollamaServiceError( + `Ollama API ${operation} failed: ${statusLabel}${extractOllamaMessage(error)}` + ); + serviceError.data.reason = 'ollama_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/ollama/src/lib/schemas.ts b/integrations/ollama/src/lib/schemas.ts index ee38162ffc..71bbe967d5 100644 --- a/integrations/ollama/src/lib/schemas.ts +++ b/integrations/ollama/src/lib/schemas.ts @@ -46,8 +46,39 @@ export let modelOptionsSchema = z .optional() .describe('Model generation parameters.'); +export let thinkSchema = z + .union([z.boolean(), z.enum(['high', 'medium', 'low'])]) + .optional() + .describe( + 'Enable thinking output. Use true or one of "high", "medium", or "low" for supported models.' + ); + +export let keepAliveSchema = z + .union([z.string(), z.number()]) + .optional() + .describe('Model keep-alive duration, such as "5m", "1h", or 0 to unload immediately.'); + +export let logprobSchema = z + .object({ + token: z.string().describe('Generated token.'), + logprob: z.number().describe('Log probability for the generated token.'), + bytes: z.array(z.number()).optional().describe('Token byte values.'), + topLogprobs: z + .array( + z.object({ + token: z.string().describe('Alternative token.'), + logprob: z.number().describe('Log probability for the alternative token.'), + bytes: z.array(z.number()).optional().describe('Alternative token byte values.') + }) + ) + .optional() + .describe('Most likely alternative tokens for this token position.') + }) + .describe('Log probability information for one generated token.'); + export let modelDetailsSchema = z .object({ + parentModel: z.string().optional().describe('Parent model this model was derived from.'), format: z.string().optional().describe('Model file format (e.g., gguf).'), family: z.string().optional().describe('Model architecture family (e.g., llama, gemma).'), families: z.array(z.string()).optional().describe('All applicable model families.'), @@ -63,7 +94,10 @@ export let modelInfoSchema = z .object({ name: z.string().describe('Full model name including tag.'), model: z.string().describe('Model identifier.'), - modifiedAt: z.string().describe('ISO 8601 timestamp of last modification.'), + modifiedAt: z + .string() + .optional() + .describe('ISO 8601 timestamp of last modification, when returned by Ollama.'), size: z.number().describe('Model size in bytes.'), digest: z.string().describe('SHA256 digest of the model.'), details: modelDetailsSchema @@ -108,3 +142,12 @@ export let toolDefinitionSchema = z }) }) .describe('A tool definition for function calling.'); + +export let createModelMessageSchema = z + .object({ + role: z + .enum(['system', 'user', 'assistant']) + .describe('Role for a message included in the model template history.'), + content: z.string().describe('Message content.') + }) + .describe('A message to bake into the created model.'); diff --git a/integrations/ollama/src/tools.schema.test.ts b/integrations/ollama/src/tools.schema.test.ts new file mode 100644 index 0000000000..b32226361e --- /dev/null +++ b/integrations/ollama/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Ollama tool input schemas', provider.actions); diff --git a/integrations/ollama/src/tools/chat.ts b/integrations/ollama/src/tools/chat.ts index d77e6f3659..09a451e0b4 100644 --- a/integrations/ollama/src/tools/chat.ts +++ b/integrations/ollama/src/tools/chat.ts @@ -1,7 +1,14 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; -import { chatMessageSchema, modelOptionsSchema, toolDefinitionSchema } from '../lib/schemas'; +import { + chatMessageSchema, + keepAliveSchema, + logprobSchema, + modelOptionsSchema, + thinkSchema, + toolDefinitionSchema +} from '../lib/schemas'; import { spec } from '../spec'; export let chat = SlateTool.create(spec, { @@ -35,14 +42,18 @@ export let chat = SlateTool.create(spec, { .describe( 'Output format: "json" for JSON mode, or a JSON schema object for structured output.' ), - think: z + think: thinkSchema, + logprobs: z .boolean() .optional() - .describe('Enable reasoning/thinking output from the model.'), - keepAlive: z - .string() + .describe('Return log probabilities for generated tokens.'), + topLogprobs: z + .number() + .int() + .positive() .optional() - .describe('How long to keep the model loaded (e.g., "5m", "1h").'), + .describe('Number of likely alternative tokens to return when logprobs is enabled.'), + keepAlive: keepAliveSchema, options: modelOptionsSchema }) ) @@ -77,7 +88,11 @@ export let chat = SlateTool.create(spec, { doneReason: z.string().optional().describe('Reason generation stopped.'), totalDuration: z.number().optional().describe('Total time in nanoseconds.'), promptEvalCount: z.number().optional().describe('Number of prompt tokens evaluated.'), - evalCount: z.number().optional().describe('Number of tokens generated.') + evalCount: z.number().optional().describe('Number of tokens generated.'), + logprobs: z + .array(logprobSchema) + .optional() + .describe('Log probability information when logprobs was requested.') }) ) .handleInvocation(async ctx => { @@ -92,6 +107,8 @@ export let chat = SlateTool.create(spec, { tools: ctx.input.tools, format: ctx.input.format, think: ctx.input.think, + logprobs: ctx.input.logprobs, + topLogprobs: ctx.input.topLogprobs, keepAlive: ctx.input.keepAlive, options: ctx.input.options }); @@ -113,7 +130,8 @@ export let chat = SlateTool.create(spec, { doneReason: result.doneReason, totalDuration: result.totalDuration, promptEvalCount: result.promptEvalCount, - evalCount: result.evalCount + evalCount: result.evalCount, + logprobs: result.logprobs }, message: `Chat response from **${result.model}**: "${contentPreview}"${toolCallInfo}` }; diff --git a/integrations/ollama/src/tools/create-model.ts b/integrations/ollama/src/tools/create-model.ts index 433c655413..6961b51e7e 100644 --- a/integrations/ollama/src/tools/create-model.ts +++ b/integrations/ollama/src/tools/create-model.ts @@ -1,16 +1,18 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; +import { ollamaServiceError } from '../lib/errors'; +import { createModelMessageSchema } from '../lib/schemas'; import { spec } from '../spec'; export let createModel = SlateTool.create(spec, { name: 'Create Model', key: 'create_model', - description: `Create a new model. You can create from a Modelfile, derive from an existing model with a custom system prompt, template, or parameters, or quantize an existing model.`, + description: `Create a new model from an existing model with custom system prompt, template, license, messages, parameters, or quantization settings.`, instructions: [ - 'To derive a model, set **from** to the base model name and optionally customize **system**, **template**, or **parameters**.', + 'To derive a model, set **from** to the base model name and optionally customize **system**, **template**, **parameters**, **messages**, or **license**.', 'To quantize, set **from** to the model to quantize and specify the **quantize** level (e.g., "q4_0", "q5_1").', - 'To create from a Modelfile, provide the full Modelfile content in the **modelfile** field.' + 'Provide at least one model source or customization field besides **modelName**.' ], tags: { destructive: false @@ -20,17 +22,24 @@ export let createModel = SlateTool.create(spec, { z.object({ modelName: z.string().describe('Name for the new model (e.g., "my-custom-model").'), from: z.string().optional().describe('Base model to derive from (e.g., "llama3.2").'), - modelfile: z.string().optional().describe('Full Modelfile content for model creation.'), quantize: z .string() .optional() .describe('Quantization level (e.g., "q4_0", "q4_1", "q5_0", "q5_1", "q8_0").'), + license: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe('License string or list of licenses for the model.'), system: z.string().optional().describe('Custom system prompt for the model.'), template: z.string().optional().describe('Custom prompt template.'), parameters: z .record(z.string(), z.unknown()) .optional() - .describe('Custom model parameters.') + .describe('Custom model parameters.'), + messages: z + .array(createModelMessageSchema) + .optional() + .describe('Message history to use in the created model template.') }) ) .output( @@ -39,6 +48,24 @@ export let createModel = SlateTool.create(spec, { }) ) .handleInvocation(async ctx => { + if ( + ctx.input.from === undefined && + ctx.input.quantize === undefined && + ctx.input.license === undefined && + ctx.input.system === undefined && + ctx.input.template === undefined && + ctx.input.parameters === undefined && + ctx.input.messages === undefined + ) { + throw ollamaServiceError( + 'Provide from, quantize, license, system, template, parameters, or messages when creating a model.' + ); + } + + if (ctx.input.quantize !== undefined && ctx.input.from === undefined) { + throw ollamaServiceError('from is required when quantize is provided.'); + } + let client = new Client({ baseUrl: ctx.config.baseUrl, token: ctx.auth.token @@ -48,11 +75,12 @@ export let createModel = SlateTool.create(spec, { let result = await client.createModel({ model: ctx.input.modelName, from: ctx.input.from, - modelfile: ctx.input.modelfile, quantize: ctx.input.quantize, + license: ctx.input.license, system: ctx.input.system, template: ctx.input.template, - parameters: ctx.input.parameters + parameters: ctx.input.parameters, + messages: ctx.input.messages }); return { diff --git a/integrations/ollama/src/tools/generate-embeddings.ts b/integrations/ollama/src/tools/generate-embeddings.ts index 5db4f1fee9..bb79d9f92f 100644 --- a/integrations/ollama/src/tools/generate-embeddings.ts +++ b/integrations/ollama/src/tools/generate-embeddings.ts @@ -28,7 +28,12 @@ export let generateEmbeddings = SlateTool.create(spec, { .boolean() .optional() .describe('Automatically truncate inputs exceeding context limits. Defaults to true.'), - dimensions: z.number().optional().describe('Desired embedding vector dimensions.'), + dimensions: z + .number() + .int() + .positive() + .optional() + .describe('Desired embedding vector dimensions.'), keepAlive: z .string() .optional() diff --git a/integrations/ollama/src/tools/generate-text.ts b/integrations/ollama/src/tools/generate-text.ts index b2244d0792..6696181df0 100644 --- a/integrations/ollama/src/tools/generate-text.ts +++ b/integrations/ollama/src/tools/generate-text.ts @@ -1,7 +1,12 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; -import { modelOptionsSchema } from '../lib/schemas'; +import { + keepAliveSchema, + logprobSchema, + modelOptionsSchema, + thinkSchema +} from '../lib/schemas'; import { spec } from '../spec'; export let generateText = SlateTool.create(spec, { @@ -39,18 +44,22 @@ export let generateText = SlateTool.create(spec, { .describe( 'Output format: "json" for JSON mode, or a JSON schema object for structured output.' ), - think: z + think: thinkSchema, + logprobs: z .boolean() .optional() - .describe('Enable reasoning/thinking output from the model.'), + .describe('Return log probabilities for generated tokens.'), + topLogprobs: z + .number() + .int() + .positive() + .optional() + .describe('Number of likely alternative tokens to return when logprobs is enabled.'), raw: z .boolean() .optional() .describe('Skip prompt templating and pass the prompt directly.'), - keepAlive: z - .string() - .optional() - .describe('How long to keep the model loaded (e.g., "5m", "1h").'), + keepAlive: keepAliveSchema, options: modelOptionsSchema }) ) @@ -67,7 +76,11 @@ export let generateText = SlateTool.create(spec, { doneReason: z.string().optional().describe('Reason generation stopped.'), totalDuration: z.number().optional().describe('Total time in nanoseconds.'), promptEvalCount: z.number().optional().describe('Number of prompt tokens evaluated.'), - evalCount: z.number().optional().describe('Number of tokens generated.') + evalCount: z.number().optional().describe('Number of tokens generated.'), + logprobs: z + .array(logprobSchema) + .optional() + .describe('Log probability information when logprobs was requested.') }) ) .handleInvocation(async ctx => { @@ -84,6 +97,8 @@ export let generateText = SlateTool.create(spec, { images: ctx.input.images, format: ctx.input.format, think: ctx.input.think, + logprobs: ctx.input.logprobs, + topLogprobs: ctx.input.topLogprobs, raw: ctx.input.raw, keepAlive: ctx.input.keepAlive, options: ctx.input.options @@ -101,7 +116,8 @@ export let generateText = SlateTool.create(spec, { doneReason: result.doneReason, totalDuration: result.totalDuration, promptEvalCount: result.promptEvalCount, - evalCount: result.evalCount + evalCount: result.evalCount, + logprobs: result.logprobs }, message: `Generated text using **${result.model}**.${tokenInfo}` }; diff --git a/integrations/ollama/src/tools/get-version.ts b/integrations/ollama/src/tools/get-version.ts new file mode 100644 index 0000000000..166de7ce4e --- /dev/null +++ b/integrations/ollama/src/tools/get-version.ts @@ -0,0 +1,33 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let getVersion = SlateTool.create(spec, { + name: 'Get Version', + key: 'get_version', + description: 'Retrieve the Ollama server version for health and compatibility checks.', + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + version: z.string().describe('Ollama server version.') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + baseUrl: ctx.config.baseUrl, + token: ctx.auth.token + }); + + let result = await client.getVersion(); + + return { + output: result, + message: `Ollama version **${result.version}**.` + }; + }) + .build(); diff --git a/integrations/ollama/src/tools/index.ts b/integrations/ollama/src/tools/index.ts index ec02728e26..8f5d9104af 100644 --- a/integrations/ollama/src/tools/index.ts +++ b/integrations/ollama/src/tools/index.ts @@ -4,6 +4,7 @@ export * from './create-model'; export * from './delete-model'; export * from './generate-embeddings'; export * from './generate-text'; +export * from './get-version'; export * from './list-models'; export * from './pull-model'; export * from './push-model'; diff --git a/integrations/ollama/src/tools/list-models.ts b/integrations/ollama/src/tools/list-models.ts index cd385510d3..58e7aa019f 100644 --- a/integrations/ollama/src/tools/list-models.ts +++ b/integrations/ollama/src/tools/list-models.ts @@ -30,7 +30,10 @@ export let listModels = SlateTool.create(spec, { z.object({ name: z.string().describe('Full model name including tag.'), model: z.string().describe('Model identifier.'), - modifiedAt: z.string().describe('ISO 8601 timestamp of last modification.'), + modifiedAt: z + .string() + .optional() + .describe('ISO 8601 timestamp of last modification, when returned by Ollama.'), size: z.number().describe('Model size in bytes.'), digest: z.string().describe('SHA256 digest of the model.'), details: modelDetailsSchema @@ -42,7 +45,10 @@ export let listModels = SlateTool.create(spec, { z.object({ name: z.string().describe('Full model name including tag.'), model: z.string().describe('Model identifier.'), - modifiedAt: z.string().describe('ISO 8601 timestamp of last modification.'), + modifiedAt: z + .string() + .optional() + .describe('ISO 8601 timestamp of last modification, when returned by Ollama.'), size: z.number().describe('Model size in bytes.'), digest: z.string().describe('SHA256 digest of the model.'), details: modelDetailsSchema, diff --git a/integrations/ollama/src/tools/show-model.ts b/integrations/ollama/src/tools/show-model.ts index 4b4a3cba3c..7a16eb474c 100644 --- a/integrations/ollama/src/tools/show-model.ts +++ b/integrations/ollama/src/tools/show-model.ts @@ -6,7 +6,7 @@ import { spec } from '../spec'; export let showModel = SlateTool.create(spec, { name: 'Show Model', key: 'show_model', - description: `Retrieve detailed information about a specific model, including its Modelfile, template, system prompt, parameters, license, and architecture details.`, + description: `Retrieve detailed information about a specific model, including template, system prompt, parameters, license, capabilities, architecture details, and verbose metadata.`, tags: { readOnly: true } @@ -24,11 +24,16 @@ export let showModel = SlateTool.create(spec, { ) .output( z.object({ - modelfile: z.string().describe('The Modelfile content for the model.'), - parameters: z.string().describe('Model parameters.'), - template: z.string().describe('Prompt template used by the model.'), - system: z.string().describe('System prompt configured for the model.'), - license: z.string().describe('License information.'), + modelfile: z.string().optional().describe('The Modelfile content for the model.'), + parameters: z.string().optional().describe('Model parameters.'), + template: z.string().optional().describe('Prompt template used by the model.'), + system: z.string().optional().describe('System prompt configured for the model.'), + license: z.string().optional().describe('License information.'), + modifiedAt: z.string().optional().describe('ISO 8601 timestamp of last modification.'), + capabilities: z + .array(z.string()) + .optional() + .describe('Model capabilities such as completion, vision, or tools.'), details: z .object({ parentModel: z.string().optional().describe('Parent model this was derived from.'), @@ -57,10 +62,13 @@ export let showModel = SlateTool.create(spec, { let sizeInfo = result.details.parameterSize ? `, ${result.details.parameterSize} parameters` : ''; + let capabilityInfo = result.capabilities?.length + ? ` Capabilities: ${result.capabilities.join(', ')}.` + : ''; return { output: result, - message: `Model **${ctx.input.modelName}**${familyInfo}${sizeInfo}.` + message: `Model **${ctx.input.modelName}**${familyInfo}${sizeInfo}.${capabilityInfo}` }; }) .build(); diff --git a/integrations/ollama/vitest.config.ts b/integrations/ollama/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/ollama/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/openrouter/README.md b/integrations/openrouter/README.md index 2dc4456362..de81cbef99 100644 --- a/integrations/openrouter/README.md +++ b/integrations/openrouter/README.md @@ -1,6 +1,6 @@ # Openrouter -Access 400+ AI models from dozens of providers through a single unified API. Send chat completions with text, images, and PDFs using an OpenAI-compatible interface. Generate structured JSON outputs, perform tool calling, create text embeddings, and generate images. Route requests with intelligent model selection, fallbacks, and variants for optimizing speed, cost, or quality. Manage API keys programmatically with creation, rotation, and deletion. Discover and filter available models with pricing and capability metadata. Track usage analytics including token counts, costs, and timing data. Manage credits and check balances. Configure guardrails to control spending limits, model access, and data privacy policies. Enable plugins for web search and PDF processing. Send observability traces to external platforms via broadcast webhooks. Enforce zero data retention for privacy. +Access 400+ AI models from dozens of providers through a single unified API. Send chat completions and Responses API requests with text, images, and PDFs using OpenAI-compatible schemas. Generate structured JSON outputs, perform tool calling, create text embeddings, and route requests with provider preferences, fallbacks, and model variants for speed, cost, or quality. Discover models, embedding models, upstream model endpoints, and providers with pricing and capability metadata. Track generation usage, check credits and key metadata, manage API keys, and configure guardrails for spending limits, model access, provider access, and data privacy policies. ## License diff --git a/integrations/openrouter/docs/SPEC.md b/integrations/openrouter/docs/SPEC.md index 13ad84b20c..060c7be1c9 100644 --- a/integrations/openrouter/docs/SPEC.md +++ b/integrations/openrouter/docs/SPEC.md @@ -1,4 +1,4 @@ -Now let me check the features like plugins, guardrails, and the analytics/credits APIs:# Slates Specification for Openrouter +# Slates Specification for Openrouter ## Overview @@ -36,7 +36,7 @@ OpenRouter supports OAuth PKCE to let third-party applications authenticate end- 3. Exchange the authorization code for an API key by POSTing to `https://openrouter.ai/api/v1/auth/keys` with the `code`, optional `code_verifier`, and `code_challenge_method`. 4. Store the API key securely within the user's browser or in your own database, and use it to make OpenRouter requests. -The callback_url is required and must be a URI. Only HTTPS URLs on ports 443 and 3000 are allowed. +The callback_url is required. Public callbacks must use HTTPS. Local development callbacks can use localhost or 127.0.0.1 with any port, which supports Slates CLI OAuth flows. ### 3. Bring Your Own Key (BYOK) @@ -69,10 +69,6 @@ The Responses API supports comprehensive tool calling capabilities, allowing mod Embeddings are numerical representations of text that capture semantic meaning. They convert text into vectors (arrays of numbers) that can be used for various machine learning tasks. OpenRouter provides a unified API to access embedding models from multiple providers. Multiple texts can be sent in a single batch request. -### Image Generation - -OpenRouter supports image generation through select models like Google Gemini image generation models. Configurable parameters include aspect ratio, image size/quality, and number of images. - ### Plugins OpenRouter plugins extend model capabilities with features like web search, PDF processing, and response healing. Enable plugins by adding a plugins array to your request. The `:online` model variant can also be used to automatically attach web search results to prompts. @@ -83,11 +79,11 @@ Guardrails let organizations control how their members and API keys can use Open ### API Key Management -API keys can be created, listed, and deleted programmatically. Enterprise deployments typically require programmatic API key management for automated provisioning, rotation, and lifecycle management. Create a Management API key to manage API keys programmatically, enabling automated key creation, programmatic key rotation, and usage monitoring with automatic limit enforcement. +API keys can be listed, created, retrieved, updated, and deleted programmatically. Enterprise deployments typically require programmatic API key management for automated provisioning, rotation, and lifecycle management. Create a Management API key to manage API keys programmatically, enabling automated key creation, programmatic key rotation, and usage monitoring with automatic limit enforcement. ### Model Discovery -The Models API makes information about all LLMs freely available. It returns a standardized JSON response format that provides comprehensive metadata for each available model, including pricing, context window, and supported parameters. Models can be filtered by supported features like tool calling. +The Models API makes information about all LLMs freely available. It returns a standardized JSON response format that provides comprehensive metadata for each available model, including pricing, context window, architecture, and supported parameters. Models can be filtered by category, supported parameters, output modalities, and sort order. The integration also exposes the user-filtered model list, embedding model discovery, model endpoint discovery, and provider discovery. ### Usage Analytics and Generation Stats diff --git a/integrations/openrouter/package.json b/integrations/openrouter/package.json index 4f4b0dc448..d0463aa62d 100644 --- a/integrations/openrouter/package.json +++ b/integrations/openrouter/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/openrouter/slate.json b/integrations/openrouter/slate.json index 60c41b94b0..55e8a0b320 100644 --- a/integrations/openrouter/slate.json +++ b/integrations/openrouter/slate.json @@ -1,16 +1,19 @@ { "name": "@metorial/openrouter", - "description": "Access 400+ AI models from dozens of providers through a single unified API. Send chat completions with text, images, and PDFs using an OpenAI-compatible interface. Generate structured JSON outputs, perform tool calling, create text embeddings, and generate images. Route requests with intelligent model selection, fallbacks, and variants for optimizing speed, cost, or quality. Manage API keys programmatically with creation, rotation, and deletion. Discover and filter available models with pricing and capability metadata. Track usage analytics including token counts, costs, and timing data. Manage credits and check balances. Configure guardrails to control spending limits, model access, and data privacy policies. Enable plugins for web search and PDF processing. Send observability traces to external platforms via broadcast webhooks. Enforce zero data retention for privacy.", + "description": "Access 400+ AI models from dozens of providers through a single unified API. Send chat completions and Responses API requests with text, images, and PDFs using OpenAI-compatible schemas. Generate structured JSON outputs, perform tool calling, create text embeddings, and route requests with provider preferences, fallbacks, and model variants for speed, cost, or quality. Discover models, embedding models, upstream model endpoints, and providers with pricing and capability metadata. Track generation usage, check credits and key metadata, manage API keys, and configure guardrails for spending limits, model access, provider access, and data privacy policies.", "categories": ["apis-and-http-requests"], "skills": [ "send chat completions", "generate structured outputs", "call model tools", "create text embeddings", - "generate images", + "create responses", "route across AI providers", "manage API keys", "discover available models", + "discover embedding models", + "discover model endpoints", + "list AI providers", "track usage and costs", "configure guardrails" ], diff --git a/integrations/openrouter/src/auth.ts b/integrations/openrouter/src/auth.ts index 86ce04247f..772f4f4071 100644 --- a/integrations/openrouter/src/auth.ts +++ b/integrations/openrouter/src/auth.ts @@ -1,5 +1,11 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { openRouterApiError, openRouterServiceError } from './lib/errors'; + +let recordValue = (value: unknown): Record | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; export let auth = SlateAuth.create() .output( @@ -29,16 +35,23 @@ export let auth = SlateAuth.create() } }); - let response = await axios.get('/auth/key'); - let data = response.data?.data; + let data: Record | undefined; + try { + let response = await axios.get('/key'); + data = recordValue(response.data?.data); + } catch (error) { + throw openRouterApiError(error, 'get API key profile'); + } + + let rateLimit = recordValue(data?.rate_limit); return { profile: { name: data?.label || 'OpenRouter API Key', usageLimit: data?.limit, usage: data?.usage, - rateLimitInterval: data?.rate_limit?.interval, - rateLimitRequests: data?.rate_limit?.requests + rateLimitInterval: rateLimit?.interval, + rateLimitRequests: rateLimit?.requests } }; } @@ -63,11 +76,19 @@ export let auth = SlateAuth.create() baseURL: 'https://openrouter.ai/api/v1' }); - let response = await axios.post('/auth/keys', { - code: ctx.code - }); + let response: any; + try { + response = await axios.post('/auth/keys', { + code: ctx.code + }); + } catch (error) { + throw openRouterApiError(error, 'exchange OAuth code'); + } let token = response.data?.key; + if (typeof token !== 'string' || token.length === 0) { + throw openRouterServiceError('OpenRouter OAuth callback did not return an API key.'); + } return { output: { diff --git a/integrations/openrouter/src/index.ts b/integrations/openrouter/src/index.ts index b852ee5018..f97bf14027 100644 --- a/integrations/openrouter/src/index.ts +++ b/integrations/openrouter/src/index.ts @@ -4,16 +4,23 @@ import { createApiKey, createEmbedding, createGuardrail, + createResponse, deleteApiKey, deleteGuardrail, + getApiKey, getCredits, getGenerationStats, + getGuardrail, getKeyInfo, getModel, listApiKeys, + listEmbeddingModels, listGuardrails, + listModelEndpoints, listModels, + listProviders, sendChatCompletion, + updateApiKey, updateGuardrail } from './tools'; import { creditBalanceChange, inboundWebhook } from './triggers'; @@ -22,17 +29,24 @@ export let provider = Slate.create({ spec, tools: [ sendChatCompletion, + createResponse, createEmbedding, + listEmbeddingModels, listModels, getModel, + listModelEndpoints, + listProviders, getGenerationStats, getCredits, getKeyInfo, listApiKeys, createApiKey, + getApiKey, + updateApiKey, deleteApiKey, listGuardrails, createGuardrail, + getGuardrail, updateGuardrail, deleteGuardrail ], diff --git a/integrations/openrouter/src/lib/client.ts b/integrations/openrouter/src/lib/client.ts index 1a93223bc5..c0f2ee8e70 100644 --- a/integrations/openrouter/src/lib/client.ts +++ b/integrations/openrouter/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { openRouterApiError, openRouterServiceError } from './errors'; export interface ClientConfig { token: string; @@ -6,6 +7,39 @@ export interface ClientConfig { appTitle?: string; } +type JsonRecord = Record; + +let isRecord = (value: unknown): value is JsonRecord => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let dataOrBody = (response: { data?: unknown }) => { + if (isRecord(response.data) && response.data.data !== undefined) { + return response.data.data; + } + return response.data; +}; + +let compact = (body: JsonRecord) => { + let result: JsonRecord = {}; + for (let [key, value] of Object.entries(body)) { + if (value !== undefined) result[key] = value; + } + return result; +}; + +let modelPath = (modelId: string) => { + let separatorIndex = modelId.indexOf('/'); + if (separatorIndex <= 0 || separatorIndex === modelId.length - 1) { + throw openRouterServiceError( + 'OpenRouter model IDs must include an author and slug, for example "openai/gpt-4o-mini".' + ); + } + + let author = modelId.slice(0, separatorIndex); + let slug = modelId.slice(separatorIndex + 1); + return `${encodeURIComponent(author)}/${encodeURIComponent(slug)}`; +}; + export class Client { private axios: ReturnType; @@ -28,232 +62,471 @@ export class Client { }); } - // ---- Chat Completions ---- + private async request(operation: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw openRouterApiError(error, operation); + } + } async createChatCompletion(params: { model: string; messages: Array<{ role: string; - content: string | Record[]; + content: string | JsonRecord[]; name?: string; toolCallId?: string; - toolCalls?: Record[]; + toolCalls?: JsonRecord[]; }>; temperature?: number; maxTokens?: number; + maxCompletionTokens?: number; topP?: number; topK?: number; + topA?: number; + minP?: number; frequencyPenalty?: number; presencePenalty?: number; repetitionPenalty?: number; stop?: string | string[]; seed?: number; - tools?: Record[]; - toolChoice?: string | Record; - responseFormat?: Record; + tools?: JsonRecord[]; + toolChoice?: string | JsonRecord; + parallelToolCalls?: boolean; + responseFormat?: JsonRecord; models?: string[]; route?: string; - provider?: Record; + provider?: JsonRecord; transforms?: string[]; - plugins?: Record[]; - }): Promise> { - let body: Record = { - model: params.model, - messages: params.messages.map(m => { - let msg: Record = { - role: m.role, - content: m.content - }; - if (m.name !== undefined) msg.name = m.name; - if (m.toolCallId !== undefined) msg.tool_call_id = m.toolCallId; - if (m.toolCalls !== undefined) msg.tool_calls = m.toolCalls; - return msg; - }) - }; - - if (params.temperature !== undefined) body.temperature = params.temperature; - if (params.maxTokens !== undefined) body.max_tokens = params.maxTokens; - if (params.topP !== undefined) body.top_p = params.topP; - if (params.topK !== undefined) body.top_k = params.topK; - if (params.frequencyPenalty !== undefined) - body.frequency_penalty = params.frequencyPenalty; - if (params.presencePenalty !== undefined) body.presence_penalty = params.presencePenalty; - if (params.repetitionPenalty !== undefined) - body.repetition_penalty = params.repetitionPenalty; - if (params.stop !== undefined) body.stop = params.stop; - if (params.seed !== undefined) body.seed = params.seed; - if (params.tools !== undefined) body.tools = params.tools; - if (params.toolChoice !== undefined) body.tool_choice = params.toolChoice; - if (params.responseFormat !== undefined) body.response_format = params.responseFormat; - if (params.models !== undefined) body.models = params.models; - if (params.route !== undefined) body.route = params.route; - if (params.provider !== undefined) body.provider = params.provider; - if (params.transforms !== undefined) body.transforms = params.transforms; - if (params.plugins !== undefined) body.plugins = params.plugins; - - let response = await this.axios.post('/chat/completions', body); - return response.data; + plugins?: JsonRecord[]; + reasoning?: JsonRecord; + reasoningEffort?: string; + modalities?: string[]; + metadata?: Record; + serviceTier?: string; + sessionId?: string; + trace?: JsonRecord; + user?: string; + logprobs?: boolean; + topLogprobs?: number; + }): Promise { + return await this.request('create chat completion', async () => { + let body = compact({ + model: params.model, + messages: params.messages.map(m => + compact({ + role: m.role, + content: m.content, + name: m.name, + tool_call_id: m.toolCallId, + tool_calls: m.toolCalls + }) + ), + temperature: params.temperature, + max_tokens: params.maxTokens, + max_completion_tokens: params.maxCompletionTokens, + top_p: params.topP, + top_k: params.topK, + top_a: params.topA, + min_p: params.minP, + frequency_penalty: params.frequencyPenalty, + presence_penalty: params.presencePenalty, + repetition_penalty: params.repetitionPenalty, + stop: params.stop, + seed: params.seed, + tools: params.tools, + tool_choice: params.toolChoice, + parallel_tool_calls: params.parallelToolCalls, + response_format: params.responseFormat, + models: params.models, + route: params.route, + provider: params.provider, + transforms: params.transforms, + plugins: params.plugins, + reasoning: + params.reasoning ?? + (params.reasoningEffort ? { effort: params.reasoningEffort } : undefined), + modalities: params.modalities, + metadata: params.metadata, + service_tier: params.serviceTier, + session_id: params.sessionId, + trace: params.trace, + user: params.user, + logprobs: params.logprobs, + top_logprobs: params.topLogprobs + }); + + let response = await this.axios.post('/chat/completions', body); + return (response.data ?? {}) as JsonRecord; + }); } - // ---- Embeddings ---- + async createResponse(params: { + model?: string; + input: string | JsonRecord[]; + instructions?: string; + maxOutputTokens?: number; + temperature?: number; + topP?: number; + topK?: number; + tools?: JsonRecord[]; + toolChoice?: string | JsonRecord; + parallelToolCalls?: boolean; + provider?: JsonRecord; + plugins?: JsonRecord[]; + reasoning?: JsonRecord; + modalities?: string[]; + metadata?: Record; + previousResponseId?: string; + sessionId?: string; + serviceTier?: string; + stream?: boolean; + text?: JsonRecord; + trace?: JsonRecord; + user?: string; + }): Promise { + return await this.request('create response', async () => { + let response = await this.axios.post( + '/responses', + compact({ + model: params.model, + input: params.input, + instructions: params.instructions, + max_output_tokens: params.maxOutputTokens, + temperature: params.temperature, + top_p: params.topP, + top_k: params.topK, + tools: params.tools, + tool_choice: params.toolChoice, + parallel_tool_calls: params.parallelToolCalls, + provider: params.provider, + plugins: params.plugins, + reasoning: params.reasoning, + modalities: params.modalities, + metadata: params.metadata, + previous_response_id: params.previousResponseId, + session_id: params.sessionId, + service_tier: params.serviceTier, + stream: params.stream, + text: params.text, + trace: params.trace, + user: params.user + }) + ); + return (response.data ?? {}) as JsonRecord; + }); + } async createEmbedding(params: { model: string; input: string | string[]; - }): Promise> { - let body = { - model: params.model, - input: params.input - }; - - let response = await this.axios.post('/embeddings', body); - return response.data; + dimensions?: number; + inputType?: string; + provider?: JsonRecord; + user?: string; + }): Promise { + return await this.request('create embedding', async () => { + let response = await this.axios.post( + '/embeddings', + compact({ + model: params.model, + input: params.input, + dimensions: params.dimensions, + input_type: params.inputType, + provider: params.provider, + user: params.user + }) + ); + return (response.data ?? {}) as JsonRecord; + }); } - // ---- Models ---- - async listModels(params?: { + category?: string; supportedParameters?: string; - }): Promise[]> { - let queryParams: Record = {}; - if (params?.supportedParameters) { - queryParams.supported_parameters = params.supportedParameters; - } + outputModalities?: string; + sort?: string; + userFiltered?: boolean; + }): Promise { + return await this.request('list models', async () => { + let response = await this.axios.get(params?.userFiltered ? '/models/user' : '/models', { + params: compact({ + category: params?.category, + supported_parameters: params?.supportedParameters, + output_modalities: params?.outputModalities, + sort: params?.sort + }) + }); + let data = dataOrBody(response); + return Array.isArray(data) ? (data as JsonRecord[]) : []; + }); + } - let response = await this.axios.get('/models', { params: queryParams }); - return response.data?.data || []; + async listEmbeddingModels(): Promise { + return await this.request('list embedding models', async () => { + let response = await this.axios.get('/embeddings/models'); + let data = dataOrBody(response); + return Array.isArray(data) ? (data as JsonRecord[]) : []; + }); } - async getModel(modelId: string): Promise> { - let response = await this.axios.get(`/models/${modelId}`); - return response.data?.data || response.data; + async getModel(modelId: string): Promise { + return await this.request('get model', async () => { + let response = await this.axios.get(`/model/${modelPath(modelId)}`); + return (dataOrBody(response) ?? {}) as JsonRecord; + }); } - // ---- Generation Stats ---- + async listModelEndpoints(modelId: string): Promise { + return await this.request('list model endpoints', async () => { + let response = await this.axios.get(`/models/${modelPath(modelId)}/endpoints`); + return (dataOrBody(response) ?? {}) as JsonRecord; + }); + } - async getGenerationStats(generationId: string): Promise> { - let response = await this.axios.get(`/generation?id=${encodeURIComponent(generationId)}`); - return response.data?.data || response.data; + async listProviders(): Promise { + return await this.request('list providers', async () => { + let response = await this.axios.get('/providers'); + let data = dataOrBody(response); + return Array.isArray(data) ? (data as JsonRecord[]) : []; + }); } - // ---- Credits ---- + async getGenerationStats(generationId: string): Promise { + return await this.request('get generation stats', async () => { + let response = await this.axios.get('/generation', { + params: { id: generationId } + }); + return (dataOrBody(response) ?? {}) as JsonRecord; + }); + } async getCredits(): Promise<{ totalCredits: number; totalUsage: number }> { - let response = await this.axios.get('/credits'); - let data = response.data?.data || response.data; - return { - totalCredits: data?.total_credits ?? 0, - totalUsage: data?.total_usage ?? 0 - }; + return await this.request('get credits', async () => { + let response = await this.axios.get('/credits'); + let data = dataOrBody(response) as JsonRecord | undefined; + return { + totalCredits: typeof data?.total_credits === 'number' ? data.total_credits : 0, + totalUsage: typeof data?.total_usage === 'number' ? data.total_usage : 0 + }; + }); } - // ---- Auth/Key Info ---- - - async getKeyInfo(): Promise> { - let response = await this.axios.get('/auth/key'); - return response.data?.data || response.data; + async getKeyInfo(): Promise { + return await this.request('get current API key', async () => { + let response = await this.axios.get('/key'); + return (dataOrBody(response) ?? {}) as JsonRecord; + }); } - // ---- API Key Management ---- - async createApiKey(params: { name: string; - limit?: number; - disabled?: boolean; - }): Promise> { - let body: Record = { - name: params.name - }; - if (params.limit !== undefined) body.limit = params.limit; - if (params.disabled !== undefined) body.disabled = params.disabled; + limit?: number | null; + limitReset?: 'daily' | 'weekly' | 'monthly' | null; + includeByokInLimit?: boolean; + expiresAt?: string | null; + workspaceId?: string; + creatorUserId?: string | null; + }): Promise { + return await this.request('create API key', async () => { + let response = await this.axios.post( + '/keys', + compact({ + name: params.name, + limit: params.limit, + limit_reset: params.limitReset, + include_byok_in_limit: params.includeByokInLimit, + expires_at: params.expiresAt, + workspace_id: params.workspaceId, + creator_user_id: params.creatorUserId + }) + ); + return (response.data ?? {}) as JsonRecord; + }); + } - let response = await this.axios.post('/keys', body); - return response.data; + async listApiKeys(params?: { + includeDisabled?: boolean; + offset?: number; + workspaceId?: string; + }): Promise { + return await this.request('list API keys', async () => { + let response = await this.axios.get('/keys', { + params: compact({ + include_disabled: + params?.includeDisabled === undefined ? undefined : String(params.includeDisabled), + offset: params?.offset, + workspace_id: params?.workspaceId + }) + }); + let data = dataOrBody(response); + return Array.isArray(data) ? (data as JsonRecord[]) : []; + }); } - async listApiKeys(): Promise[]> { - let response = await this.axios.get('/keys'); - return response.data?.data || response.data || []; + async getApiKey(keyHash: string): Promise { + return await this.request('get API key', async () => { + let response = await this.axios.get(`/keys/${encodeURIComponent(keyHash)}`); + return (dataOrBody(response) ?? {}) as JsonRecord; + }); } - async deleteApiKey(keyHash: string): Promise { - await this.axios.delete(`/keys/${encodeURIComponent(keyHash)}`); + async updateApiKey( + keyHash: string, + params: { + name?: string; + limit?: number | null; + limitReset?: 'daily' | 'weekly' | 'monthly' | null; + disabled?: boolean; + includeByokInLimit?: boolean; + } + ): Promise { + return await this.request('update API key', async () => { + let response = await this.axios.patch( + `/keys/${encodeURIComponent(keyHash)}`, + compact({ + name: params.name, + limit: params.limit, + limit_reset: params.limitReset, + disabled: params.disabled, + include_byok_in_limit: params.includeByokInLimit + }) + ); + return (dataOrBody(response) ?? {}) as JsonRecord; + }); } - // ---- Guardrails ---- + async deleteApiKey(keyHash: string): Promise { + return await this.request('delete API key', async () => { + let response = await this.axios.delete(`/keys/${encodeURIComponent(keyHash)}`, { + data: {} + }); + let data = response.data as JsonRecord | undefined; + return data?.deleted === true; + }); + } async createGuardrail(params: { name: string; - budgetLimit?: number; - budgetResetInterval?: string; - modelAllowlist?: string[]; - modelDenylist?: string[]; - providerAllowlist?: string[]; - providerDenylist?: string[]; - zdr?: boolean; - }): Promise> { - let body: Record = { - name: params.name - }; - if (params.budgetLimit !== undefined) body.budget_limit = params.budgetLimit; - if (params.budgetResetInterval !== undefined) - body.budget_reset_interval = params.budgetResetInterval; - if (params.modelAllowlist !== undefined) body.model_allowlist = params.modelAllowlist; - if (params.modelDenylist !== undefined) body.model_denylist = params.modelDenylist; - if (params.providerAllowlist !== undefined) - body.provider_allowlist = params.providerAllowlist; - if (params.providerDenylist !== undefined) - body.provider_denylist = params.providerDenylist; - if (params.zdr !== undefined) body.zdr = params.zdr; - - let response = await this.axios.post('/guardrails', body); - return response.data; + description?: string | null; + limitUsd?: number | null; + resetInterval?: 'daily' | 'weekly' | 'monthly'; + allowedModels?: string[] | null; + ignoredModels?: string[] | null; + allowedProviders?: string[] | null; + ignoredProviders?: string[] | null; + enforceZdr?: boolean | null; + enforceZdrAnthropic?: boolean | null; + enforceZdrGoogle?: boolean | null; + enforceZdrOpenAI?: boolean | null; + enforceZdrOther?: boolean | null; + contentFilters?: JsonRecord[] | null; + contentFilterBuiltins?: JsonRecord[] | null; + workspaceId?: string; + }): Promise { + return await this.request('create guardrail', async () => { + let response = await this.axios.post( + '/guardrails', + compact({ + name: params.name, + description: params.description, + limit_usd: params.limitUsd, + reset_interval: params.resetInterval, + allowed_models: params.allowedModels, + ignored_models: params.ignoredModels, + allowed_providers: params.allowedProviders, + ignored_providers: params.ignoredProviders, + enforce_zdr: params.enforceZdr, + enforce_zdr_anthropic: params.enforceZdrAnthropic, + enforce_zdr_google: params.enforceZdrGoogle, + enforce_zdr_openai: params.enforceZdrOpenAI, + enforce_zdr_other: params.enforceZdrOther, + content_filters: params.contentFilters, + content_filter_builtins: params.contentFilterBuiltins, + workspace_id: params.workspaceId + }) + ); + return (dataOrBody(response) ?? {}) as JsonRecord; + }); } - async listGuardrails(): Promise[]> { - let response = await this.axios.get('/guardrails'); - return response.data?.data || response.data || []; + async listGuardrails(params?: { + limit?: number; + offset?: number; + workspaceId?: string; + }): Promise<{ guardrails: JsonRecord[]; totalCount?: number }> { + return await this.request('list guardrails', async () => { + let response = await this.axios.get('/guardrails', { + params: compact({ + limit: params?.limit, + offset: params?.offset, + workspace_id: params?.workspaceId + }) + }); + let body = response.data as JsonRecord | undefined; + return { + guardrails: Array.isArray(body?.data) ? (body.data as JsonRecord[]) : [], + totalCount: typeof body?.total_count === 'number' ? body.total_count : undefined + }; + }); } - async getGuardrail(guardrailId: string): Promise> { - let response = await this.axios.get(`/guardrails/${encodeURIComponent(guardrailId)}`); - return response.data?.data || response.data; + async getGuardrail(guardrailId: string): Promise { + return await this.request('get guardrail', async () => { + let response = await this.axios.get(`/guardrails/${encodeURIComponent(guardrailId)}`); + return (dataOrBody(response) ?? {}) as JsonRecord; + }); } async updateGuardrail( guardrailId: string, params: { name?: string; - budgetLimit?: number; - budgetResetInterval?: string; - modelAllowlist?: string[]; - modelDenylist?: string[]; - providerAllowlist?: string[]; - providerDenylist?: string[]; - zdr?: boolean; + description?: string | null; + limitUsd?: number | null; + resetInterval?: 'daily' | 'weekly' | 'monthly'; + allowedModels?: string[] | null; + ignoredModels?: string[] | null; + allowedProviders?: string[] | null; + ignoredProviders?: string[] | null; + enforceZdr?: boolean | null; + enforceZdrAnthropic?: boolean | null; + enforceZdrGoogle?: boolean | null; + enforceZdrOpenAI?: boolean | null; + enforceZdrOther?: boolean | null; + contentFilters?: JsonRecord[] | null; + contentFilterBuiltins?: JsonRecord[] | null; } - ): Promise> { - let body: Record = {}; - if (params.name !== undefined) body.name = params.name; - if (params.budgetLimit !== undefined) body.budget_limit = params.budgetLimit; - if (params.budgetResetInterval !== undefined) - body.budget_reset_interval = params.budgetResetInterval; - if (params.modelAllowlist !== undefined) body.model_allowlist = params.modelAllowlist; - if (params.modelDenylist !== undefined) body.model_denylist = params.modelDenylist; - if (params.providerAllowlist !== undefined) - body.provider_allowlist = params.providerAllowlist; - if (params.providerDenylist !== undefined) - body.provider_denylist = params.providerDenylist; - if (params.zdr !== undefined) body.zdr = params.zdr; - - let response = await this.axios.put( - `/guardrails/${encodeURIComponent(guardrailId)}`, - body - ); - return response.data; + ): Promise { + return await this.request('update guardrail', async () => { + let response = await this.axios.patch( + `/guardrails/${encodeURIComponent(guardrailId)}`, + compact({ + name: params.name, + description: params.description, + limit_usd: params.limitUsd, + reset_interval: params.resetInterval, + allowed_models: params.allowedModels, + ignored_models: params.ignoredModels, + allowed_providers: params.allowedProviders, + ignored_providers: params.ignoredProviders, + enforce_zdr: params.enforceZdr, + enforce_zdr_anthropic: params.enforceZdrAnthropic, + enforce_zdr_google: params.enforceZdrGoogle, + enforce_zdr_openai: params.enforceZdrOpenAI, + enforce_zdr_other: params.enforceZdrOther, + content_filters: params.contentFilters, + content_filter_builtins: params.contentFilterBuiltins + }) + ); + return (dataOrBody(response) ?? {}) as JsonRecord; + }); } async deleteGuardrail(guardrailId: string): Promise { - await this.axios.delete(`/guardrails/${encodeURIComponent(guardrailId)}`); + await this.request('delete guardrail', async () => { + await this.axios.delete(`/guardrails/${encodeURIComponent(guardrailId)}`); + }); } } diff --git a/integrations/openrouter/src/lib/errors.ts b/integrations/openrouter/src/lib/errors.ts new file mode 100644 index 0000000000..bde2ed13d3 --- /dev/null +++ b/integrations/openrouter/src/lib/errors.ts @@ -0,0 +1,76 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') return; + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data; + let details: string[] = []; + + if (isRecord(data)) { + let nestedError = isRecord(data.error) ? data.error : undefined; + for (let key of ['message', 'code', 'type', 'param']) { + addDetail(details, nestedError?.[key]); + } + for (let key of ['message', 'error_description', 'error']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) return details.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let getStatus = (error: unknown) => { + if (!isRecord(error)) return undefined; + + let response = error.response as ErrorResponse | undefined; + return ( + response?.status ?? error.status ?? (isRecord(error.data) ? error.data.status : undefined) + ); +}; + +export let openRouterServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let openRouterApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = openRouterServiceError( + `OpenRouter API ${operation} failed: ${statusLabel}${extractMessage(error)}` + ); + serviceError.data.reason = 'openrouter_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/openrouter/src/tools.schema.test.ts b/integrations/openrouter/src/tools.schema.test.ts new file mode 100644 index 0000000000..5bf4b92099 --- /dev/null +++ b/integrations/openrouter/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('OpenRouter tool input schemas', provider.actions); diff --git a/integrations/openrouter/src/tools/create-embedding.ts b/integrations/openrouter/src/tools/create-embedding.ts index 37ebafe5d4..d4b72f1551 100644 --- a/integrations/openrouter/src/tools/create-embedding.ts +++ b/integrations/openrouter/src/tools/create-embedding.ts @@ -22,7 +22,23 @@ export let createEmbedding = SlateTool.create(spec, { .union([z.string(), z.array(z.string())]) .describe( 'Text to embed — a single string or an array of strings for batch processing' - ) + ), + dimensions: z + .number() + .min(1) + .optional() + .describe('Number of dimensions for the returned embeddings, when supported'), + inputType: z + .string() + .optional() + .describe( + 'Input type hint for supported models, such as "search_query" or "search_document"' + ), + provider: z + .record(z.string(), z.unknown()) + .optional() + .describe('Provider routing preferences for the embeddings request'), + user: z.string().optional().describe('Stable end-user identifier') }) ) .output( @@ -54,7 +70,11 @@ export let createEmbedding = SlateTool.create(spec, { let result = await client.createEmbedding({ model: ctx.input.model, - input: ctx.input.input ?? '' + input: ctx.input.input ?? '', + dimensions: ctx.input.dimensions, + inputType: ctx.input.inputType, + provider: ctx.input.provider, + user: ctx.input.user }); let rawData = (result.data as Record[]) || []; diff --git a/integrations/openrouter/src/tools/create-response.ts b/integrations/openrouter/src/tools/create-response.ts new file mode 100644 index 0000000000..f39068dd1a --- /dev/null +++ b/integrations/openrouter/src/tools/create-response.ts @@ -0,0 +1,195 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let responseInputItemSchema = z.record(z.string(), z.unknown()); + +let responseUsageSchema = z.object({ + inputTokens: z.number().describe('Input tokens used'), + outputTokens: z.number().describe('Output tokens generated'), + totalTokens: z.number().describe('Total tokens used') +}); + +let extractOutputText = (result: Record) => { + if (typeof result.output_text === 'string') return result.output_text; + + let output = result.output; + if (!Array.isArray(output)) return null; + + for (let item of output) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + let content = (item as Record).content; + if (!Array.isArray(content)) continue; + + for (let part of content) { + if (!part || typeof part !== 'object' || Array.isArray(part)) continue; + let record = part as Record; + if (record.type === 'output_text' && typeof record.text === 'string') { + return record.text; + } + } + } + + return null; +}; + +export let createResponse = SlateTool.create(spec, { + name: 'Create Response', + key: 'create_response', + description: `Create a response using OpenRouter's OpenResponses-compatible API. Use this for current Responses API workflows with text or multimodal inputs, tools, reasoning settings, provider routing, metadata, and session stickiness.`, + instructions: [ + 'Use input as a simple string for one-shot prompts, or an array of response input items for multi-turn or tool workflows.', + 'Use text for structured-output configuration and reasoning for extended-thinking model settings.', + 'Set sessionId when related calls should prefer the same upstream provider.' + ], + constraints: [ + 'Streaming is not supported through this tool; responses are returned in full.' + ], + tags: { + readOnly: false + } +}) + .input( + z.object({ + model: z + .string() + .optional() + .describe('Model ID to use. If omitted, OpenRouter uses the payer default.'), + input: z + .union([z.string(), z.array(responseInputItemSchema)]) + .describe('Response input as a string or array of OpenResponses input items'), + instructions: z.string().optional().describe('System-level instructions'), + maxOutputTokens: z.number().optional().describe('Maximum output tokens'), + temperature: z.number().min(0).max(2).optional().describe('Sampling temperature'), + topP: z.number().min(0).max(1).optional().describe('Nucleus sampling parameter'), + topK: z.number().optional().describe('Top-K sampling parameter'), + tools: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe('Function tools or OpenRouter server tools available to the model'), + toolChoice: z + .union([z.string(), z.record(z.string(), z.unknown())]) + .optional() + .describe('Tool choice setting, such as "auto", or an object forcing a tool'), + parallelToolCalls: z + .boolean() + .optional() + .describe('Whether the model may produce parallel tool calls'), + provider: z + .record(z.string(), z.unknown()) + .optional() + .describe('Provider routing preferences'), + plugins: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe('OpenRouter plugins, such as web or file-parser'), + reasoning: z + .record(z.string(), z.unknown()) + .optional() + .describe('Reasoning configuration for supported models'), + modalities: z + .array(z.enum(['text', 'image'])) + .optional() + .describe('Requested output modalities'), + metadata: z + .record(z.string(), z.string()) + .optional() + .describe('Request metadata, up to 16 key-value pairs'), + previousResponseId: z + .string() + .optional() + .describe('Previous response ID for conversation continuity'), + sessionId: z + .string() + .optional() + .describe('Stable session identifier for provider stickiness and observability'), + serviceTier: z.string().optional().describe('Service tier to use for routing'), + text: z + .record(z.string(), z.unknown()) + .optional() + .describe('Text output configuration, including format and verbosity'), + trace: z.record(z.string(), z.unknown()).optional().describe('Observability metadata'), + user: z.string().optional().describe('Stable end-user identifier') + }) + ) + .output( + z.object({ + responseId: z.string().describe('Response ID'), + status: z.string().optional().describe('Response status'), + model: z.string().optional().describe('Model used'), + outputText: z.string().nullable().describe('Flattened text output when present'), + outputItems: z + .array(z.record(z.string(), z.unknown())) + .describe('Full response output items'), + usage: responseUsageSchema.optional().describe('Token usage'), + openrouterMetadata: z + .record(z.string(), z.unknown()) + .optional() + .describe('OpenRouter routing metadata when returned') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + siteUrl: ctx.config.siteUrl, + appTitle: ctx.config.appTitle + }); + + let result = await client.createResponse({ + model: ctx.input.model, + input: ctx.input.input, + instructions: ctx.input.instructions, + maxOutputTokens: ctx.input.maxOutputTokens, + temperature: ctx.input.temperature, + topP: ctx.input.topP, + topK: ctx.input.topK, + tools: ctx.input.tools, + toolChoice: ctx.input.toolChoice, + parallelToolCalls: ctx.input.parallelToolCalls, + provider: ctx.input.provider, + plugins: ctx.input.plugins, + reasoning: ctx.input.reasoning, + modalities: ctx.input.modalities, + metadata: ctx.input.metadata, + previousResponseId: ctx.input.previousResponseId, + sessionId: ctx.input.sessionId, + serviceTier: ctx.input.serviceTier, + text: ctx.input.text, + trace: ctx.input.trace, + user: ctx.input.user + }); + + let usage = result.usage as Record | undefined; + let outputItems = Array.isArray(result.output) + ? (result.output as Record[]) + : []; + + let output = { + responseId: (result.id as string) || '', + status: (result.status as string) || undefined, + model: (result.model as string) || undefined, + outputText: extractOutputText(result), + outputItems, + ...(usage + ? { + usage: { + inputTokens: + (usage.input_tokens as number) ?? (usage.prompt_tokens as number) ?? 0, + outputTokens: + (usage.output_tokens as number) ?? (usage.completion_tokens as number) ?? 0, + totalTokens: (usage.total_tokens as number) ?? 0 + } + } + : {}), + ...(result.openrouter_metadata + ? { openrouterMetadata: result.openrouter_metadata as Record } + : {}) + }; + + return { + output, + message: `Response **${output.responseId || 'unknown'}** completed with status **${output.status || 'unknown'}**.` + }; + }) + .build(); diff --git a/integrations/openrouter/src/tools/get-key-info.ts b/integrations/openrouter/src/tools/get-key-info.ts index e082661e51..fe057193e1 100644 --- a/integrations/openrouter/src/tools/get-key-info.ts +++ b/integrations/openrouter/src/tools/get-key-info.ts @@ -16,8 +16,28 @@ export let getKeyInfo = SlateTool.create(spec, { z.object({ label: z.string().optional().describe('Key label/name'), usage: z.number().optional().describe('Total credits used by this key'), + usageDaily: z.number().optional().describe('Daily usage for this key'), + usageWeekly: z.number().optional().describe('Weekly usage for this key'), + usageMonthly: z.number().optional().describe('Monthly usage for this key'), + byokUsage: z.number().optional().describe('Total BYOK usage for this key'), limit: z.number().nullable().optional().describe('Spending limit (null = unlimited)'), + limitRemaining: z.number().optional().describe('Remaining spend under the key limit'), + limitReset: z + .string() + .nullable() + .optional() + .describe('Limit reset interval, or null for no reset'), + includeByokInLimit: z + .boolean() + .optional() + .describe('Whether BYOK usage counts toward the key limit'), isFreeTier: z.boolean().optional().describe('Whether the key is on the free tier'), + isManagementKey: z + .boolean() + .optional() + .describe('Whether this key can manage keys, credits, and guardrails'), + isProvisioningKey: z.boolean().optional().describe('Whether this is a provisioning key'), + expiresAt: z.string().nullable().optional().describe('Expiration timestamp'), rateLimitRequests: z .number() .optional() @@ -41,9 +61,29 @@ export let getKeyInfo = SlateTool.create(spec, { let output = { label: (data.label as string) || undefined, - usage: (data.usage as number) || undefined, + usage: typeof data.usage === 'number' ? data.usage : undefined, + usageDaily: typeof data.usage_daily === 'number' ? data.usage_daily : undefined, + usageWeekly: typeof data.usage_weekly === 'number' ? data.usage_weekly : undefined, + usageMonthly: typeof data.usage_monthly === 'number' ? data.usage_monthly : undefined, + byokUsage: typeof data.byok_usage === 'number' ? data.byok_usage : undefined, limit: data.limit !== undefined ? (data.limit as number | null) : undefined, - isFreeTier: (data.is_free_tier as boolean) || undefined, + limitRemaining: + typeof data.limit_remaining === 'number' ? data.limit_remaining : undefined, + limitReset: + data.limit_reset !== undefined ? (data.limit_reset as string | null) : undefined, + includeByokInLimit: + data.include_byok_in_limit !== undefined + ? (data.include_byok_in_limit as boolean) + : undefined, + isFreeTier: data.is_free_tier !== undefined ? (data.is_free_tier as boolean) : undefined, + isManagementKey: + data.is_management_key !== undefined ? (data.is_management_key as boolean) : undefined, + isProvisioningKey: + data.is_provisioning_key !== undefined + ? (data.is_provisioning_key as boolean) + : undefined, + expiresAt: + data.expires_at !== undefined ? (data.expires_at as string | null) : undefined, rateLimitRequests: rateLimit ? (rateLimit.requests as number) || undefined : undefined, rateLimitInterval: rateLimit ? (rateLimit.interval as string) || undefined : undefined }; diff --git a/integrations/openrouter/src/tools/get-model.ts b/integrations/openrouter/src/tools/get-model.ts index 14701ffeba..571fbc2ff1 100644 --- a/integrations/openrouter/src/tools/get-model.ts +++ b/integrations/openrouter/src/tools/get-model.ts @@ -2,6 +2,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; +import { normalizeModel } from './list-models'; export let getModel = SlateTool.create(spec, { name: 'Get Model', @@ -21,6 +22,7 @@ export let getModel = SlateTool.create(spec, { .output( z.object({ modelId: z.string().describe('Unique model identifier'), + canonicalSlug: z.string().optional().describe('Canonical model slug'), name: z.string().optional().describe('Human-readable model name'), description: z.string().optional().describe('Model description'), contextLength: z.number().optional().describe('Maximum context length in tokens'), @@ -44,6 +46,14 @@ export let getModel = SlateTool.create(spec, { architecture: z .object({ modality: z.string().optional().describe('Input/output modality'), + inputModalities: z + .array(z.string()) + .optional() + .describe('Supported input modalities'), + outputModalities: z + .array(z.string()) + .optional() + .describe('Supported output modalities'), tokenizer: z.string().optional().describe('Tokenizer'), instructType: z.string().optional().describe('Instruction type') }) @@ -64,45 +74,11 @@ export let getModel = SlateTool.create(spec, { }); let m = await client.getModel(ctx.input.modelId); - - let pricing = m.pricing as Record | undefined; - let topProvider = m.top_provider as Record | undefined; - let architecture = m.architecture as Record | undefined; + let normalized = normalizeModel(m); let output = { - modelId: (m.id as string) || ctx.input.modelId, - name: (m.name as string) || undefined, - description: (m.description as string) || undefined, - contextLength: (m.context_length as number) || undefined, - ...(pricing - ? { - pricing: { - prompt: (pricing.prompt as string) || undefined, - completion: (pricing.completion as string) || undefined, - image: (pricing.image as string) || undefined, - request: (pricing.request as string) || undefined - } - } - : {}), - ...(topProvider - ? { - topProvider: { - contextLength: (topProvider.context_length as number) || undefined, - maxCompletionTokens: (topProvider.max_completion_tokens as number) || undefined, - isModerated: (topProvider.is_moderated as boolean) || undefined - } - } - : {}), - ...(architecture - ? { - architecture: { - modality: (architecture.modality as string) || undefined, - tokenizer: (architecture.tokenizer as string) || undefined, - instructType: (architecture.instruct_type as string) || undefined - } - } - : {}), - supportedParameters: (m.supported_parameters as string[]) || undefined, + ...normalized, + modelId: normalized.modelId || ctx.input.modelId, createdAt: m.created ? String(m.created) : undefined }; diff --git a/integrations/openrouter/src/tools/index.ts b/integrations/openrouter/src/tools/index.ts index d177c26bfa..37aae95c9a 100644 --- a/integrations/openrouter/src/tools/index.ts +++ b/integrations/openrouter/src/tools/index.ts @@ -1,9 +1,13 @@ export * from './create-embedding'; +export * from './create-response'; export * from './get-credits'; export * from './get-generation-stats'; export * from './get-key-info'; export * from './get-model'; +export * from './list-embedding-models'; +export * from './list-model-endpoints'; export * from './list-models'; +export * from './list-providers'; export * from './manage-api-keys'; export * from './manage-guardrails'; export * from './send-chat-completion'; diff --git a/integrations/openrouter/src/tools/list-embedding-models.ts b/integrations/openrouter/src/tools/list-embedding-models.ts new file mode 100644 index 0000000000..b2f287c561 --- /dev/null +++ b/integrations/openrouter/src/tools/list-embedding-models.ts @@ -0,0 +1,64 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; +import { modelSchema, normalizeModel } from './list-models'; + +export let listEmbeddingModels = SlateTool.create(spec, { + name: 'List Embedding Models', + key: 'list_embedding_models', + description: + 'List OpenRouter models available through the embeddings router, including pricing, context length, architecture, and supported metadata.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + search: z + .string() + .optional() + .describe('Search term to filter embedding models by name or ID'), + maxResults: z + .number() + .optional() + .describe('Maximum number of models to return (default: 50)') + }) + ) + .output( + z.object({ + models: z.array(modelSchema).describe('Available embedding models'), + totalCount: z.number().describe('Total number of matching embedding models') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + siteUrl: ctx.config.siteUrl, + appTitle: ctx.config.appTitle + }); + + let models = (await client.listEmbeddingModels()).map(normalizeModel); + + if (ctx.input.search) { + let searchLower = ctx.input.search.toLowerCase(); + models = models.filter( + model => + model.modelId.toLowerCase().includes(searchLower) || + model.name?.toLowerCase().includes(searchLower) + ); + } + + let totalCount = models.length; + let maxResults = ctx.input.maxResults || 50; + models = models.slice(0, maxResults); + + return { + output: { + models, + totalCount + }, + message: `Found **${totalCount}** embedding model(s). Showing ${models.length}.` + }; + }) + .build(); diff --git a/integrations/openrouter/src/tools/list-model-endpoints.ts b/integrations/openrouter/src/tools/list-model-endpoints.ts new file mode 100644 index 0000000000..7fc38f4c31 --- /dev/null +++ b/integrations/openrouter/src/tools/list-model-endpoints.ts @@ -0,0 +1,54 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +export let listModelEndpoints = SlateTool.create(spec, { + name: 'List Model Endpoints', + key: 'list_model_endpoints', + description: + 'List the upstream provider endpoints available for a specific OpenRouter model. Use this to compare provider routing options, pricing, context limits, and availability for a model.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + modelId: z + .string() + .describe('Model identifier with author and slug, for example "openai/gpt-4o-mini"') + }) + ) + .output( + z.object({ + modelId: z.string().describe('Model ID'), + name: z.string().optional().describe('Model display name'), + description: z.string().optional().describe('Model description'), + endpoints: z + .array(z.record(z.string(), z.unknown())) + .describe('Provider endpoints available for the model') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + siteUrl: ctx.config.siteUrl, + appTitle: ctx.config.appTitle + }); + + let data = await client.listModelEndpoints(ctx.input.modelId); + let endpoints = Array.isArray(data.endpoints) + ? (data.endpoints as Record[]) + : []; + + return { + output: { + modelId: (data.id as string) || ctx.input.modelId, + name: (data.name as string) || undefined, + description: (data.description as string) || undefined, + endpoints + }, + message: `Found **${endpoints.length}** endpoint(s) for **${data.name || ctx.input.modelId}**.` + }; + }) + .build(); diff --git a/integrations/openrouter/src/tools/list-models.ts b/integrations/openrouter/src/tools/list-models.ts index 0e5e90d384..01e9f5f567 100644 --- a/integrations/openrouter/src/tools/list-models.ts +++ b/integrations/openrouter/src/tools/list-models.ts @@ -3,8 +3,9 @@ import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; -let modelSchema = z.object({ +export let modelSchema = z.object({ modelId: z.string().describe('Unique model identifier (e.g., "openai/gpt-4o")'), + canonicalSlug: z.string().optional().describe('Canonical model slug'), name: z.string().optional().describe('Human-readable model name'), description: z.string().optional().describe('Model description'), contextLength: z.number().optional().describe('Maximum context length in tokens'), @@ -37,13 +38,64 @@ let modelSchema = z.object({ .string() .optional() .describe('Model modality (e.g., "text->text", "text+image->text")'), + inputModalities: z.array(z.string()).optional().describe('Supported input modalities'), + outputModalities: z.array(z.string()).optional().describe('Supported output modalities'), tokenizer: z.string().optional().describe('Tokenizer used'), instructType: z.string().optional().describe('Instruction type') }) .optional() - .describe('Model architecture details') + .describe('Model architecture details'), + supportedParameters: z + .array(z.string()) + .optional() + .describe('Supported OpenRouter request parameters') }); +export let normalizeModel = (m: Record) => { + let pricing = m.pricing as Record | undefined; + let topProvider = m.top_provider as Record | undefined; + let architecture = m.architecture as Record | undefined; + + return { + modelId: (m.id as string) || '', + canonicalSlug: (m.canonical_slug as string) || undefined, + name: (m.name as string) || undefined, + description: (m.description as string) || undefined, + contextLength: (m.context_length as number) || undefined, + ...(pricing + ? { + pricing: { + prompt: (pricing.prompt as string) || undefined, + completion: (pricing.completion as string) || undefined, + image: (pricing.image as string) || undefined, + request: (pricing.request as string) || undefined + } + } + : {}), + ...(topProvider + ? { + topProvider: { + contextLength: (topProvider.context_length as number) || undefined, + maxCompletionTokens: (topProvider.max_completion_tokens as number) || undefined, + isModerated: (topProvider.is_moderated as boolean) || undefined + } + } + : {}), + ...(architecture + ? { + architecture: { + modality: (architecture.modality as string) || undefined, + inputModalities: (architecture.input_modalities as string[]) || undefined, + outputModalities: (architecture.output_modalities as string[]) || undefined, + tokenizer: (architecture.tokenizer as string) || undefined, + instructType: (architecture.instruct_type as string) || undefined + } + } + : {}), + supportedParameters: (m.supported_parameters as string[]) || undefined + }; +}; + export let listModels = SlateTool.create(spec, { name: 'List Models', key: 'list_models', @@ -61,7 +113,36 @@ export let listModels = SlateTool.create(spec, { .string() .optional() .describe( - 'Filter models by supported parameter (e.g., "tools", "response_format", "temperature")' + 'Filter models by supported parameter, comma-separated for multiple values (e.g., "tools,response_format")' + ), + category: z + .string() + .optional() + .describe('Filter models by OpenRouter use-case category'), + outputModalities: z + .string() + .optional() + .describe( + 'Filter by output modalities, comma-separated (text, image, audio, embeddings) or "all"' + ), + sort: z + .enum([ + 'pricing-low-to-high', + 'pricing-high-to-low', + 'context-high-to-low', + 'throughput-high-to-low', + 'latency-low-to-high', + 'most-popular', + 'top-weekly', + 'newest' + ]) + .optional() + .describe('Server-side sort order for model discovery'), + userFiltered: z + .boolean() + .optional() + .describe( + 'List models filtered by the authenticated user preferences, privacy settings, and guardrails' ), search: z .string() @@ -87,50 +168,14 @@ export let listModels = SlateTool.create(spec, { }); let rawModels = await client.listModels({ - supportedParameters: ctx.input.supportedParameters + supportedParameters: ctx.input.supportedParameters, + category: ctx.input.category, + outputModalities: ctx.input.outputModalities, + sort: ctx.input.sort, + userFiltered: ctx.input.userFiltered }); - let models = rawModels.map((m: Record) => { - let pricing = m.pricing as Record | undefined; - let topProvider = m.top_provider as Record | undefined; - let architecture = m.architecture as Record | undefined; - - return { - modelId: (m.id as string) || '', - name: (m.name as string) || undefined, - description: (m.description as string) || undefined, - contextLength: (m.context_length as number) || undefined, - ...(pricing - ? { - pricing: { - prompt: (pricing.prompt as string) || undefined, - completion: (pricing.completion as string) || undefined, - image: (pricing.image as string) || undefined, - request: (pricing.request as string) || undefined - } - } - : {}), - ...(topProvider - ? { - topProvider: { - contextLength: (topProvider.context_length as number) || undefined, - maxCompletionTokens: - (topProvider.max_completion_tokens as number) || undefined, - isModerated: (topProvider.is_moderated as boolean) || undefined - } - } - : {}), - ...(architecture - ? { - architecture: { - modality: (architecture.modality as string) || undefined, - tokenizer: (architecture.tokenizer as string) || undefined, - instructType: (architecture.instruct_type as string) || undefined - } - } - : {}) - }; - }); + let models = rawModels.map(normalizeModel); // Client-side search filter if (ctx.input.search) { diff --git a/integrations/openrouter/src/tools/list-providers.ts b/integrations/openrouter/src/tools/list-providers.ts new file mode 100644 index 0000000000..c35f46ede0 --- /dev/null +++ b/integrations/openrouter/src/tools/list-providers.ts @@ -0,0 +1,57 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { Client } from '../lib/client'; +import { spec } from '../spec'; + +let providerSchema = z.object({ + slug: z.string().optional().describe('Provider slug used in routing preferences'), + name: z.string().describe('Provider name'), + headquarters: z.string().optional().describe('Provider headquarters country or region'), + datacenters: z.array(z.string()).optional().describe('Provider datacenter regions'), + privacyPolicyUrl: z.string().optional().describe('Provider privacy policy URL'), + statusPageUrl: z.string().optional().describe('Provider status page URL'), + termsOfServiceUrl: z.string().optional().describe('Provider terms of service URL') +}); + +export let listProviders = SlateTool.create(spec, { + name: 'List Providers', + key: 'list_providers', + description: + 'List OpenRouter upstream model providers and metadata useful for provider routing, privacy review, status checks, and guardrail/provider allowlists.', + tags: { + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + providers: z.array(providerSchema).describe('Available OpenRouter providers'), + totalCount: z.number().describe('Total provider count') + }) + ) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + siteUrl: ctx.config.siteUrl, + appTitle: ctx.config.appTitle + }); + + let providers = (await client.listProviders()).map(provider => ({ + slug: (provider.slug as string) || undefined, + name: (provider.name as string) || 'Unknown provider', + headquarters: (provider.headquarters as string) || undefined, + datacenters: (provider.datacenters as string[]) || undefined, + privacyPolicyUrl: (provider.privacy_policy_url as string) || undefined, + statusPageUrl: (provider.status_page_url as string) || undefined, + termsOfServiceUrl: (provider.terms_of_service_url as string) || undefined + })); + + return { + output: { + providers, + totalCount: providers.length + }, + message: `Found **${providers.length}** provider(s).` + }; + }) + .build(); diff --git a/integrations/openrouter/src/tools/manage-api-keys.ts b/integrations/openrouter/src/tools/manage-api-keys.ts index c084adde94..d492d57df3 100644 --- a/integrations/openrouter/src/tools/manage-api-keys.ts +++ b/integrations/openrouter/src/tools/manage-api-keys.ts @@ -3,33 +3,108 @@ import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; +let numberValue = (value: unknown) => (typeof value === 'number' ? value : undefined); +let stringValue = (value: unknown) => (typeof value === 'string' ? value : undefined); +let booleanValue = (value: unknown) => (typeof value === 'boolean' ? value : undefined); + +let apiKeyOutputSchema = z.object({ + keyHash: z.string().optional().describe('Hash identifier for the key'), + key: z.string().optional().describe('New API key secret, returned only when creating a key'), + name: z.string().optional().describe('API key name'), + label: z.string().optional().describe('API key label'), + usage: z.number().optional().describe('Total credits used by this key'), + usageDaily: z.number().optional().describe('Daily usage for this key'), + usageWeekly: z.number().optional().describe('Weekly usage for this key'), + usageMonthly: z.number().optional().describe('Monthly usage for this key'), + byokUsage: z.number().optional().describe('Total BYOK usage for this key'), + limit: z.number().nullable().optional().describe('Spending limit (null = unlimited)'), + limitRemaining: z.number().optional().describe('Remaining spend under the key limit'), + limitReset: z + .string() + .nullable() + .optional() + .describe('Limit reset interval, or null for no reset'), + includeByokInLimit: z + .boolean() + .optional() + .describe('Whether BYOK usage counts toward this key limit'), + disabled: z.boolean().optional().describe('Whether the key is disabled'), + workspaceId: z.string().optional().describe('Workspace ID for the key'), + creatorUserId: z.string().optional().describe('Creator user ID'), + createdAt: z.string().optional().describe('Creation timestamp'), + updatedAt: z.string().optional().describe('Last update timestamp'), + expiresAt: z.string().nullable().optional().describe('Expiration timestamp') +}); + +let normalizeApiKey = (data: Record, key?: string) => ({ + keyHash: stringValue(data.hash ?? data.key_hash), + key, + name: stringValue(data.name), + label: stringValue(data.label), + usage: numberValue(data.usage), + usageDaily: numberValue(data.usage_daily), + usageWeekly: numberValue(data.usage_weekly), + usageMonthly: numberValue(data.usage_monthly), + byokUsage: numberValue(data.byok_usage), + limit: data.limit !== undefined ? (data.limit as number | null) : undefined, + limitRemaining: numberValue(data.limit_remaining), + limitReset: data.limit_reset !== undefined ? (data.limit_reset as string | null) : undefined, + includeByokInLimit: booleanValue(data.include_byok_in_limit), + disabled: booleanValue(data.disabled), + workspaceId: stringValue(data.workspace_id), + creatorUserId: stringValue(data.creator_user_id), + createdAt: data.created_at ? String(data.created_at) : undefined, + updatedAt: data.updated_at ? String(data.updated_at) : undefined, + expiresAt: data.expires_at !== undefined ? (data.expires_at as string | null) : undefined +}); + +let createKeyFields = { + name: z.string().describe('Name for the new API key'), + limit: z + .number() + .nullable() + .optional() + .describe('Optional spending limit in USD; null means unlimited'), + limitReset: z + .enum(['daily', 'weekly', 'monthly']) + .nullable() + .optional() + .describe('Credit limit reset interval; null means no reset'), + includeByokInLimit: z + .boolean() + .optional() + .describe('Whether BYOK usage should count toward the spending limit'), + expiresAt: z + .string() + .nullable() + .optional() + .describe('Optional UTC ISO timestamp when the API key expires'), + workspaceId: z.string().optional().describe('Workspace to create the key in'), + creatorUserId: z + .string() + .nullable() + .optional() + .describe('Creator user ID for organization-owned keys') +}; + export let listApiKeys = SlateTool.create(spec, { name: 'List API Keys', key: 'list_api_keys', - description: `List all API keys for your OpenRouter account. Returns key metadata including name, usage, limits, and status. Requires a Management API key.`, + description: `List API keys for the authenticated OpenRouter account, including usage, limits, BYOK usage, workspace, disabled state, and expiration metadata. Requires a Management API key.`, tags: { readOnly: true } }) - .input(z.object({})) + .input( + z.object({ + includeDisabled: z.boolean().optional().describe('Whether to include disabled API keys'), + offset: z.number().min(0).optional().describe('Number of API keys to skip'), + workspaceId: z.string().optional().describe('Filter API keys by workspace ID') + }) + ) .output( z.object({ - keys: z - .array( - z.object({ - keyHash: z.string().optional().describe('Hash identifier for the key'), - name: z.string().optional().describe('Name/label of the key'), - usage: z.number().optional().describe('Credits used by this key'), - limit: z - .number() - .nullable() - .optional() - .describe('Credit limit for this key (null = unlimited)'), - disabled: z.boolean().optional().describe('Whether the key is disabled'), - createdAt: z.string().optional().describe('Key creation timestamp') - }) - ) - .describe('List of API keys') + keys: z.array(apiKeyOutputSchema).describe('List of API keys') }) ) .handleInvocation(async ctx => { @@ -39,16 +114,12 @@ export let listApiKeys = SlateTool.create(spec, { appTitle: ctx.config.appTitle }); - let rawKeys = await client.listApiKeys(); - - let keys = (Array.isArray(rawKeys) ? rawKeys : []).map((k: Record) => ({ - keyHash: (k.hash as string) || (k.key_hash as string) || undefined, - name: (k.name as string) || (k.label as string) || undefined, - usage: (k.usage as number) || undefined, - limit: k.limit !== undefined ? (k.limit as number | null) : undefined, - disabled: (k.disabled as boolean) || undefined, - createdAt: k.created_at ? String(k.created_at) : undefined - })); + let rawKeys = await client.listApiKeys({ + includeDisabled: ctx.input.includeDisabled, + offset: ctx.input.offset, + workspaceId: ctx.input.workspaceId + }); + let keys = rawKeys.map(key => normalizeApiKey(key)); return { output: { keys }, @@ -60,29 +131,94 @@ export let listApiKeys = SlateTool.create(spec, { export let createApiKey = SlateTool.create(spec, { name: 'Create API Key', key: 'create_api_key', - description: `Create a new API key for your OpenRouter account. Optionally set a credit limit or create it in a disabled state. Requires a Management API key.`, + description: `Create a new OpenRouter API key. Optionally set an expiration, spending limit, limit reset interval, BYOK-limit behavior, and workspace. Requires a Management API key.`, tags: { destructive: false } +}) + .input(z.object(createKeyFields)) + .output(apiKeyOutputSchema) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + siteUrl: ctx.config.siteUrl, + appTitle: ctx.config.appTitle + }); + + let result = await client.createApiKey(ctx.input); + let data = (result.data as Record) || result; + let output = normalizeApiKey(data, stringValue(result.key)); + + return { + output, + message: `Created API key **${output.name || ctx.input.name}**.` + }; + }) + .build(); + +export let getApiKey = SlateTool.create(spec, { + name: 'Get API Key', + key: 'get_api_key', + description: + 'Retrieve metadata for a single OpenRouter API key by hash. Requires a Management API key.', + tags: { + readOnly: true + } }) .input( z.object({ - name: z.string().describe('Name/label for the new API key'), - limit: z.number().optional().describe('Credit limit for the key (omit for unlimited)'), - disabled: z.boolean().optional().describe('Create the key in a disabled state') + keyHash: z.string().describe('Hash identifier of the API key to retrieve') }) ) - .output( + .output(apiKeyOutputSchema) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + siteUrl: ctx.config.siteUrl, + appTitle: ctx.config.appTitle + }); + + let data = await client.getApiKey(ctx.input.keyHash); + let output = normalizeApiKey(data); + + return { + output, + message: `Retrieved API key **${output.name || output.keyHash || ctx.input.keyHash}**.` + }; + }) + .build(); + +export let updateApiKey = SlateTool.create(spec, { + name: 'Update API Key', + key: 'update_api_key', + description: + 'Update an OpenRouter API key name, spending limit, limit reset interval, disabled state, or BYOK-limit behavior. Requires a Management API key.', + tags: { + destructive: false + } +}) + .input( z.object({ - key: z - .string() + keyHash: z.string().describe('Hash identifier of the API key to update'), + name: z.string().optional().describe('New API key name'), + limit: z + .number() + .nullable() + .optional() + .describe('New spending limit in USD; null means unlimited'), + limitReset: z + .enum(['daily', 'weekly', 'monthly']) + .nullable() + .optional() + .describe('New limit reset interval; null means no reset'), + disabled: z.boolean().optional().describe('Whether to disable this API key'), + includeByokInLimit: z + .boolean() .optional() - .describe('The newly created API key (only returned once — store securely)'), - keyHash: z.string().optional().describe('Hash identifier for the key'), - name: z.string().optional().describe('Name of the created key'), - limit: z.number().nullable().optional().describe('Credit limit for the key') + .describe('Whether BYOK usage should count toward this key limit') }) ) + .output(apiKeyOutputSchema) .handleInvocation(async ctx => { let client = new Client({ token: ctx.auth.token, @@ -90,18 +226,18 @@ export let createApiKey = SlateTool.create(spec, { appTitle: ctx.config.appTitle }); - let result = await client.createApiKey(ctx.input); - let data = (result.data as Record) || result; + let result = await client.updateApiKey(ctx.input.keyHash, { + name: ctx.input.name, + limit: ctx.input.limit, + limitReset: ctx.input.limitReset, + disabled: ctx.input.disabled, + includeByokInLimit: ctx.input.includeByokInLimit + }); + let output = normalizeApiKey(result); return { - output: { - key: (data.key as string) || (result.key as string) || undefined, - keyHash: (data.hash as string) || (data.key_hash as string) || undefined, - name: (data.name as string) || ctx.input.name, - limit: - data.limit !== undefined ? (data.limit as number | null) : (ctx.input.limit ?? null) - }, - message: `Created API key **${ctx.input.name}**.` + output, + message: `Updated API key **${output.name || ctx.input.keyHash}**.` }; }) .build(); @@ -109,7 +245,7 @@ export let createApiKey = SlateTool.create(spec, { export let deleteApiKey = SlateTool.create(spec, { name: 'Delete API Key', key: 'delete_api_key', - description: `Delete an API key by its hash. This is irreversible — the key will immediately stop working. Requires a Management API key.`, + description: `Delete an OpenRouter API key by hash. This is irreversible and the key immediately stops working. Requires a Management API key.`, tags: { destructive: true } @@ -131,10 +267,10 @@ export let deleteApiKey = SlateTool.create(spec, { appTitle: ctx.config.appTitle }); - await client.deleteApiKey(ctx.input.keyHash); + let deleted = await client.deleteApiKey(ctx.input.keyHash); return { - output: { deleted: true }, + output: { deleted }, message: `Deleted API key **${ctx.input.keyHash}**.` }; }) diff --git a/integrations/openrouter/src/tools/manage-guardrails.ts b/integrations/openrouter/src/tools/manage-guardrails.ts index 3792cbfc5c..d3f119b2c7 100644 --- a/integrations/openrouter/src/tools/manage-guardrails.ts +++ b/integrations/openrouter/src/tools/manage-guardrails.ts @@ -3,58 +3,149 @@ import { z } from 'zod'; import { Client } from '../lib/client'; import { spec } from '../spec'; +let contentFilterSchema = z.record(z.string(), z.unknown()); + let guardrailInputSchema = z.object({ name: z.string().optional().describe('Guardrail name'), - budgetLimit: z.number().optional().describe('Spending limit in credits'), - budgetResetInterval: z + description: z.string().nullable().optional().describe('Guardrail description'), + limitUsd: z.number().nullable().optional().describe('Spending limit in USD'), + resetInterval: z .enum(['daily', 'weekly', 'monthly']) .optional() - .describe('How often the budget resets'), - modelAllowlist: z.array(z.string()).optional().describe('Only allow these model IDs'), - modelDenylist: z.array(z.string()).optional().describe('Block these model IDs'), - providerAllowlist: z.array(z.string()).optional().describe('Only allow these providers'), - providerDenylist: z.array(z.string()).optional().describe('Block these providers'), - zdr: z.boolean().optional().describe('Enable Zero Data Retention for this guardrail') + .describe('How often the spending limit resets'), + allowedModels: z + .array(z.string()) + .nullable() + .optional() + .describe('Only allow these model identifiers; null removes the allowlist'), + ignoredModels: z + .array(z.string()) + .nullable() + .optional() + .describe('Exclude these model identifiers from routing; null removes the denylist'), + allowedProviders: z + .array(z.string()) + .nullable() + .optional() + .describe('Only allow these provider IDs; null removes the allowlist'), + ignoredProviders: z + .array(z.string()) + .nullable() + .optional() + .describe('Exclude these provider IDs from routing; null removes the denylist'), + enforceZdr: z + .boolean() + .nullable() + .optional() + .describe('Deprecated global ZDR switch; prefer per-provider ZDR fields'), + enforceZdrAnthropic: z + .boolean() + .nullable() + .optional() + .describe('Whether to enforce zero data retention for Anthropic models'), + enforceZdrGoogle: z + .boolean() + .nullable() + .optional() + .describe('Whether to enforce zero data retention for Google models'), + enforceZdrOpenAI: z + .boolean() + .nullable() + .optional() + .describe('Whether to enforce zero data retention for OpenAI models'), + enforceZdrOther: z + .boolean() + .nullable() + .optional() + .describe('Whether to enforce zero data retention for other providers'), + contentFilters: z + .array(contentFilterSchema) + .nullable() + .optional() + .describe('Custom regex content filters; null removes them on update'), + contentFilterBuiltins: z + .array(contentFilterSchema) + .nullable() + .optional() + .describe('Builtin content filters; null removes them on update'), + workspaceId: z.string().optional().describe('Workspace ID for guardrail creation') }); let guardrailOutputSchema = z.object({ guardrailId: z.string().optional().describe('Unique guardrail identifier'), name: z.string().optional().describe('Guardrail name'), - budgetLimit: z.number().optional().describe('Spending limit'), - budgetResetInterval: z.string().optional().describe('Budget reset interval'), - modelAllowlist: z.array(z.string()).optional().describe('Allowed models'), - modelDenylist: z.array(z.string()).optional().describe('Blocked models'), - providerAllowlist: z.array(z.string()).optional().describe('Allowed providers'), - providerDenylist: z.array(z.string()).optional().describe('Blocked providers'), - zdr: z.boolean().optional().describe('Zero Data Retention enabled'), - createdAt: z.string().optional().describe('Creation timestamp') + workspaceId: z.string().optional().describe('Workspace ID'), + description: z.string().nullable().optional().describe('Guardrail description'), + limitUsd: z.number().nullable().optional().describe('Spending limit in USD'), + resetInterval: z.string().optional().describe('Limit reset interval'), + allowedModels: z.array(z.string()).nullable().optional().describe('Allowed models'), + ignoredModels: z.array(z.string()).nullable().optional().describe('Ignored models'), + allowedProviders: z.array(z.string()).nullable().optional().describe('Allowed providers'), + ignoredProviders: z.array(z.string()).nullable().optional().describe('Ignored providers'), + enforceZdr: z.boolean().nullable().optional().describe('Deprecated global ZDR setting'), + enforceZdrAnthropic: z.boolean().nullable().optional().describe('Anthropic ZDR enforcement'), + enforceZdrGoogle: z.boolean().nullable().optional().describe('Google ZDR enforcement'), + enforceZdrOpenAI: z.boolean().nullable().optional().describe('OpenAI ZDR enforcement'), + enforceZdrOther: z + .boolean() + .nullable() + .optional() + .describe('Other provider ZDR enforcement'), + createdAt: z.string().optional().describe('Creation timestamp'), + updatedAt: z.string().nullable().optional().describe('Last update timestamp') }); +let booleanOrNull = (value: unknown) => + value === null || typeof value === 'boolean' ? value : undefined; + let normalizeGuardrail = (data: Record) => ({ guardrailId: (data.id as string) || undefined, name: (data.name as string) || undefined, - budgetLimit: (data.budget_limit as number) || undefined, - budgetResetInterval: (data.budget_reset_interval as string) || undefined, - modelAllowlist: (data.model_allowlist as string[]) || undefined, - modelDenylist: (data.model_denylist as string[]) || undefined, - providerAllowlist: (data.provider_allowlist as string[]) || undefined, - providerDenylist: (data.provider_denylist as string[]) || undefined, - zdr: (data.zdr as boolean) || undefined, - createdAt: data.created_at ? String(data.created_at) : undefined + workspaceId: (data.workspace_id as string) || undefined, + description: + data.description !== undefined ? (data.description as string | null) : undefined, + limitUsd: data.limit_usd !== undefined ? (data.limit_usd as number | null) : undefined, + resetInterval: (data.reset_interval as string) || undefined, + allowedModels: + data.allowed_models !== undefined ? (data.allowed_models as string[] | null) : undefined, + ignoredModels: + data.ignored_models !== undefined ? (data.ignored_models as string[] | null) : undefined, + allowedProviders: + data.allowed_providers !== undefined + ? (data.allowed_providers as string[] | null) + : undefined, + ignoredProviders: + data.ignored_providers !== undefined + ? (data.ignored_providers as string[] | null) + : undefined, + enforceZdr: booleanOrNull(data.enforce_zdr), + enforceZdrAnthropic: booleanOrNull(data.enforce_zdr_anthropic), + enforceZdrGoogle: booleanOrNull(data.enforce_zdr_google), + enforceZdrOpenAI: booleanOrNull(data.enforce_zdr_openai), + enforceZdrOther: booleanOrNull(data.enforce_zdr_other), + createdAt: data.created_at ? String(data.created_at) : undefined, + updatedAt: data.updated_at !== undefined ? (data.updated_at as string | null) : undefined }); export let listGuardrails = SlateTool.create(spec, { name: 'List Guardrails', key: 'list_guardrails', - description: `List all guardrails configured for your OpenRouter organization. Guardrails control spending limits, model/provider restrictions, and data privacy policies.`, + description: `List OpenRouter guardrails for the authenticated account, including spending limits, model/provider allowlists and denylists, ZDR enforcement, workspace, and pagination metadata. Requires a Management API key.`, tags: { readOnly: true } }) - .input(z.object({})) + .input( + z.object({ + limit: z.number().min(1).max(100).optional().describe('Maximum guardrails to return'), + offset: z.number().min(0).optional().describe('Number of records to skip'), + workspaceId: z.string().optional().describe('Filter guardrails by workspace ID') + }) + ) .output( z.object({ - guardrails: z.array(guardrailOutputSchema).describe('List of guardrails') + guardrails: z.array(guardrailOutputSchema).describe('List of guardrails'), + totalCount: z.number().optional().describe('Total guardrail count') }) ) .handleInvocation(async ctx => { @@ -64,13 +155,15 @@ export let listGuardrails = SlateTool.create(spec, { appTitle: ctx.config.appTitle }); - let rawGuardrails = await client.listGuardrails(); - let guardrails = (Array.isArray(rawGuardrails) ? rawGuardrails : []).map( - normalizeGuardrail - ); + let result = await client.listGuardrails({ + limit: ctx.input.limit, + offset: ctx.input.offset, + workspaceId: ctx.input.workspaceId + }); + let guardrails = result.guardrails.map(normalizeGuardrail); return { - output: { guardrails }, + output: { guardrails, totalCount: result.totalCount }, message: `Found **${guardrails.length}** guardrail(s).` }; }) @@ -79,7 +172,7 @@ export let listGuardrails = SlateTool.create(spec, { export let createGuardrail = SlateTool.create(spec, { name: 'Create Guardrail', key: 'create_guardrail', - description: `Create a new guardrail to control spending limits, model/provider access, and data privacy policies for your OpenRouter organization.`, + description: `Create an OpenRouter guardrail to control spending, provider/model routing, content filters, workspace scope, and zero-data-retention requirements. Requires a Management API key.`, tags: { destructive: false } @@ -95,17 +188,24 @@ export let createGuardrail = SlateTool.create(spec, { let result = await client.createGuardrail({ name: ctx.input.name!, - budgetLimit: ctx.input.budgetLimit, - budgetResetInterval: ctx.input.budgetResetInterval, - modelAllowlist: ctx.input.modelAllowlist, - modelDenylist: ctx.input.modelDenylist, - providerAllowlist: ctx.input.providerAllowlist, - providerDenylist: ctx.input.providerDenylist, - zdr: ctx.input.zdr + description: ctx.input.description, + limitUsd: ctx.input.limitUsd, + resetInterval: ctx.input.resetInterval, + allowedModels: ctx.input.allowedModels, + ignoredModels: ctx.input.ignoredModels, + allowedProviders: ctx.input.allowedProviders, + ignoredProviders: ctx.input.ignoredProviders, + enforceZdr: ctx.input.enforceZdr, + enforceZdrAnthropic: ctx.input.enforceZdrAnthropic, + enforceZdrGoogle: ctx.input.enforceZdrGoogle, + enforceZdrOpenAI: ctx.input.enforceZdrOpenAI, + enforceZdrOther: ctx.input.enforceZdrOther, + contentFilters: ctx.input.contentFilters, + contentFilterBuiltins: ctx.input.contentFilterBuiltins, + workspaceId: ctx.input.workspaceId }); - let data = (result.data as Record) || result; - let output = normalizeGuardrail(data); + let output = normalizeGuardrail(result); return { output, @@ -114,20 +214,50 @@ export let createGuardrail = SlateTool.create(spec, { }) .build(); +export let getGuardrail = SlateTool.create(spec, { + name: 'Get Guardrail', + key: 'get_guardrail', + description: 'Retrieve a single OpenRouter guardrail by ID. Requires a Management API key.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + guardrailId: z.string().describe('ID of the guardrail to retrieve') + }) + ) + .output(guardrailOutputSchema) + .handleInvocation(async ctx => { + let client = new Client({ + token: ctx.auth.token, + siteUrl: ctx.config.siteUrl, + appTitle: ctx.config.appTitle + }); + + let data = await client.getGuardrail(ctx.input.guardrailId); + let output = normalizeGuardrail(data); + + return { + output, + message: `Retrieved guardrail **${output.name || ctx.input.guardrailId}**.` + }; + }) + .build(); + export let updateGuardrail = SlateTool.create(spec, { name: 'Update Guardrail', key: 'update_guardrail', - description: `Update an existing guardrail's configuration including spending limits, model/provider restrictions, and data privacy settings.`, + description: `Update an OpenRouter guardrail's name, description, spending limit, provider/model restrictions, content filters, or ZDR settings. Requires a Management API key.`, tags: { destructive: false } }) .input( - z - .object({ - guardrailId: z.string().describe('ID of the guardrail to update') - }) - .merge(guardrailInputSchema) + z.object({ + guardrailId: z.string().describe('ID of the guardrail to update'), + ...guardrailInputSchema.shape + }) ) .output(guardrailOutputSchema) .handleInvocation(async ctx => { @@ -139,17 +269,23 @@ export let updateGuardrail = SlateTool.create(spec, { let result = await client.updateGuardrail(ctx.input.guardrailId, { name: ctx.input.name, - budgetLimit: ctx.input.budgetLimit, - budgetResetInterval: ctx.input.budgetResetInterval, - modelAllowlist: ctx.input.modelAllowlist, - modelDenylist: ctx.input.modelDenylist, - providerAllowlist: ctx.input.providerAllowlist, - providerDenylist: ctx.input.providerDenylist, - zdr: ctx.input.zdr + description: ctx.input.description, + limitUsd: ctx.input.limitUsd, + resetInterval: ctx.input.resetInterval, + allowedModels: ctx.input.allowedModels, + ignoredModels: ctx.input.ignoredModels, + allowedProviders: ctx.input.allowedProviders, + ignoredProviders: ctx.input.ignoredProviders, + enforceZdr: ctx.input.enforceZdr, + enforceZdrAnthropic: ctx.input.enforceZdrAnthropic, + enforceZdrGoogle: ctx.input.enforceZdrGoogle, + enforceZdrOpenAI: ctx.input.enforceZdrOpenAI, + enforceZdrOther: ctx.input.enforceZdrOther, + contentFilters: ctx.input.contentFilters, + contentFilterBuiltins: ctx.input.contentFilterBuiltins }); - let data = (result.data as Record) || result; - let output = normalizeGuardrail(data); + let output = normalizeGuardrail(result); return { output, @@ -161,7 +297,7 @@ export let updateGuardrail = SlateTool.create(spec, { export let deleteGuardrail = SlateTool.create(spec, { name: 'Delete Guardrail', key: 'delete_guardrail', - description: `Delete a guardrail by its ID. This is irreversible — all restrictions from this guardrail will be removed immediately.`, + description: `Delete an OpenRouter guardrail by ID. This is irreversible and removes its restrictions immediately. Requires a Management API key.`, tags: { destructive: true } diff --git a/integrations/openrouter/src/tools/send-chat-completion.ts b/integrations/openrouter/src/tools/send-chat-completion.ts index d0e72b9d40..b2d210dcc5 100644 --- a/integrations/openrouter/src/tools/send-chat-completion.ts +++ b/integrations/openrouter/src/tools/send-chat-completion.ts @@ -38,6 +38,8 @@ let toolDefinitionSchema = z.object({ let providerPreferencesSchema = z .object({ order: z.array(z.string()).optional().describe('Ordered list of provider names to prefer'), + only: z.array(z.string()).optional().describe('Only route to these provider names'), + ignore: z.array(z.string()).optional().describe('Do not route to these provider names'), allow_fallbacks: z .boolean() .optional() @@ -53,7 +55,12 @@ let providerPreferencesSchema = z data_collection: z .enum(['allow', 'deny']) .optional() - .describe('Data collection policy for providers') + .describe('Data collection policy for providers'), + sort: z + .enum(['price', 'throughput', 'latency']) + .optional() + .describe('Provider sorting preference'), + zdr: z.boolean().optional().describe('Require zero data retention providers') }) .describe('Provider routing preferences'); @@ -73,7 +80,12 @@ let responseFormatSchema = z let pluginSchema = z .object({ - id: z.string().describe('Plugin ID (e.g., "web-search")'), + id: z + .string() + .describe( + 'Plugin ID (e.g., "web", "file-parser", "response-healing", "context-compression")' + ), + enabled: z.boolean().optional().describe('Whether this plugin is enabled'), config: z .record(z.string(), z.unknown()) .optional() @@ -81,6 +93,16 @@ let pluginSchema = z }) .describe('Plugin to enable for this request'); +let reasoningSchema = z + .object({ + effort: z + .enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) + .optional() + .describe('Reasoning effort for models that support extended thinking'), + max_tokens: z.number().optional().describe('Maximum reasoning tokens') + }) + .describe('Reasoning configuration'); + export let sendChatCompletion = SlateTool.create(spec, { name: 'Send Chat Completion', key: 'send_chat_completion', @@ -112,12 +134,24 @@ export let sendChatCompletion = SlateTool.create(spec, { .max(2) .optional() .describe('Sampling temperature (0-2). Higher values make output more random.'), - maxTokens: z.number().optional().describe('Maximum number of tokens to generate'), + maxTokens: z + .number() + .optional() + .describe('Deprecated OpenRouter max_tokens field; prefer maxCompletionTokens'), + maxCompletionTokens: z + .number() + .optional() + .describe('Maximum number of tokens to generate in the completion'), topP: z.number().min(0).max(1).optional().describe('Nucleus sampling threshold (0-1)'), topK: z .number() .optional() .describe('Top-K sampling: limits token selection to K most likely tokens'), + topA: z.number().optional().describe('Top-A sampling threshold for supported providers'), + minP: z + .number() + .optional() + .describe('Minimum probability threshold for supported providers'), frequencyPenalty: z .number() .optional() @@ -142,6 +176,10 @@ export let sendChatCompletion = SlateTool.create(spec, { .describe( 'Controls tool selection: "auto", "none", "required", or force a specific function' ), + parallelToolCalls: z + .boolean() + .optional() + .describe('Whether the model may produce multiple tool calls in one response'), responseFormat: responseFormatSchema .optional() .describe('Enforce structured JSON output'), @@ -157,11 +195,42 @@ export let sendChatCompletion = SlateTool.create(spec, { transforms: z .array(z.string()) .optional() - .describe('Request transforms (e.g., ["middle-out"] for context compression)'), + .describe('Legacy request transforms; prefer the context-compression plugin'), plugins: z .array(pluginSchema) .optional() - .describe('Plugins to enable (e.g., web search, PDF processing)') + .describe('Plugins to enable (e.g., web search, PDF processing)'), + reasoning: reasoningSchema.optional().describe('Reasoning model configuration'), + reasoningEffort: z + .enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) + .optional() + .describe('Shorthand for reasoning.effort'), + modalities: z + .array(z.enum(['text', 'image', 'audio'])) + .optional() + .describe('Requested output modalities for supported models'), + metadata: z + .record(z.string(), z.string()) + .optional() + .describe('Metadata for the request, up to 16 key-value pairs'), + serviceTier: z.string().optional().describe('Service tier to use for routing'), + sessionId: z + .string() + .optional() + .describe('Stable session identifier for provider stickiness and observability'), + trace: z + .record(z.string(), z.unknown()) + .optional() + .describe('Observability trace metadata'), + user: z + .string() + .optional() + .describe('Stable end-user identifier used to help detect and prevent abuse'), + logprobs: z.boolean().optional().describe('Return token log probabilities'), + topLogprobs: z + .number() + .optional() + .describe('Number of top log probabilities to return, when supported') }) ) .output( @@ -234,8 +303,11 @@ export let sendChatCompletion = SlateTool.create(spec, { }>; temperature?: number; maxTokens?: number; + maxCompletionTokens?: number; topP?: number; topK?: number; + topA?: number; + minP?: number; frequencyPenalty?: number; presencePenalty?: number; repetitionPenalty?: number; @@ -243,12 +315,23 @@ export let sendChatCompletion = SlateTool.create(spec, { seed?: number; tools?: Record[]; toolChoice?: string | Record; + parallelToolCalls?: boolean; responseFormat?: Record; models?: string[]; route?: string; provider?: Record; transforms?: string[]; plugins?: Record[]; + reasoning?: Record; + reasoningEffort?: string; + modalities?: string[]; + metadata?: Record; + serviceTier?: string; + sessionId?: string; + trace?: Record; + user?: string; + logprobs?: boolean; + topLogprobs?: number; } = { model: ctx.input.model, messages: ctx.input.messages.map(m => ({ @@ -260,8 +343,13 @@ export let sendChatCompletion = SlateTool.create(spec, { })), ...(ctx.input.temperature !== undefined ? { temperature: ctx.input.temperature } : {}), ...(ctx.input.maxTokens !== undefined ? { maxTokens: ctx.input.maxTokens } : {}), + ...(ctx.input.maxCompletionTokens !== undefined + ? { maxCompletionTokens: ctx.input.maxCompletionTokens } + : {}), ...(ctx.input.topP !== undefined ? { topP: ctx.input.topP } : {}), ...(ctx.input.topK !== undefined ? { topK: ctx.input.topK } : {}), + ...(ctx.input.topA !== undefined ? { topA: ctx.input.topA } : {}), + ...(ctx.input.minP !== undefined ? { minP: ctx.input.minP } : {}), ...(ctx.input.frequencyPenalty !== undefined ? { frequencyPenalty: ctx.input.frequencyPenalty } : {}), @@ -275,6 +363,9 @@ export let sendChatCompletion = SlateTool.create(spec, { ...(ctx.input.seed !== undefined ? { seed: ctx.input.seed } : {}), ...(ctx.input.tools !== undefined ? { tools: ctx.input.tools } : {}), ...(ctx.input.toolChoice !== undefined ? { toolChoice: ctx.input.toolChoice } : {}), + ...(ctx.input.parallelToolCalls !== undefined + ? { parallelToolCalls: ctx.input.parallelToolCalls } + : {}), ...(ctx.input.responseFormat !== undefined ? { responseFormat: ctx.input.responseFormat } : {}), @@ -282,7 +373,19 @@ export let sendChatCompletion = SlateTool.create(spec, { ...(ctx.input.route !== undefined ? { route: ctx.input.route } : {}), ...(ctx.input.provider !== undefined ? { provider: ctx.input.provider } : {}), ...(ctx.input.transforms !== undefined ? { transforms: ctx.input.transforms } : {}), - ...(ctx.input.plugins !== undefined ? { plugins: ctx.input.plugins } : {}) + ...(ctx.input.plugins !== undefined ? { plugins: ctx.input.plugins } : {}), + ...(ctx.input.reasoning !== undefined ? { reasoning: ctx.input.reasoning } : {}), + ...(ctx.input.reasoningEffort !== undefined + ? { reasoningEffort: ctx.input.reasoningEffort } + : {}), + ...(ctx.input.modalities !== undefined ? { modalities: ctx.input.modalities } : {}), + ...(ctx.input.metadata !== undefined ? { metadata: ctx.input.metadata } : {}), + ...(ctx.input.serviceTier !== undefined ? { serviceTier: ctx.input.serviceTier } : {}), + ...(ctx.input.sessionId !== undefined ? { sessionId: ctx.input.sessionId } : {}), + ...(ctx.input.trace !== undefined ? { trace: ctx.input.trace } : {}), + ...(ctx.input.user !== undefined ? { user: ctx.input.user } : {}), + ...(ctx.input.logprobs !== undefined ? { logprobs: ctx.input.logprobs } : {}), + ...(ctx.input.topLogprobs !== undefined ? { topLogprobs: ctx.input.topLogprobs } : {}) }; let result = await client.createChatCompletion(request); diff --git a/integrations/openrouter/vitest.config.ts b/integrations/openrouter/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/openrouter/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/opsgenie/README.md b/integrations/opsgenie/README.md index 19018d1b5a..712a4cb105 100644 --- a/integrations/opsgenie/README.md +++ b/integrations/opsgenie/README.md @@ -1,12 +1,12 @@ # Opsgenie -Create, manage, and resolve alerts and incidents with priority levels, responders, and rich metadata. Configure on-call schedules with rotations and overrides, and query who is currently on-call. Define escalation policies to notify responders in order when alerts go unacknowledged. Manage teams, users, services, and integrations. Set up per-user notification rules with contact method preferences. Receive webhook notifications for alert activity such as creation, acknowledgment, and closure. +Create, manage, and resolve alerts and incidents with priority levels, responders, and rich metadata. Track asynchronous alert and incident request status. Configure on-call schedules with rotations and temporary overrides, and query who is currently on-call. Define escalation policies to notify responders in order when alerts go unacknowledged. Manage teams, users, and services. Receive webhook notifications for alert activity such as creation, acknowledgment, and closure. ## Tools ### Alert Action -Perform an action on an existing alert: close, acknowledge, unacknowledge, snooze, assign ownership, escalate, add a note, or add/remove tags. All actions are processed asynchronously. +Perform an action on an existing alert: close, acknowledge, unacknowledge, snooze, assign ownership, escalate, add a note, add/remove tags, or delete. All mutating actions are processed asynchronously. ### Create Alert @@ -20,10 +20,18 @@ Create a new incident in OpsGenie. Incidents are higher-severity events that may Retrieve detailed information about a specific alert. Supports lookup by alert ID, tiny ID, or alias. +### Get Alert Request Status + +Check the processing status of an asynchronous alert request, such as create, delete, acknowledge, close, snooze, assign, or tag updates. + ### Get Incident Retrieve detailed information about a specific incident. Only available on Standard and Enterprise plans. +### Get Incident Request Status + +Check the processing status of an asynchronous incident request, such as create, delete, resolve, close, or add note. + ### Get On-Call Query who is currently on-call or who is next on-call for a specific schedule. Returns the on-call participants. Use "current" to see who is on call now, or "next" to see who will be on call next. @@ -76,6 +84,10 @@ Create, update, or delete an escalation policy. Escalation policies define the o Create, update, or delete an on-call schedule. When creating, provide a name and optionally rotations and timezone. When updating, provide the schedule identifier and fields to change. When deleting, provide the identifier with the delete action. +### Manage Schedule Override + +Create, get, update, delete, or list temporary on-call overrides for an Opsgenie schedule. + ### Manage Service Create, update, or delete a service. Services represent business services impacted by incidents. Only available on Standard and Enterprise plans. diff --git a/integrations/opsgenie/docs/SPEC.md b/integrations/opsgenie/docs/SPEC.md index 391c676d15..3296b2df91 100644 --- a/integrations/opsgenie/docs/SPEC.md +++ b/integrations/opsgenie/docs/SPEC.md @@ -25,15 +25,15 @@ Access rights for account-level API keys include: Read (alerts, incidents, confi ### Alert Management -Create, retrieve, update, close, acknowledge, and delete alerts. Alert creation, deletion, and action requests are processed asynchronously. Alerts support rich metadata including message, description, priority (P1–P5), responders (teams, users, escalations, schedules), tags, custom actions, visibility controls, and custom key-value details. Additional alert actions include snoozing, assigning ownership, adding notes, and adding tags. +Create, retrieve, update, close, acknowledge, and delete alerts. Alert creation, deletion, and action requests are processed asynchronously, and request IDs can be checked for final status and alert IDs. Alerts support rich metadata including message, description, priority (P1–P5), responders (teams, users, escalations, schedules), tags, custom actions, visibility controls, and custom key-value details. Additional alert actions include snoozing, assigning ownership, adding notes, and adding tags. ### Incident Management -The Incident API is only available to Standard and Enterprise plans. Create and manage incidents with responders, priority levels, tags, impacted services, status page entries, and stakeholder notifications. Incidents can be resolved and their associated alerts retrieved. +The Incident API is only available to Standard and Enterprise plans. Create and manage incidents with responders, priority levels, tags, impacted services, status page entries, and stakeholder notifications. Incident create, delete, close, resolve, and note requests are asynchronous and can be checked by request ID. Incidents can be resolved and retrieved. ### On-Call Schedule Management -Create, update, and delete schedules programmatically. Quickly adapt on-call personnel based on shifts or role changes. Schedules support rotations (daily, weekly, monthly, custom) with multiple participants, and overrides for temporary coverage changes. You can also query who is currently on-call and who is next on-call for a given schedule. +Create, update, and delete schedules programmatically. Quickly adapt on-call personnel based on shifts or role changes. Schedules support rotations (daily, weekly, monthly, custom) with multiple participants, and overrides for temporary coverage changes. Overrides can be created, listed, retrieved, updated, and deleted. You can also query who is currently on-call and who is next on-call for a given schedule. ### Escalation Policy Management @@ -51,13 +51,9 @@ Create, retrieve, update, and delete users. Manage user contact methods, notific The Service API is only available to Standard and Enterprise plans. Create, update, delete, and list services associated with teams, including tags and descriptions. -### Integration Management +### Deferred Admin Surfaces -Programmatically create, list, update, enable/disable, and authenticate integrations. Configure integration-level access permissions (read, write, delete, configuration access) and define responders and notification suppression settings. The Integration API does not support Zendesk, Slack, and Incoming Call integrations. - -### Notification Rule Management - -Configure per-user notification rules that control how and when users receive notifications based on alert criteria, including notification steps with delays and contact method preferences. +Opsgenie also documents integration management, contacts, notification rules, forwarding rules, reporting, heartbeats, and maintenance windows. Those surfaces are intentionally not exposed here because they are admin-heavy, account-policy-sensitive, or lower-frequency than the practical incident response and on-call workflows covered by the tools. ## Events diff --git a/integrations/opsgenie/package.json b/integrations/opsgenie/package.json index 6548cef712..19849751e8 100644 --- a/integrations/opsgenie/package.json +++ b/integrations/opsgenie/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/opsgenie/slate.json b/integrations/opsgenie/slate.json index 8d12821995..b6fa4f33c8 100644 --- a/integrations/opsgenie/slate.json +++ b/integrations/opsgenie/slate.json @@ -1,6 +1,6 @@ { "name": "@atlassian/opsgenie", - "description": "Create, manage, and resolve alerts and incidents with priority levels, responders, and rich metadata. Configure on-call schedules with rotations and overrides, and query who is currently on-call. Define escalation policies to notify responders in order when alerts go unacknowledged. Manage teams, users, services, and integrations. Set up per-user notification rules with contact method preferences. Receive webhook notifications for alert activity such as creation, acknowledgment, and closure.", + "description": "Create, manage, and resolve alerts and incidents with priority levels, responders, and rich metadata. Track asynchronous request status. Configure on-call schedules with rotations and overrides, and query who is currently on-call. Define escalation policies to notify responders in order when alerts go unacknowledged. Manage teams, users, and services. Receive webhook notifications for alert activity such as creation, acknowledgment, and closure.", "categories": ["apis-and-http-requests", "email-and-messaging"], "skills": [ "create and manage alerts", @@ -10,9 +10,8 @@ "manage teams and members", "manage users and roles", "query on-call responders", - "configure notification rules", - "manage services", - "manage integrations" + "manage schedule overrides", + "manage services" ], "logoUrl": "https://provider-logos.metorial-cdn.com/opsgenie.png" } diff --git a/integrations/opsgenie/src/index.ts b/integrations/opsgenie/src/index.ts index 20ee67198b..ca92ab53db 100644 --- a/integrations/opsgenie/src/index.ts +++ b/integrations/opsgenie/src/index.ts @@ -5,7 +5,9 @@ import { createAlert, createIncident, getAlert, + getAlertRequestStatus, getIncident, + getIncidentRequestStatus, getOnCall, getTeam, getUser, @@ -19,6 +21,7 @@ import { listUsers, manageEscalation, manageSchedule, + manageScheduleOverride, manageService, manageTeam, manageUser, @@ -34,13 +37,16 @@ export let provider = Slate.create({ listAlerts, updateAlert, alertAction, + getAlertRequestStatus, createIncident, getIncident, listIncidents, incidentAction, + getIncidentRequestStatus, manageSchedule, listSchedules, getOnCall, + manageScheduleOverride, manageEscalation, listEscalations, manageTeam, diff --git a/integrations/opsgenie/src/lib/client.ts b/integrations/opsgenie/src/lib/client.ts index 3566e46636..575e00ad1e 100644 --- a/integrations/opsgenie/src/lib/client.ts +++ b/integrations/opsgenie/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { opsgenieApiError } from './errors'; let BASE_URLS: Record = { us: 'https://api.opsgenie.com', @@ -17,6 +18,11 @@ export class OpsGenieClient { 'Content-Type': 'application/json' } }); + + this.http.interceptors.response.use( + response => response, + error => Promise.reject(opsgenieApiError(error)) + ); } // ─── Alerts ────────────────────────────────────────────────── @@ -344,7 +350,7 @@ export class OpsGenieClient { async getAlertRequestStatus(requestId: string) { let response = await this.http.get(`/v2/alerts/requests/${requestId}`); - return response.data.data; + return response.data; } async listAlertLogs( @@ -459,6 +465,11 @@ export class OpsGenieClient { return response.data; } + async getIncidentRequestStatus(requestId: string) { + let response = await this.http.get(`/v1/incidents/requests/${requestId}`); + return response.data; + } + // ─── Schedules ────────────────────────────────────────────── async createSchedule(data: { @@ -564,6 +575,81 @@ export class OpsGenieClient { return response.data.data; } + // ─── Schedule Overrides ───────────────────────────────────── + + async createScheduleOverride( + scheduleIdentifier: string, + params: { scheduleIdentifierType?: string }, + data: { + alias?: string; + user: { type: string; id?: string; username?: string }; + startDate: string; + endDate: string; + rotations?: Array<{ id?: string; name?: string }>; + } + ) { + let response = await this.http.post( + `/v2/schedules/${encodeURIComponent(scheduleIdentifier)}/overrides`, + data, + { params } + ); + return response.data; + } + + async getScheduleOverride( + scheduleIdentifier: string, + alias: string, + params: { scheduleIdentifierType?: string } = {} + ) { + let response = await this.http.get( + `/v2/schedules/${encodeURIComponent(scheduleIdentifier)}/overrides/${encodeURIComponent(alias)}`, + { params } + ); + return response.data.data; + } + + async updateScheduleOverride( + scheduleIdentifier: string, + alias: string, + params: { scheduleIdentifierType?: string }, + data: { + user: { type: string; id?: string; username?: string }; + startDate: string; + endDate: string; + rotations?: Array<{ id?: string; name?: string }>; + } + ) { + let response = await this.http.put( + `/v2/schedules/${encodeURIComponent(scheduleIdentifier)}/overrides/${encodeURIComponent(alias)}`, + data, + { params } + ); + return response.data; + } + + async deleteScheduleOverride( + scheduleIdentifier: string, + alias: string, + params: { scheduleIdentifierType?: string } = {} + ) { + let response = await this.http.delete( + `/v2/schedules/${encodeURIComponent(scheduleIdentifier)}/overrides/${encodeURIComponent(alias)}`, + { params } + ); + return response.data; + } + + async listScheduleOverrides( + scheduleIdentifier: string, + params: { scheduleIdentifierType?: string } = {} + ) { + let response = await this.http.get( + `/v2/schedules/${encodeURIComponent(scheduleIdentifier)}/overrides`, + { params } + ); + return response.data.data; + } + // ─── Schedule Rotations ───────────────────────────────────── async createRotation( diff --git a/integrations/opsgenie/src/lib/errors.ts b/integrations/opsgenie/src/lib/errors.ts new file mode 100644 index 0000000000..791eefac6b --- /dev/null +++ b/integrations/opsgenie/src/lib/errors.ts @@ -0,0 +1,77 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) details.push(detail); +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) collectDetails(item, details); + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.message); + addDetail(details, value.error); + addDetail(details, value.code); + addDetail(details, value.reason); + collectDetails(value.errors, details); +}; + +let extractOpsgenieMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let details: string[] = []; + + collectDetails(response?.data, details); + + if (details.length > 0) return details.join(' - '); + if (error instanceof Error && error.message) return error.message; + return 'Unknown error'; +}; + +let statusLabelFor = (response?: ErrorResponse) => + response?.status !== undefined + ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + +let upstreamCodeFor = (response?: ErrorResponse) => { + if (!isRecord(response?.data)) return undefined; + + let code = response.data.code ?? response.data.errorCode; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +}; + +export let opsgenieServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let opsgenieApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) return error; + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let serviceError = opsgenieServiceError( + `Opsgenie API ${operation} failed: ${statusLabelFor(response)}${extractOpsgenieMessage(error)}` + ); + serviceError.data.reason = 'opsgenie_api_error'; + serviceError.data.upstreamStatus = response?.status; + serviceError.data.upstreamCode = upstreamCodeFor(response); + + if (error instanceof Error) serviceError.setParent(error); + + return serviceError; +}; diff --git a/integrations/opsgenie/src/tools.schema.test.ts b/integrations/opsgenie/src/tools.schema.test.ts new file mode 100644 index 0000000000..df12b284dc --- /dev/null +++ b/integrations/opsgenie/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Opsgenie tool input schemas', provider.actions); diff --git a/integrations/opsgenie/src/tools/alert-action.ts b/integrations/opsgenie/src/tools/alert-action.ts index c44fb6ffda..31a33e0fb7 100644 --- a/integrations/opsgenie/src/tools/alert-action.ts +++ b/integrations/opsgenie/src/tools/alert-action.ts @@ -1,12 +1,13 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OpsGenieClient } from '../lib/client'; +import { opsgenieServiceError } from '../lib/errors'; import { spec } from '../spec'; export let alertAction = SlateTool.create(spec, { name: 'Alert Action', key: 'alert_action', - description: `Perform an action on an existing alert: close, acknowledge, unacknowledge, snooze, assign ownership, escalate, add a note, or add/remove tags. All actions are processed asynchronously.`, + description: `Perform an action on an existing alert: close, acknowledge, unacknowledge, snooze, assign ownership, escalate, add a note, add/remove tags, or delete. All mutating actions are processed asynchronously.`, instructions: [ 'Choose exactly one action to perform on the alert.', 'For "snooze", provide snoozeEndTime as an ISO 8601 datetime.', @@ -14,7 +15,8 @@ export let alertAction = SlateTool.create(spec, { 'For "escalate", provide escalationId or escalationName.', 'For "add_note", provide note text.', 'For "add_tags", provide an array of tags to add.', - 'For "remove_tags", provide an array of tags to remove.' + 'For "remove_tags", provide an array of tags to remove.', + 'For "delete", use identifierType "id" or "tiny".' ] }) .input( @@ -34,7 +36,8 @@ export let alertAction = SlateTool.create(spec, { 'escalate', 'add_note', 'add_tags', - 'remove_tags' + 'remove_tags', + 'delete' ]) .describe('Action to perform on the alert'), note: z @@ -95,7 +98,7 @@ export let alertAction = SlateTool.create(spec, { break; case 'snooze': if (!ctx.input.snoozeEndTime) { - throw new Error('snoozeEndTime is required for the snooze action.'); + throw opsgenieServiceError('snoozeEndTime is required for the snooze action.'); } response = await client.snoozeAlert(id, idType, { endTime: ctx.input.snoozeEndTime, @@ -104,7 +107,9 @@ export let alertAction = SlateTool.create(spec, { break; case 'assign': if (!ctx.input.ownerUsername && !ctx.input.ownerId) { - throw new Error('ownerUsername or ownerId is required for the assign action.'); + throw opsgenieServiceError( + 'ownerUsername or ownerId is required for the assign action.' + ); } response = await client.assignAlert(id, idType, { owner: ctx.input.ownerId @@ -115,7 +120,7 @@ export let alertAction = SlateTool.create(spec, { break; case 'escalate': if (!ctx.input.escalationId && !ctx.input.escalationName) { - throw new Error( + throw opsgenieServiceError( 'escalationId or escalationName is required for the escalate action.' ); } @@ -128,7 +133,7 @@ export let alertAction = SlateTool.create(spec, { break; case 'add_note': if (!ctx.input.note) { - throw new Error('note is required for the add_note action.'); + throw opsgenieServiceError('note is required for the add_note action.'); } response = await client.addNoteToAlert(id, idType, { note: ctx.input.note, @@ -138,7 +143,7 @@ export let alertAction = SlateTool.create(spec, { break; case 'add_tags': if (!ctx.input.tags || ctx.input.tags.length === 0) { - throw new Error('tags array is required for the add_tags action.'); + throw opsgenieServiceError('tags array is required for the add_tags action.'); } response = await client.addTagsToAlert(id, idType, { tags: ctx.input.tags, @@ -147,7 +152,7 @@ export let alertAction = SlateTool.create(spec, { break; case 'remove_tags': if (!ctx.input.tags || ctx.input.tags.length === 0) { - throw new Error('tags array is required for the remove_tags action.'); + throw opsgenieServiceError('tags array is required for the remove_tags action.'); } response = await client.removeTagsFromAlert(id, idType, { tags: ctx.input.tags.join(','), @@ -155,6 +160,15 @@ export let alertAction = SlateTool.create(spec, { source: ctx.input.source }); break; + case 'delete': + if (idType === 'alias') { + throw opsgenieServiceError('Alert delete only supports id or tiny identifierType.'); + } + response = await client.deleteAlert(id, idType, { + user: ctx.input.user, + source: ctx.input.source + }); + break; } return { diff --git a/integrations/opsgenie/src/tools/get-alert-request-status.ts b/integrations/opsgenie/src/tools/get-alert-request-status.ts new file mode 100644 index 0000000000..2c1ace95f6 --- /dev/null +++ b/integrations/opsgenie/src/tools/get-alert-request-status.ts @@ -0,0 +1,57 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { OpsGenieClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getAlertRequestStatus = SlateTool.create(spec, { + name: 'Get Alert Request Status', + key: 'get_alert_request_status', + description: + 'Check the processing status of an asynchronous alert request, such as create, delete, acknowledge, close, snooze, assign, or tag updates.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + requestId: z.string().describe('Request ID returned by an asynchronous alert operation') + }) + ) + .output( + z.object({ + requestId: z.string().describe('Request ID that was checked'), + success: z.boolean().optional().describe('Whether the request was processed'), + isSuccess: z.boolean().optional().describe('Whether processing succeeded'), + action: z.string().optional().describe('Action that was processed'), + status: z.string().optional().describe('Status message returned by Opsgenie'), + alertId: z.string().optional().describe('Alert ID produced or affected by the request'), + alias: z.string().optional().describe('Alert alias produced or affected by the request'), + processedAt: z.string().optional().describe('When the request was processed'), + integrationId: z.string().optional().describe('Opsgenie integration ID') + }) + ) + .handleInvocation(async ctx => { + let client = new OpsGenieClient({ + token: ctx.auth.token, + instance: ctx.config.instance + }); + + let response = await client.getAlertRequestStatus(ctx.input.requestId); + let data = response.data ?? {}; + + return { + output: { + requestId: response.requestId ?? ctx.input.requestId, + success: data.success, + isSuccess: data.isSuccess, + action: data.action, + status: data.status, + alertId: data.alertId || undefined, + alias: data.alias || undefined, + processedAt: data.processedAt, + integrationId: data.integrationId + }, + message: `Alert request \`${ctx.input.requestId}\` status: ${data.status ?? 'unknown'}` + }; + }) + .build(); diff --git a/integrations/opsgenie/src/tools/get-incident-request-status.ts b/integrations/opsgenie/src/tools/get-incident-request-status.ts new file mode 100644 index 0000000000..613da3c75d --- /dev/null +++ b/integrations/opsgenie/src/tools/get-incident-request-status.ts @@ -0,0 +1,61 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { OpsGenieClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getIncidentRequestStatus = SlateTool.create(spec, { + name: 'Get Incident Request Status', + key: 'get_incident_request_status', + description: + 'Check the processing status of an asynchronous incident request, such as create, delete, resolve, close, or add note.', + constraints: ['Requires Standard or Enterprise plan.'], + tags: { + readOnly: true + } +}) + .input( + z.object({ + requestId: z + .string() + .describe('Request ID returned by an asynchronous incident operation') + }) + ) + .output( + z.object({ + requestId: z.string().describe('Request ID that was checked'), + success: z.boolean().optional().describe('Whether the request was processed'), + isSuccess: z.boolean().optional().describe('Whether processing succeeded'), + action: z.string().optional().describe('Action that was processed'), + status: z.string().optional().describe('Status message returned by Opsgenie'), + incidentId: z + .string() + .optional() + .describe('Incident ID produced or affected by the request'), + processedAt: z.string().optional().describe('When the request was processed'), + integrationId: z.string().optional().describe('Opsgenie integration ID') + }) + ) + .handleInvocation(async ctx => { + let client = new OpsGenieClient({ + token: ctx.auth.token, + instance: ctx.config.instance + }); + + let response = await client.getIncidentRequestStatus(ctx.input.requestId); + let data = response.data ?? {}; + + return { + output: { + requestId: response.requestId ?? ctx.input.requestId, + success: data.success, + isSuccess: data.isSuccess, + action: data.action, + status: data.status, + incidentId: data.incidentId || undefined, + processedAt: data.processedAt, + integrationId: data.integrationId + }, + message: `Incident request \`${ctx.input.requestId}\` status: ${data.status ?? 'unknown'}` + }; + }) + .build(); diff --git a/integrations/opsgenie/src/tools/incident-action.ts b/integrations/opsgenie/src/tools/incident-action.ts index b8eaae06bb..1989887d60 100644 --- a/integrations/opsgenie/src/tools/incident-action.ts +++ b/integrations/opsgenie/src/tools/incident-action.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OpsGenieClient } from '../lib/client'; +import { opsgenieServiceError } from '../lib/errors'; import { spec } from '../spec'; export let incidentAction = SlateTool.create(spec, { @@ -54,7 +55,7 @@ export let incidentAction = SlateTool.create(spec, { break; case 'add_note': if (!ctx.input.note) { - throw new Error('note is required for the add_note action.'); + throw opsgenieServiceError('note is required for the add_note action.'); } response = await client.addNoteToIncident(id, idType, { note: ctx.input.note }); break; diff --git a/integrations/opsgenie/src/tools/index.ts b/integrations/opsgenie/src/tools/index.ts index 6d1dbe79a4..2e4bdc01ed 100644 --- a/integrations/opsgenie/src/tools/index.ts +++ b/integrations/opsgenie/src/tools/index.ts @@ -2,7 +2,9 @@ export * from './alert-action'; export * from './create-alert'; export * from './create-incident'; export * from './get-alert'; +export * from './get-alert-request-status'; export * from './get-incident'; +export * from './get-incident-request-status'; export * from './get-on-call'; export * from './get-team'; export * from './get-user'; @@ -16,6 +18,7 @@ export * from './list-teams'; export * from './list-users'; export * from './manage-escalation'; export * from './manage-schedule'; +export * from './manage-schedule-override'; export * from './manage-service'; export * from './manage-team'; export * from './manage-user'; diff --git a/integrations/opsgenie/src/tools/manage-escalation.ts b/integrations/opsgenie/src/tools/manage-escalation.ts index 330c2ca3a8..9ad5b4a405 100644 --- a/integrations/opsgenie/src/tools/manage-escalation.ts +++ b/integrations/opsgenie/src/tools/manage-escalation.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OpsGenieClient } from '../lib/client'; +import { opsgenieServiceError } from '../lib/errors'; import { spec } from '../spec'; let escalationRuleSchema = z.object({ @@ -95,10 +96,10 @@ export let manageEscalation = SlateTool.create(spec, { switch (ctx.input.action) { case 'create': { if (!ctx.input.name) { - throw new Error('name is required when creating an escalation policy.'); + throw opsgenieServiceError('name is required when creating an escalation policy.'); } if (!ctx.input.rules || ctx.input.rules.length === 0) { - throw new Error('rules are required when creating an escalation policy.'); + throw opsgenieServiceError('rules are required when creating an escalation policy.'); } let escalation = await client.createEscalation({ name: ctx.input.name, @@ -118,7 +119,7 @@ export let manageEscalation = SlateTool.create(spec, { } case 'update': { if (!ctx.input.escalationIdentifier) { - throw new Error( + throw opsgenieServiceError( 'escalationIdentifier is required when updating an escalation policy.' ); } @@ -144,7 +145,7 @@ export let manageEscalation = SlateTool.create(spec, { } case 'delete': { if (!ctx.input.escalationIdentifier) { - throw new Error( + throw opsgenieServiceError( 'escalationIdentifier is required when deleting an escalation policy.' ); } diff --git a/integrations/opsgenie/src/tools/manage-schedule-override.ts b/integrations/opsgenie/src/tools/manage-schedule-override.ts new file mode 100644 index 0000000000..c52bbf7316 --- /dev/null +++ b/integrations/opsgenie/src/tools/manage-schedule-override.ts @@ -0,0 +1,256 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { OpsGenieClient } from '../lib/client'; +import { opsgenieServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let rotationIdentifierSchema = z.object({ + id: z.string().optional().describe('Rotation ID'), + name: z.string().optional().describe('Rotation name') +}); + +let overrideUserSchema = z + .object({ + type: z.string().optional().describe('Override user type'), + id: z.string().optional().describe('User ID'), + username: z.string().optional().describe('Username/email') + }) + .optional(); + +let overrideOutputSchema = z.object({ + alias: z.string().describe('Schedule override alias'), + user: overrideUserSchema.describe('User taking on-call responsibility, or type none'), + startDate: z.string().optional().describe('Override start time'), + endDate: z.string().optional().describe('Override end time'), + rotations: z + .array( + z.object({ + id: z.string().optional().describe('Rotation ID'), + name: z.string().optional().describe('Rotation name') + }) + ) + .optional() + .describe('Rotations affected by the override') +}); + +let requireAlias = (value: string | undefined, action: string) => { + if (value) return value; + throw opsgenieServiceError(`alias is required for the ${action} action.`); +}; + +let requireScheduleOverridePayload = (input: { + userType?: 'user' | 'none'; + userId?: string; + userUsername?: string; + startDate?: string; + endDate?: string; + rotations?: Array<{ id?: string; name?: string }>; +}) => { + if (!input.startDate || !input.endDate) { + throw opsgenieServiceError('startDate and endDate are required for schedule overrides.'); + } + + for (let rotation of input.rotations ?? []) { + if (!rotation.id && !rotation.name) { + throw opsgenieServiceError('Each rotation must include id or name.'); + } + } + + if (input.userType === 'none') { + return { + user: { type: 'none' }, + startDate: input.startDate, + endDate: input.endDate, + rotations: input.rotations + }; + } + + if (input.userId) { + return { + user: { type: 'user', id: input.userId }, + startDate: input.startDate, + endDate: input.endDate, + rotations: input.rotations + }; + } + + if (input.userUsername) { + return { + user: { type: 'user', username: input.userUsername }, + startDate: input.startDate, + endDate: input.endDate, + rotations: input.rotations + }; + } + + throw opsgenieServiceError( + 'userType "none", userId, or userUsername is required for schedule overrides.' + ); +}; + +let formatOverride = (override: any) => ({ + alias: override.alias, + user: override.user + ? { + type: override.user.type, + id: override.user.id, + username: override.user.username + } + : undefined, + startDate: override.startDate, + endDate: override.endDate, + rotations: (override.rotations ?? []).map((rotation: any) => ({ + id: rotation.id, + name: rotation.name + })) +}); + +export let manageScheduleOverride = SlateTool.create(spec, { + name: 'Manage Schedule Override', + key: 'manage_schedule_override', + description: + 'Create, get, update, delete, or list temporary on-call overrides for an Opsgenie schedule.', + instructions: [ + 'For create/update, provide scheduleIdentifier, startDate, endDate, and either userType "none", userId, or userUsername.', + 'For get/update/delete, provide alias.', + 'Use rotations to restrict the override to specific rotations; each rotation must include id or name.' + ] +}) + .input( + z.object({ + action: z + .enum(['create', 'get', 'update', 'delete', 'list']) + .describe('Schedule override action to perform'), + scheduleIdentifier: z.string().describe('Schedule ID or name'), + scheduleIdentifierType: z + .enum(['id', 'name']) + .optional() + .describe('Type of schedule identifier. Defaults to "id"'), + alias: z + .string() + .optional() + .describe('Schedule override alias. Required for get, update, and delete.'), + userType: z + .enum(['user', 'none']) + .optional() + .describe('Use "user" for a specific user or "none" to reserve the period'), + userId: z.string().optional().describe('User ID for user overrides'), + userUsername: z.string().optional().describe('Username/email for user overrides'), + startDate: z + .string() + .optional() + .describe('Override start time in ISO 8601 format. Required for create/update.'), + endDate: z + .string() + .optional() + .describe('Override end time in ISO 8601 format. Required for create/update.'), + rotations: z + .array(rotationIdentifierSchema) + .optional() + .describe('Optional schedule rotations to override') + }) + ) + .output( + z.object({ + alias: z.string().optional().describe('Schedule override alias'), + requestId: z.string().optional().describe('Opsgenie request ID'), + override: overrideOutputSchema.optional().describe('Schedule override details'), + overrides: z + .array(overrideOutputSchema) + .optional() + .describe('Schedule overrides returned by list'), + totalCount: z.number().optional().describe('Number of overrides returned by list'), + result: z.string().describe('Operation result') + }) + ) + .handleInvocation(async ctx => { + let client = new OpsGenieClient({ + token: ctx.auth.token, + instance: ctx.config.instance + }); + let params = { scheduleIdentifierType: ctx.input.scheduleIdentifierType ?? 'id' }; + + switch (ctx.input.action) { + case 'create': { + let response = await client.createScheduleOverride( + ctx.input.scheduleIdentifier, + params, + { + alias: ctx.input.alias, + ...requireScheduleOverridePayload(ctx.input) + } + ); + let alias = response.data?.alias ?? ctx.input.alias; + return { + output: { + alias, + requestId: response.requestId, + result: response.result ?? 'Schedule override request will be processed' + }, + message: `Created schedule override${alias ? ` \`${alias}\`` : ''}.` + }; + } + case 'get': { + let alias = requireAlias(ctx.input.alias, 'get'); + let override = await client.getScheduleOverride( + ctx.input.scheduleIdentifier, + alias, + params + ); + return { + output: { + alias: override.alias, + override: formatOverride(override), + result: 'Schedule override retrieved successfully' + }, + message: `Retrieved schedule override \`${override.alias}\`.` + }; + } + case 'update': { + let alias = requireAlias(ctx.input.alias, 'update'); + let response = await client.updateScheduleOverride( + ctx.input.scheduleIdentifier, + alias, + params, + requireScheduleOverridePayload(ctx.input) + ); + return { + output: { + alias: response.data?.alias ?? alias, + requestId: response.requestId, + result: response.result ?? 'Schedule override updated successfully' + }, + message: `Updated schedule override \`${alias}\`.` + }; + } + case 'delete': { + let alias = requireAlias(ctx.input.alias, 'delete'); + let response = await client.deleteScheduleOverride( + ctx.input.scheduleIdentifier, + alias, + params + ); + return { + output: { + alias, + requestId: response.requestId, + result: response.result ?? 'Schedule override deleted successfully' + }, + message: `Deleted schedule override \`${alias}\`.` + }; + } + case 'list': { + let data = await client.listScheduleOverrides(ctx.input.scheduleIdentifier, params); + let overrides = (data ?? []).map(formatOverride); + return { + output: { + overrides, + totalCount: overrides.length, + result: 'Schedule overrides listed successfully' + }, + message: `Found **${overrides.length}** schedule overrides.` + }; + } + } + }) + .build(); diff --git a/integrations/opsgenie/src/tools/manage-schedule.ts b/integrations/opsgenie/src/tools/manage-schedule.ts index 2b66e51844..f47b4d0b5d 100644 --- a/integrations/opsgenie/src/tools/manage-schedule.ts +++ b/integrations/opsgenie/src/tools/manage-schedule.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OpsGenieClient } from '../lib/client'; +import { opsgenieServiceError } from '../lib/errors'; import { spec } from '../spec'; let rotationSchema = z.object({ @@ -72,7 +73,7 @@ export let manageSchedule = SlateTool.create(spec, { switch (ctx.input.action) { case 'create': { if (!ctx.input.name) { - throw new Error('name is required when creating a schedule.'); + throw opsgenieServiceError('name is required when creating a schedule.'); } let schedule = await client.createSchedule({ name: ctx.input.name, @@ -93,7 +94,9 @@ export let manageSchedule = SlateTool.create(spec, { } case 'update': { if (!ctx.input.scheduleIdentifier) { - throw new Error('scheduleIdentifier is required when updating a schedule.'); + throw opsgenieServiceError( + 'scheduleIdentifier is required when updating a schedule.' + ); } let updated = await client.updateSchedule( ctx.input.scheduleIdentifier, @@ -118,7 +121,9 @@ export let manageSchedule = SlateTool.create(spec, { } case 'delete': { if (!ctx.input.scheduleIdentifier) { - throw new Error('scheduleIdentifier is required when deleting a schedule.'); + throw opsgenieServiceError( + 'scheduleIdentifier is required when deleting a schedule.' + ); } await client.deleteSchedule( ctx.input.scheduleIdentifier, diff --git a/integrations/opsgenie/src/tools/manage-service.ts b/integrations/opsgenie/src/tools/manage-service.ts index 252beb837d..a10855c8af 100644 --- a/integrations/opsgenie/src/tools/manage-service.ts +++ b/integrations/opsgenie/src/tools/manage-service.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OpsGenieClient } from '../lib/client'; +import { opsgenieServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageService = SlateTool.create(spec, { @@ -43,7 +44,7 @@ export let manageService = SlateTool.create(spec, { switch (ctx.input.action) { case 'create': { if (!ctx.input.name || !ctx.input.teamId) { - throw new Error('name and teamId are required when creating a service.'); + throw opsgenieServiceError('name and teamId are required when creating a service.'); } let service = await client.createService({ name: ctx.input.name, @@ -62,7 +63,9 @@ export let manageService = SlateTool.create(spec, { } case 'update': { if (!ctx.input.serviceId || !ctx.input.name) { - throw new Error('serviceId and name are required when updating a service.'); + throw opsgenieServiceError( + 'serviceId and name are required when updating a service.' + ); } let updated = await client.updateService(ctx.input.serviceId, { name: ctx.input.name, @@ -80,7 +83,7 @@ export let manageService = SlateTool.create(spec, { } case 'delete': { if (!ctx.input.serviceId) { - throw new Error('serviceId is required when deleting a service.'); + throw opsgenieServiceError('serviceId is required when deleting a service.'); } await client.deleteService(ctx.input.serviceId); return { diff --git a/integrations/opsgenie/src/tools/manage-team.ts b/integrations/opsgenie/src/tools/manage-team.ts index 6f1e4175fc..7ab4f2cd52 100644 --- a/integrations/opsgenie/src/tools/manage-team.ts +++ b/integrations/opsgenie/src/tools/manage-team.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OpsGenieClient } from '../lib/client'; +import { opsgenieServiceError } from '../lib/errors'; import { spec } from '../spec'; let memberSchema = z.object({ @@ -58,7 +59,7 @@ export let manageTeam = SlateTool.create(spec, { switch (ctx.input.action) { case 'create': { if (!ctx.input.name) { - throw new Error('name is required when creating a team.'); + throw opsgenieServiceError('name is required when creating a team.'); } let team = await client.createTeam({ name: ctx.input.name, @@ -76,7 +77,7 @@ export let manageTeam = SlateTool.create(spec, { } case 'update': { if (!ctx.input.teamId) { - throw new Error('teamId is required when updating a team.'); + throw opsgenieServiceError('teamId is required when updating a team.'); } let updated = await client.updateTeam(ctx.input.teamId, { name: ctx.input.name, @@ -95,7 +96,9 @@ export let manageTeam = SlateTool.create(spec, { case 'delete': { let identifier = ctx.input.teamIdentifier ?? ctx.input.teamId; if (!identifier) { - throw new Error('teamIdentifier or teamId is required when deleting a team.'); + throw opsgenieServiceError( + 'teamIdentifier or teamId is required when deleting a team.' + ); } await client.deleteTeam(identifier, ctx.input.identifierType ?? 'id'); return { diff --git a/integrations/opsgenie/src/tools/manage-user.ts b/integrations/opsgenie/src/tools/manage-user.ts index b1a4df7f7b..55cd19b73c 100644 --- a/integrations/opsgenie/src/tools/manage-user.ts +++ b/integrations/opsgenie/src/tools/manage-user.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OpsGenieClient } from '../lib/client'; +import { opsgenieServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageUser = SlateTool.create(spec, { @@ -52,7 +53,9 @@ export let manageUser = SlateTool.create(spec, { switch (ctx.input.action) { case 'create': { if (!ctx.input.username || !ctx.input.fullName || !ctx.input.role) { - throw new Error('username, fullName, and role are required when creating a user.'); + throw opsgenieServiceError( + 'username, fullName, and role are required when creating a user.' + ); } let user = await client.createUser({ username: ctx.input.username, @@ -74,7 +77,7 @@ export let manageUser = SlateTool.create(spec, { } case 'update': { if (!ctx.input.userIdentifier) { - throw new Error('userIdentifier is required when updating a user.'); + throw opsgenieServiceError('userIdentifier is required when updating a user.'); } let updateData: any = {}; if (ctx.input.username !== undefined) updateData.username = ctx.input.username; @@ -96,7 +99,7 @@ export let manageUser = SlateTool.create(spec, { } case 'delete': { if (!ctx.input.userIdentifier) { - throw new Error('userIdentifier is required when deleting a user.'); + throw opsgenieServiceError('userIdentifier is required when deleting a user.'); } await client.deleteUser(ctx.input.userIdentifier); return { diff --git a/integrations/opsgenie/src/tools/update-alert.ts b/integrations/opsgenie/src/tools/update-alert.ts index 265d9d8444..973b3566f8 100644 --- a/integrations/opsgenie/src/tools/update-alert.ts +++ b/integrations/opsgenie/src/tools/update-alert.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { OpsGenieClient } from '../lib/client'; +import { opsgenieServiceError } from '../lib/errors'; import { spec } from '../spec'; export let updateAlert = SlateTool.create(spec, { @@ -65,7 +66,7 @@ export let updateAlert = SlateTool.create(spec, { } if (updatedFields.length === 0) { - throw new Error( + throw opsgenieServiceError( 'No fields provided to update. Provide at least one of: message, description, priority.' ); } diff --git a/integrations/opsgenie/vitest.config.ts b/integrations/opsgenie/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/opsgenie/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/pagerduty/package.json b/integrations/pagerduty/package.json index b543059219..8cce2ce8b2 100644 --- a/integrations/pagerduty/package.json +++ b/integrations/pagerduty/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/pagerduty/src/auth.ts b/integrations/pagerduty/src/auth.ts index 2ca8128ce3..265279bd23 100644 --- a/integrations/pagerduty/src/auth.ts +++ b/integrations/pagerduty/src/auth.ts @@ -1,11 +1,22 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { pagerDutyApiError, pagerDutyServiceError } from './lib/errors'; + +let expiresAtFromSeconds = (expiresIn?: number) => { + if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn)) { + return undefined; + } + + return new Date(Date.now() + expiresIn * 1000).toISOString(); +}; export let auth = SlateAuth.create() .output( z.object({ token: z.string(), - tokenType: z.enum(['oauth', 'api_key']).optional() + tokenType: z.enum(['oauth', 'api_key']).optional(), + refreshToken: z.string().optional(), + expiresAt: z.string().optional() }) ) .addOauth({ @@ -50,6 +61,18 @@ export let auth = SlateAuth.create() scope: 'services.write' }, + // Business Services + { + title: 'Read Business Services', + description: 'Read access to business services', + scope: 'business_services.read' + }, + { + title: 'Write Business Services', + description: 'Write access to business services', + scope: 'business_services.write' + }, + // Users { title: 'Read Users', description: 'Read access to users', scope: 'users.read' }, { title: 'Write Users', description: 'Write access to users', scope: 'users.write' }, @@ -224,19 +247,24 @@ export let auth = SlateAuth.create() handleCallback: async ctx => { let client = createAxios({ baseURL: 'https://app.pagerduty.com' }); - let response = await client.post( - '/oauth/token', - { - grant_type: 'authorization_code', - code: ctx.code, - redirect_uri: ctx.redirectUri, - client_id: ctx.clientId, - client_secret: ctx.clientSecret - }, - { - headers: { 'Content-Type': 'application/json' } - } - ); + let response: any; + try { + response = await client.post( + '/oauth/token', + { + grant_type: 'authorization_code', + code: ctx.code, + redirect_uri: ctx.redirectUri, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }, + { + headers: { 'Content-Type': 'application/json' } + } + ); + } catch (error) { + throw pagerDutyApiError(error, 'OAuth token exchange'); + } let data = response.data as { access_token?: string; @@ -247,13 +275,17 @@ export let auth = SlateAuth.create() }; if (!data.access_token) { - throw new Error(`PagerDuty OAuth error: ${data.error || 'No access token returned'}`); + throw pagerDutyServiceError( + `PagerDuty OAuth error: ${data.error || 'No access token returned'}` + ); } return { output: { token: data.access_token, - tokenType: 'oauth' as const + tokenType: 'oauth' as const, + refreshToken: data.refresh_token, + expiresAt: expiresAtFromSeconds(data.expires_in) }, input: ctx.input }; @@ -262,18 +294,27 @@ export let auth = SlateAuth.create() handleTokenRefresh: async (ctx: any) => { let client = createAxios({ baseURL: 'https://app.pagerduty.com' }); - let response = await client.post( - '/oauth/token', - { - grant_type: 'refresh_token', - refresh_token: ctx.output.token, - client_id: ctx.clientId, - client_secret: ctx.clientSecret - }, - { - headers: { 'Content-Type': 'application/json' } - } - ); + if (!ctx.output.refreshToken) { + throw pagerDutyServiceError('No PagerDuty refresh token is available.'); + } + + let response: any; + try { + response = await client.post( + '/oauth/token', + { + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }, + { + headers: { 'Content-Type': 'application/json' } + } + ); + } catch (error) { + throw pagerDutyApiError(error, 'OAuth token refresh'); + } let data = response.data as { access_token?: string; @@ -284,7 +325,7 @@ export let auth = SlateAuth.create() }; if (!data.access_token) { - throw new Error( + throw pagerDutyServiceError( `PagerDuty token refresh error: ${data.error || 'No access token returned'}` ); } @@ -292,7 +333,9 @@ export let auth = SlateAuth.create() return { output: { token: data.access_token, - tokenType: 'oauth' as const + tokenType: 'oauth' as const, + refreshToken: data.refresh_token ?? ctx.output.refreshToken, + expiresAt: expiresAtFromSeconds(data.expires_in) }, input: ctx.input }; diff --git a/integrations/pagerduty/src/index.ts b/integrations/pagerduty/src/index.ts index 0250091f0d..70abfc112d 100644 --- a/integrations/pagerduty/src/index.ts +++ b/integrations/pagerduty/src/index.ts @@ -12,8 +12,10 @@ import { listServices, listTeams, listUsers, + manageBusinessService, manageMaintenanceWindow, manageService, + manageServiceIntegration, sendEvent, updateIncident } from './tools'; @@ -28,6 +30,8 @@ export let provider = Slate.create({ updateIncident, listServices, manageService, + manageServiceIntegration, + manageBusinessService, listUsers, listOnCalls, listEscalationPolicies, diff --git a/integrations/pagerduty/src/lib/client.ts b/integrations/pagerduty/src/lib/client.ts index 0bb54e1418..02288c4191 100644 --- a/integrations/pagerduty/src/lib/client.ts +++ b/integrations/pagerduty/src/lib/client.ts @@ -1,9 +1,12 @@ import { createAxios } from 'slates'; +import { pagerDutyApiError } from './errors'; import type { PagerDutyAnalyticsIncidentData, + PagerDutyBusinessService, PagerDutyEscalationPolicy, PagerDutyIncident, PagerDutyIncidentNote, + PagerDutyIntegration, PagerDutyMaintenanceWindow, PagerDutyOnCall, PagerDutyPriority, @@ -39,22 +42,38 @@ export class PagerDutyClient { // ─── Generic helpers ──────────────────────────────────────── private async get(path: string, params?: Record): Promise { - let response = await this.axios.get(path, { params }); - return response.data as T; + try { + let response = await this.axios.get(path, { params }); + return response.data as T; + } catch (error) { + throw pagerDutyApiError(error, `GET ${path}`); + } } private async post(path: string, body?: Record): Promise { - let response = await this.axios.post(path, body || {}); - return response.data as T; + try { + let response = await this.axios.post(path, body || {}); + return response.data as T; + } catch (error) { + throw pagerDutyApiError(error, `POST ${path}`); + } } private async put(path: string, body?: Record): Promise { - let response = await this.axios.put(path, body || {}); - return response.data as T; + try { + let response = await this.axios.put(path, body || {}); + return response.data as T; + } catch (error) { + throw pagerDutyApiError(error, `PUT ${path}`); + } } private async delete(path: string): Promise { - await this.axios.delete(path); + try { + await this.axios.delete(path); + } catch (error) { + throw pagerDutyApiError(error, `DELETE ${path}`); + } } // ─── Incidents ────────────────────────────────────────────── @@ -111,7 +130,7 @@ export class PagerDutyClient { conferenceNumber?: string; conferenceUrl?: string; }, - _fromEmail: string + fromEmail: string ): Promise { let incident: Record = { type: 'incident', @@ -144,8 +163,18 @@ export class PagerDutyClient { incident.conference_bridge.conference_url = params.conferenceUrl; } - let data = await this.post<{ incident: PagerDutyIncident }>('/incidents', { incident }); - return data.incident; + try { + let response = await this.axios.post( + '/incidents', + { incident }, + { + headers: { From: fromEmail } + } + ); + return (response.data as { incident: PagerDutyIncident }).incident; + } catch (error) { + throw pagerDutyApiError(error, 'POST /incidents'); + } } async updateIncident( @@ -196,14 +225,18 @@ export class PagerDutyClient { incident.body = { type: 'incident_body', details: params.resolution }; } - let response = await this.axios.put( - `/incidents/${incidentId}`, - { incident }, - { - headers: { From: fromEmail } - } - ); - return (response.data as { incident: PagerDutyIncident }).incident; + try { + let response = await this.axios.put( + `/incidents/${incidentId}`, + { incident }, + { + headers: { From: fromEmail } + } + ); + return (response.data as { incident: PagerDutyIncident }).incident; + } catch (error) { + throw pagerDutyApiError(error, `PUT /incidents/${incidentId}`); + } } async manageIncidents( @@ -243,14 +276,18 @@ export class PagerDutyClient { return item; }); - let response = await this.axios.put( - '/incidents', - { incidents: body }, - { - headers: { From: fromEmail } - } - ); - return (response.data as { incidents: PagerDutyIncident[] }).incidents; + try { + let response = await this.axios.put( + '/incidents', + { incidents: body }, + { + headers: { From: fromEmail } + } + ); + return (response.data as { incidents: PagerDutyIncident[] }).incidents; + } catch (error) { + throw pagerDutyApiError(error, 'PUT /incidents'); + } } async mergeIncidents( @@ -258,19 +295,23 @@ export class PagerDutyClient { sourceIncidentIds: string[], fromEmail: string ): Promise { - let response = await this.axios.put( - `/incidents/${targetIncidentId}/merge`, - { - source_incidents: sourceIncidentIds.map(id => ({ - id, - type: 'incident_reference' - })) - }, - { - headers: { From: fromEmail } - } - ); - return (response.data as { incident: PagerDutyIncident }).incident; + try { + let response = await this.axios.put( + `/incidents/${targetIncidentId}/merge`, + { + source_incidents: sourceIncidentIds.map(id => ({ + id, + type: 'incident_reference' + })) + }, + { + headers: { From: fromEmail } + } + ); + return (response.data as { incident: PagerDutyIncident }).incident; + } catch (error) { + throw pagerDutyApiError(error, `PUT /incidents/${targetIncidentId}/merge`); + } } async addIncidentNote( @@ -278,16 +319,20 @@ export class PagerDutyClient { content: string, fromEmail: string ): Promise { - let response = await this.axios.post( - `/incidents/${incidentId}/notes`, - { - note: { content } - }, - { - headers: { From: fromEmail } - } - ); - return (response.data as { note: PagerDutyIncidentNote }).note; + try { + let response = await this.axios.post( + `/incidents/${incidentId}/notes`, + { + note: { content } + }, + { + headers: { From: fromEmail } + } + ); + return (response.data as { note: PagerDutyIncidentNote }).note; + } catch (error) { + throw pagerDutyApiError(error, `POST /incidents/${incidentId}/notes`); + } } async listIncidentNotes(incidentId: string): Promise { @@ -302,16 +347,20 @@ export class PagerDutyClient { durationSeconds: number, fromEmail: string ): Promise { - let response = await this.axios.post( - `/incidents/${incidentId}/snooze`, - { - duration: durationSeconds - }, - { - headers: { From: fromEmail } - } - ); - return (response.data as { incident: PagerDutyIncident }).incident; + try { + let response = await this.axios.post( + `/incidents/${incidentId}/snooze`, + { + duration: durationSeconds + }, + { + headers: { From: fromEmail } + } + ); + return (response.data as { incident: PagerDutyIncident }).incident; + } catch (error) { + throw pagerDutyApiError(error, `POST /incidents/${incidentId}/snooze`); + } } // ─── Services ─────────────────────────────────────────────── @@ -418,6 +467,148 @@ export class PagerDutyClient { await this.delete(`/services/${serviceId}`); } + async listServiceIntegrations(serviceId: string): Promise { + let service = await this.getService(serviceId, ['integrations']); + return service.integrations ?? []; + } + + async getServiceIntegration( + serviceId: string, + integrationId: string + ): Promise { + let data = await this.get<{ integration: PagerDutyIntegration }>( + `/services/${serviceId}/integrations/${integrationId}` + ); + return data.integration; + } + + async createServiceIntegration(params: { + serviceId: string; + name: string; + integrationType?: string; + vendorId?: string; + }): Promise { + let integration: Record = { + type: params.integrationType ?? 'events_api_v2_inbound_integration', + name: params.name + }; + + if (params.vendorId) { + integration.vendor = { id: params.vendorId, type: 'vendor_reference' }; + } + + let data = await this.post<{ integration: PagerDutyIntegration }>( + `/services/${params.serviceId}/integrations`, + { integration } + ); + return data.integration; + } + + async updateServiceIntegration(params: { + serviceId: string; + integrationId: string; + name?: string; + }): Promise { + let current = await this.getServiceIntegration(params.serviceId, params.integrationId); + let integration: Record = { + type: current.type + }; + + if (params.name !== undefined) integration.name = params.name; + + let data = await this.put<{ integration: PagerDutyIntegration }>( + `/services/${params.serviceId}/integrations/${params.integrationId}`, + { integration } + ); + return data.integration; + } + + // ─── Business Services ────────────────────────────────────── + + async listBusinessServices(params?: { + query?: string; + teamIds?: string[]; + limit?: number; + offset?: number; + }): Promise<{ + business_services: PagerDutyBusinessService[]; + more: boolean; + total: number; + }> { + let query: Record = {}; + if (params?.query) query.query = params.query; + if (params?.teamIds) query['team_ids[]'] = params.teamIds; + if (params?.limit) query.limit = params.limit; + if (params?.offset) query.offset = params.offset; + + let data = await this.get<{ + business_services: PagerDutyBusinessService[]; + more: boolean; + total: number; + }>('/business_services', query); + return data; + } + + async getBusinessService(businessServiceId: string): Promise { + let data = await this.get<{ business_service: PagerDutyBusinessService }>( + `/business_services/${businessServiceId}` + ); + return data.business_service; + } + + async createBusinessService(params: { + name: string; + description?: string; + pointOfContact?: string; + teamId?: string; + }): Promise { + let businessService: Record = { + type: 'business_service', + name: params.name + }; + + if (params.description !== undefined) businessService.description = params.description; + if (params.pointOfContact !== undefined) + businessService.point_of_contact = params.pointOfContact; + if (params.teamId) businessService.team = { id: params.teamId, type: 'team_reference' }; + + let data = await this.post<{ business_service: PagerDutyBusinessService }>( + '/business_services', + { business_service: businessService } + ); + return data.business_service; + } + + async updateBusinessService( + businessServiceId: string, + params: { + name?: string; + description?: string; + pointOfContact?: string; + teamId?: string; + } + ): Promise { + let businessService: Record = { + type: 'business_service' + }; + + if (params.name !== undefined) businessService.name = params.name; + if (params.description !== undefined) businessService.description = params.description; + if (params.pointOfContact !== undefined) + businessService.point_of_contact = params.pointOfContact; + if (params.teamId) businessService.team = { id: params.teamId, type: 'team_reference' }; + + let data = await this.put<{ business_service: PagerDutyBusinessService }>( + `/business_services/${businessServiceId}`, + { business_service: businessService } + ); + return data.business_service; + } + + async deleteBusinessService(businessServiceId: string): Promise { + await this.delete(`/business_services/${businessServiceId}`); + } + // ─── Users ────────────────────────────────────────────────── async listUsers(params?: { @@ -641,15 +832,19 @@ export class PagerDutyClient { }; if (params.description) maintenanceWindow.description = params.description; - let response = await this.axios.post( - '/maintenance_windows', - { maintenance_window: maintenanceWindow }, - { - headers: { From: fromEmail } - } - ); - return (response.data as { maintenance_window: PagerDutyMaintenanceWindow }) - .maintenance_window; + try { + let response = await this.axios.post( + '/maintenance_windows', + { maintenance_window: maintenanceWindow }, + { + headers: { From: fromEmail } + } + ); + return (response.data as { maintenance_window: PagerDutyMaintenanceWindow }) + .maintenance_window; + } catch (error) { + throw pagerDutyApiError(error, 'POST /maintenance_windows'); + } } async deleteMaintenanceWindow(windowId: string): Promise { @@ -783,8 +978,12 @@ export class PagerDutyClient { if (params.customDetails) body.payload.custom_details = params.customDetails; } - let response = await eventsAxios.post('/v2/enqueue', body); - return response.data as { status: string; message: string; dedup_key: string }; + try { + let response = await eventsAxios.post('/v2/enqueue', body); + return response.data as { status: string; message: string; dedup_key: string }; + } catch (error) { + throw pagerDutyApiError(error, 'POST /v2/enqueue'); + } } async sendChangeEvent(params: { @@ -812,7 +1011,11 @@ export class PagerDutyClient { if (params.customDetails) body.payload.custom_details = params.customDetails; if (params.links) body.links = params.links; - let response = await eventsAxios.post('/v2/change/enqueue', body); - return response.data as { status: string; message: string }; + try { + let response = await eventsAxios.post('/v2/change/enqueue', body); + return response.data as { status: string; message: string }; + } catch (error) { + throw pagerDutyApiError(error, 'POST /v2/change/enqueue'); + } } } diff --git a/integrations/pagerduty/src/lib/errors.ts b/integrations/pagerduty/src/lib/errors.ts new file mode 100644 index 0000000000..3e0555b1f9 --- /dev/null +++ b/integrations/pagerduty/src/lib/errors.ts @@ -0,0 +1,110 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string') { + return; + } + + let trimmed = value.trim(); + if (trimmed && !details.includes(trimmed)) { + details.push(trimmed); + } +}; + +let extractPagerDutyMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data ?? (isRecord(error) ? error.data : undefined); + let details: string[] = []; + + if (isRecord(data)) { + let errorBody = data.error; + if (isRecord(errorBody)) { + addDetail(details, errorBody.message); + addDetail(details, errorBody.code); + + let nestedErrors = errorBody.errors; + if (Array.isArray(nestedErrors)) { + for (let item of nestedErrors) { + addDetail(details, item); + if (isRecord(item)) { + addDetail(details, item.message); + addDetail(details, item.detail); + } + } + } + } + + let errors = data.errors; + if (Array.isArray(errors)) { + for (let item of errors) { + addDetail(details, item); + if (isRecord(item)) { + addDetail(details, item.message); + addDetail(details, item.detail); + } + } + } + + for (let key of ['message', 'error_description', 'error']) { + addDetail(details, data[key]); + } + } else { + addDetail(details, data); + } + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getPagerDutyErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + return response?.status ?? error.status; +}; + +export let pagerDutyServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let pagerDutyApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getPagerDutyErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = pagerDutyServiceError( + `PagerDuty API ${operation} failed: ${statusLabel}${extractPagerDutyMessage(error)}` + ); + serviceError.data.reason = 'pagerduty_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/pagerduty/src/lib/types.ts b/integrations/pagerduty/src/lib/types.ts index 68cedcf6b0..3c055e60f7 100644 --- a/integrations/pagerduty/src/lib/types.ts +++ b/integrations/pagerduty/src/lib/types.ts @@ -74,11 +74,29 @@ export interface PagerDutyIntegration { self?: string; html_url?: string; name?: string; + created_at?: string; integration_key?: string; integration_email?: string; + email_incident_creation?: string; + email_filter_mode?: string; + service?: PagerDutyReference; vendor?: PagerDutyReference; } +export interface PagerDutyBusinessService { + id: string; + type: string; + summary?: string; + self?: string; + html_url?: string; + name?: string; + description?: string | null; + point_of_contact?: string | null; + team?: PagerDutyReference | null; + created_at?: string; + updated_at?: string; +} + export interface PagerDutyUser { id: string; type: string; diff --git a/integrations/pagerduty/src/tools.schema.test.ts b/integrations/pagerduty/src/tools.schema.test.ts new file mode 100644 index 0000000000..5e2876d87e --- /dev/null +++ b/integrations/pagerduty/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('PagerDuty tool input schemas', provider.actions); diff --git a/integrations/pagerduty/src/tools/index.ts b/integrations/pagerduty/src/tools/index.ts index 1f7e22af90..d6b484bf81 100644 --- a/integrations/pagerduty/src/tools/index.ts +++ b/integrations/pagerduty/src/tools/index.ts @@ -9,7 +9,9 @@ export * from './list-schedules'; export * from './list-services'; export * from './list-teams'; export * from './list-users'; +export * from './manage-business-service'; export * from './manage-maintenance-window'; export * from './manage-service'; +export * from './manage-service-integration'; export * from './send-event'; export * from './update-incident'; diff --git a/integrations/pagerduty/src/tools/manage-business-service.ts b/integrations/pagerduty/src/tools/manage-business-service.ts new file mode 100644 index 0000000000..23be473ee1 --- /dev/null +++ b/integrations/pagerduty/src/tools/manage-business-service.ts @@ -0,0 +1,186 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { PagerDutyClient } from '../lib/client'; +import { pagerDutyServiceError } from '../lib/errors'; +import type { PagerDutyBusinessService } from '../lib/types'; +import { spec } from '../spec'; + +let formatBusinessService = (businessService: PagerDutyBusinessService) => ({ + businessServiceId: businessService.id, + name: businessService.name ?? businessService.summary, + description: businessService.description ?? undefined, + pointOfContact: businessService.point_of_contact ?? undefined, + teamId: businessService.team?.id, + teamName: businessService.team?.summary, + htmlUrl: businessService.html_url +}); + +let hasBusinessServiceUpdate = (input: { + name?: string; + description?: string; + pointOfContact?: string; + teamId?: string; +}) => + input.name !== undefined || + input.description !== undefined || + input.pointOfContact !== undefined || + input.teamId !== undefined; + +export let manageBusinessService = SlateTool.create(spec, { + name: 'Manage Business Service', + key: 'manage_business_service', + description: `List, get, create, update, or delete PagerDuty business services. Business services represent customer-facing capabilities and are used to track impacts across technical services.`, + instructions: [ + 'Set **action** to "list", "get", "create", "update", or "delete".', + 'For create, **name** is required.', + 'For get/update/delete, **businessServiceId** is required.', + 'For update, provide at least one of **name**, **description**, **pointOfContact**, or **teamId**.' + ], + tags: { + destructive: true, + readOnly: false + } +}) + .input( + z.object({ + action: z + .enum(['list', 'get', 'create', 'update', 'delete']) + .describe('Action to perform'), + businessServiceId: z.string().optional().describe('Business service ID'), + name: z.string().optional().describe('Business service name'), + description: z.string().optional().describe('Business service description'), + pointOfContact: z.string().optional().describe('Point of contact'), + teamId: z.string().optional().describe('Team ID that owns the business service'), + query: z.string().optional().describe('Search query for list action'), + teamIds: z.array(z.string()).optional().describe('Filter list results by team IDs'), + limit: z.number().optional().describe('Max results for list'), + offset: z.number().optional().describe('Pagination offset for list') + }) + ) + .output( + z.object({ + businessServices: z + .array( + z.object({ + businessServiceId: z.string().describe('Business service ID'), + name: z.string().optional().describe('Business service name'), + description: z.string().optional().describe('Description'), + pointOfContact: z.string().optional().describe('Point of contact'), + teamId: z.string().optional().describe('Team ID'), + teamName: z.string().optional().describe('Team name'), + htmlUrl: z.string().optional().describe('Web URL') + }) + ) + .optional() + .describe('Business services for list action'), + businessServiceId: z.string().optional().describe('Business service ID'), + name: z.string().optional().describe('Business service name'), + description: z.string().optional().describe('Description'), + pointOfContact: z.string().optional().describe('Point of contact'), + teamId: z.string().optional().describe('Team ID'), + teamName: z.string().optional().describe('Team name'), + htmlUrl: z.string().optional().describe('Web URL'), + deleted: z.boolean().optional().describe('Whether the business service was deleted'), + more: z.boolean().optional().describe('Whether more list results are available'), + total: z.number().optional().describe('Total count for list') + }) + ) + .handleInvocation(async ctx => { + let client = new PagerDutyClient({ + token: ctx.auth.token, + tokenType: ctx.auth.tokenType, + region: ctx.config.region + }); + + if (ctx.input.action === 'list') { + let result = await client.listBusinessServices({ + query: ctx.input.query, + teamIds: ctx.input.teamIds, + limit: ctx.input.limit, + offset: ctx.input.offset + }); + + return { + output: { + businessServices: result.business_services.map(formatBusinessService), + more: result.more, + total: result.total + }, + message: `Found **${result.total}** business service(s). Returned ${result.business_services.length} result(s).` + }; + } + + if (ctx.input.action === 'get') { + if (!ctx.input.businessServiceId) + throw pagerDutyServiceError( + 'businessServiceId is required for getting a business service' + ); + + let businessService = await client.getBusinessService(ctx.input.businessServiceId); + + return { + output: formatBusinessService(businessService), + message: `Fetched business service **${businessService.name ?? businessService.id}**.` + }; + } + + if (ctx.input.action === 'create') { + if (!ctx.input.name) + throw pagerDutyServiceError('name is required for creating a business service'); + + let businessService = await client.createBusinessService({ + name: ctx.input.name, + description: ctx.input.description, + pointOfContact: ctx.input.pointOfContact, + teamId: ctx.input.teamId + }); + + return { + output: formatBusinessService(businessService), + message: `Created business service **${businessService.name ?? businessService.id}**.` + }; + } + + if (ctx.input.action === 'update') { + if (!ctx.input.businessServiceId) + throw pagerDutyServiceError( + 'businessServiceId is required for updating a business service' + ); + if (!hasBusinessServiceUpdate(ctx.input)) + throw pagerDutyServiceError( + 'Provide at least one business service property to update.' + ); + + let businessService = await client.updateBusinessService(ctx.input.businessServiceId, { + name: ctx.input.name, + description: ctx.input.description, + pointOfContact: ctx.input.pointOfContact, + teamId: ctx.input.teamId + }); + + return { + output: formatBusinessService(businessService), + message: `Updated business service **${businessService.name ?? businessService.id}**.` + }; + } + + if (ctx.input.action === 'delete') { + if (!ctx.input.businessServiceId) + throw pagerDutyServiceError( + 'businessServiceId is required for deleting a business service' + ); + + await client.deleteBusinessService(ctx.input.businessServiceId); + + return { + output: { + businessServiceId: ctx.input.businessServiceId, + deleted: true + }, + message: `Deleted business service \`${ctx.input.businessServiceId}\`.` + }; + } + + throw pagerDutyServiceError(`Unknown action: ${ctx.input.action}`); + }) + .build(); diff --git a/integrations/pagerduty/src/tools/manage-maintenance-window.ts b/integrations/pagerduty/src/tools/manage-maintenance-window.ts index 639d799ae5..97feab8854 100644 --- a/integrations/pagerduty/src/tools/manage-maintenance-window.ts +++ b/integrations/pagerduty/src/tools/manage-maintenance-window.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { PagerDutyClient } from '../lib/client'; +import { pagerDutyServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageMaintenanceWindow = SlateTool.create(spec, { @@ -80,11 +81,11 @@ export let manageMaintenanceWindow = SlateTool.create(spec, { }); if (ctx.input.action === 'create') { - if (!ctx.input.startTime) throw new Error('startTime is required'); - if (!ctx.input.endTime) throw new Error('endTime is required'); + if (!ctx.input.startTime) throw pagerDutyServiceError('startTime is required'); + if (!ctx.input.endTime) throw pagerDutyServiceError('endTime is required'); if (!ctx.input.serviceIds || ctx.input.serviceIds.length === 0) - throw new Error('serviceIds is required'); - if (!ctx.input.fromEmail) throw new Error('fromEmail is required'); + throw pagerDutyServiceError('serviceIds is required'); + if (!ctx.input.fromEmail) throw pagerDutyServiceError('fromEmail is required'); let mw = await client.createMaintenanceWindow( { @@ -105,7 +106,8 @@ export let manageMaintenanceWindow = SlateTool.create(spec, { } if (ctx.input.action === 'end') { - if (!ctx.input.maintenanceWindowId) throw new Error('maintenanceWindowId is required'); + if (!ctx.input.maintenanceWindowId) + throw pagerDutyServiceError('maintenanceWindowId is required'); await client.deleteMaintenanceWindow(ctx.input.maintenanceWindowId); return { diff --git a/integrations/pagerduty/src/tools/manage-service-integration.ts b/integrations/pagerduty/src/tools/manage-service-integration.ts new file mode 100644 index 0000000000..c67165e096 --- /dev/null +++ b/integrations/pagerduty/src/tools/manage-service-integration.ts @@ -0,0 +1,155 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { PagerDutyClient } from '../lib/client'; +import { pagerDutyServiceError } from '../lib/errors'; +import type { PagerDutyIntegration } from '../lib/types'; +import { spec } from '../spec'; + +let formatIntegration = (integration: PagerDutyIntegration) => ({ + integrationId: integration.id, + name: integration.name ?? integration.summary, + type: integration.type, + integrationKey: integration.integration_key, + integrationEmail: integration.integration_email, + serviceId: integration.service?.id, + serviceName: integration.service?.summary, + vendorId: integration.vendor?.id, + vendorName: integration.vendor?.summary, + htmlUrl: integration.html_url, + createdAt: integration.created_at +}); + +export let manageServiceIntegration = SlateTool.create(spec, { + name: 'Manage Service Integration', + key: 'manage_service_integration', + description: `List, get, create, or update PagerDuty service integrations. Use this to provision Events API v2 routing keys for sending alert and change events with send_event.`, + instructions: [ + 'Set **action** to "list", "get", "create", or "update".', + 'For all actions, **serviceId** is required.', + 'For get/update, **integrationId** is required.', + 'For create, **name** is required. The default **integrationType** creates an Events API v2 inbound integration and returns its routing key.' + ], + tags: { + destructive: false, + readOnly: false + } +}) + .input( + z.object({ + action: z.enum(['list', 'get', 'create', 'update']).describe('Action to perform'), + serviceId: z.string().describe('PagerDuty service ID'), + integrationId: z.string().optional().describe('Service integration ID'), + name: z.string().optional().describe('Integration name'), + integrationType: z + .enum(['events_api_v2_inbound_integration', 'generic_events_api_inbound_integration']) + .optional() + .describe('Inbound events integration type for create'), + vendorId: z + .string() + .optional() + .describe('Optional PagerDuty vendor ID to associate with the integration') + }) + ) + .output( + z.object({ + integrations: z + .array( + z.object({ + integrationId: z.string().describe('Integration ID'), + name: z.string().optional().describe('Integration name'), + type: z.string().optional().describe('Integration type'), + integrationKey: z.string().optional().describe('Events API routing key'), + integrationEmail: z.string().optional().describe('Inbound email address'), + serviceId: z.string().optional().describe('Service ID'), + serviceName: z.string().optional().describe('Service name'), + vendorId: z.string().optional().describe('Vendor ID'), + vendorName: z.string().optional().describe('Vendor name'), + htmlUrl: z.string().optional().describe('Web URL'), + createdAt: z.string().optional().describe('Creation timestamp') + }) + ) + .optional() + .describe('Service integrations for list action'), + integrationId: z.string().optional().describe('Integration ID'), + name: z.string().optional().describe('Integration name'), + type: z.string().optional().describe('Integration type'), + integrationKey: z.string().optional().describe('Events API routing key'), + integrationEmail: z.string().optional().describe('Inbound email address'), + serviceId: z.string().optional().describe('Service ID'), + serviceName: z.string().optional().describe('Service name'), + vendorId: z.string().optional().describe('Vendor ID'), + vendorName: z.string().optional().describe('Vendor name'), + htmlUrl: z.string().optional().describe('Web URL'), + createdAt: z.string().optional().describe('Creation timestamp') + }) + ) + .handleInvocation(async ctx => { + let client = new PagerDutyClient({ + token: ctx.auth.token, + tokenType: ctx.auth.tokenType, + region: ctx.config.region + }); + + if (ctx.input.action === 'list') { + let integrations = await client.listServiceIntegrations(ctx.input.serviceId); + return { + output: { + integrations: integrations.map(formatIntegration) + }, + message: `Found **${integrations.length}** service integration(s).` + }; + } + + if (ctx.input.action === 'get') { + if (!ctx.input.integrationId) + throw pagerDutyServiceError('integrationId is required for getting an integration'); + + let integration = await client.getServiceIntegration( + ctx.input.serviceId, + ctx.input.integrationId + ); + + return { + output: formatIntegration(integration), + message: `Fetched service integration **${integration.name ?? integration.id}**.` + }; + } + + if (ctx.input.action === 'create') { + if (!ctx.input.name) + throw pagerDutyServiceError('name is required for creating an integration'); + + let integration = await client.createServiceIntegration({ + serviceId: ctx.input.serviceId, + name: ctx.input.name, + integrationType: ctx.input.integrationType, + vendorId: ctx.input.vendorId + }); + + return { + output: formatIntegration(integration), + message: `Created service integration **${integration.name ?? integration.id}**.` + }; + } + + if (ctx.input.action === 'update') { + if (!ctx.input.integrationId) + throw pagerDutyServiceError('integrationId is required for updating an integration'); + if (!ctx.input.name) + throw pagerDutyServiceError('name is required for updating an integration'); + + let integration = await client.updateServiceIntegration({ + serviceId: ctx.input.serviceId, + integrationId: ctx.input.integrationId, + name: ctx.input.name + }); + + return { + output: formatIntegration(integration), + message: `Updated service integration **${integration.name ?? integration.id}**.` + }; + } + + throw pagerDutyServiceError(`Unknown action: ${ctx.input.action}`); + }) + .build(); diff --git a/integrations/pagerduty/src/tools/manage-service.ts b/integrations/pagerduty/src/tools/manage-service.ts index 98328766af..dccd9b005c 100644 --- a/integrations/pagerduty/src/tools/manage-service.ts +++ b/integrations/pagerduty/src/tools/manage-service.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { PagerDutyClient } from '../lib/client'; +import { pagerDutyServiceError } from '../lib/errors'; import { spec } from '../spec'; export let manageService = SlateTool.create(spec, { @@ -62,9 +63,10 @@ export let manageService = SlateTool.create(spec, { }); if (ctx.input.action === 'create') { - if (!ctx.input.name) throw new Error('name is required for creating a service'); + if (!ctx.input.name) + throw pagerDutyServiceError('name is required for creating a service'); if (!ctx.input.escalationPolicyId) - throw new Error('escalationPolicyId is required for creating a service'); + throw pagerDutyServiceError('escalationPolicyId is required for creating a service'); let service = await client.createService({ name: ctx.input.name, @@ -89,7 +91,7 @@ export let manageService = SlateTool.create(spec, { if (ctx.input.action === 'update') { if (!ctx.input.serviceId) - throw new Error('serviceId is required for updating a service'); + throw pagerDutyServiceError('serviceId is required for updating a service'); let service = await client.updateService(ctx.input.serviceId, { name: ctx.input.name, @@ -114,7 +116,7 @@ export let manageService = SlateTool.create(spec, { if (ctx.input.action === 'delete') { if (!ctx.input.serviceId) - throw new Error('serviceId is required for deleting a service'); + throw pagerDutyServiceError('serviceId is required for deleting a service'); await client.deleteService(ctx.input.serviceId); return { @@ -126,6 +128,6 @@ export let manageService = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw pagerDutyServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/pagerduty/src/tools/send-event.ts b/integrations/pagerduty/src/tools/send-event.ts index 6117fe977c..fb562d282a 100644 --- a/integrations/pagerduty/src/tools/send-event.ts +++ b/integrations/pagerduty/src/tools/send-event.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { PagerDutyClient } from '../lib/client'; +import { pagerDutyServiceError } from '../lib/errors'; import { spec } from '../spec'; export let sendEvent = SlateTool.create(spec, { @@ -78,7 +79,8 @@ export let sendEvent = SlateTool.create(spec, { }); if (ctx.input.eventType === 'change') { - if (!ctx.input.summary) throw new Error('summary is required for change events'); + if (!ctx.input.summary) + throw pagerDutyServiceError('summary is required for change events'); let result = await client.sendChangeEvent({ routingKey: ctx.input.routingKey, @@ -99,7 +101,14 @@ export let sendEvent = SlateTool.create(spec, { } // Alert event - if (!ctx.input.eventAction) throw new Error('eventAction is required for alert events'); + if (!ctx.input.eventAction) + throw pagerDutyServiceError('eventAction is required for alert events'); + if (ctx.input.eventAction === 'trigger' && !ctx.input.summary) + throw pagerDutyServiceError('summary is required for trigger alert events'); + if (ctx.input.eventAction !== 'trigger' && !ctx.input.dedupKey) + throw pagerDutyServiceError( + 'dedupKey is required for acknowledge and resolve alert events' + ); let result = await client.sendEvent({ routingKey: ctx.input.routingKey, diff --git a/integrations/pagerduty/src/tools/update-incident.ts b/integrations/pagerduty/src/tools/update-incident.ts index 78f91a595f..86edc01958 100644 --- a/integrations/pagerduty/src/tools/update-incident.ts +++ b/integrations/pagerduty/src/tools/update-incident.ts @@ -8,7 +8,7 @@ export let updateIncident = SlateTool.create(spec, { key: 'update_incident', description: `Update a PagerDuty incident — acknowledge, resolve, reassign, change urgency, escalation policy, priority, or add a note. Also supports snoozing and merging incidents.`, instructions: [ - 'To acknowledge, set **status** to "acknowledged". To resolve, set **status** to "resolved".', + 'Set **status** to "acknowledged", "resolved", or "triggered" to change incident state.', 'To snooze, provide **snoozeDurationSeconds** (the incident will re-trigger after the duration).', 'To merge incidents, provide **mergeSourceIncidentIds** — the specified incidents will be merged into this one.', 'To add a note, provide **noteContent**.' @@ -22,7 +22,10 @@ export let updateIncident = SlateTool.create(spec, { z.object({ incidentId: z.string().describe('Incident ID to update'), fromEmail: z.string().describe('Email of the PagerDuty user performing the update'), - status: z.enum(['acknowledged', 'resolved']).optional().describe('New incident status'), + status: z + .enum(['triggered', 'acknowledged', 'resolved']) + .optional() + .describe('New incident status'), title: z.string().optional().describe('New title'), urgency: z.enum(['high', 'low']).optional().describe('New urgency level'), escalationPolicyId: z.string().optional().describe('New escalation policy ID'), diff --git a/integrations/pagerduty/vitest.config.ts b/integrations/pagerduty/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/pagerduty/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/pandadoc/README.md b/integrations/pandadoc/README.md index 109b77a460..9d8cbd50a6 100644 --- a/integrations/pandadoc/README.md +++ b/integrations/pandadoc/README.md @@ -1,6 +1,6 @@ # Pandadoc -Create, send, track, and electronically sign documents such as proposals, contracts, and quotes. Generate documents programmatically from templates or file uploads, populate them with dynamic data (tokens, fields, recipients, pricing tables), and manage the full document lifecycle. Embed document editing, sending, and signing experiences directly in applications. Manage templates, content library items, contacts, and a product catalog. Link documents to external CRM objects, manage workspaces and users, handle notarization requests, and configure webhooks for real-time event notifications on document state changes, recipient completions, and template updates. +Create, send, track, and electronically sign documents such as proposals, contracts, and quotes. Generate documents programmatically from templates or public PDF URLs, populate them with dynamic data (tokens, fields, recipients, pricing tables, text blocks, tables, and images), and manage the core document lifecycle. Inspect templates, content library items, contacts, recipients, folders, forms, workspace members, and CRM links. ## Tools @@ -10,7 +10,11 @@ Create a shareable or embeddable session link for a PandaDoc document, targeted ### Create Document -Create a new PandaDoc document from a template, populating it with recipients, tokens, fields, metadata, and pricing data. The document is created in draft status and can then be sent for signing. +Create a new PandaDoc document from a template or a publicly accessible PDF URL, populating it with recipients, tokens, fields, metadata, pricing data, tables, text blocks, and image blocks. The document is created in draft status and can then be sent for signing. + +### Update Document + +Update a draft PandaDoc document with mutable values such as name, recipients, fields, tokens, tags, metadata, pricing tables, tables, texts, or images. ### Delete Document @@ -18,12 +22,16 @@ Permanently delete a PandaDoc document by its ID. ### Download Document -Get the download URL for a completed PandaDoc document PDF. +Download a PandaDoc document and return the file as a Slate attachment. ### Get Document Retrieve full details of a PandaDoc document including its status, recipients, fields, tokens, metadata, tags, pricing, and linked objects. Use this to inspect any aspect of a document. +### Get Document Status + +Retrieve lightweight document status and lifecycle timestamps. Use this to poll newly created documents until they reach draft status. + ### Get Template Retrieve full details of a PandaDoc template including its roles, fields, tokens, pricing tables, content placeholders, and metadata. @@ -52,13 +60,33 @@ Search and list PandaDoc templates with optional filtering by name, folder, tag, Create a new contact in the PandaDoc contacts directory. Contacts can be used as recipients when creating documents. +### List Contacts + +List contacts from the PandaDoc contacts directory. Optionally filter by email address. + +### Update Contact + +Update an existing PandaDoc contact. + +### Delete Contact + +Delete a contact from the PandaDoc contacts directory. + ### Change Document Status Manually change a PandaDoc document's status to completed, voided, or paid. Use this to force-complete documents, void/expire documents, or mark them as paid. ### List Document Folders -List document folders in the PandaDoc workspace. Optionally create a new folder or move a document into a folder. +List document folders in the PandaDoc workspace. + +### Create Document Folder + +Create a new document folder. PandaDoc's public API does not provide a delete-folder endpoint. + +### Rename Document Folder + +Rename an existing PandaDoc document folder. ### Link CRM Object @@ -68,6 +96,14 @@ Link a PandaDoc document to an external CRM object (e.g., Salesforce opportunity Add a new recipient (CC) to an existing PandaDoc document. Works on documents in any status. +### Update Recipient + +Update recipient delivery and contact details on an existing PandaDoc document. + +### Remove Recipient + +Remove a recipient from a PandaDoc document. + ### Send Document Send a PandaDoc document to its recipients for viewing, signing, or approval. Optionally customize the email subject and message, or send silently for embedded signing flows. diff --git a/integrations/pandadoc/docs/SPEC.md b/integrations/pandadoc/docs/SPEC.md index 7a934c7526..093dd55d91 100644 --- a/integrations/pandadoc/docs/SPEC.md +++ b/integrations/pandadoc/docs/SPEC.md @@ -2,7 +2,7 @@ ## Overview -PandaDoc is a document automation platform for creating, sending, tracking, and electronically signing documents such as proposals, contracts, and quotes. It provides APIs for programmatic document generation from templates or file uploads, embedded signing experiences, and workflow automation. The platform is SOC 2 certified and compliant with E-SIGN, UETA, HIPAA, and GDPR. +PandaDoc is a document automation platform for creating, sending, tracking, and electronically signing documents such as proposals, contracts, and quotes. It provides APIs for programmatic document generation from templates or publicly available PDF URLs, embedded signing experiences, and workflow automation. The platform is SOC 2 certified and compliant with E-SIGN, UETA, HIPAA, and GDPR. ## Authentication @@ -34,12 +34,11 @@ Eventually, access_token will expire, and accessing an API method will return 40 ### Document Creation and Management -Create documents programmatically from PandaDoc templates (populating them with dynamic data such as tokens, fields, recipients, and pricing), from file uploads (PDF, DOCX), or from publicly available PDF URLs. You can list and filter documents, change document status manually, update document ownership, transfer all documents ownership, send documents, share document links, download documents, and delete documents. Documents can be organized into folders. +Create documents programmatically from PandaDoc templates (populating them with dynamic data such as tokens, fields, recipients, and pricing) or from publicly available PDF URLs. You can list and filter documents, update draft document content, poll document status, change document status manually, send documents, share document links, download documents as Slate attachments, and delete documents. Documents can be placed into folders at creation time. - Documents support recipients (signers, approvers, CC), fields, tokens, content placeholders, and pricing tables. -- Document sections (bundles) allow appending additional content to a document after creation. -- Documents can have attachments added and managed. - Document settings, reminders (automatic and manual), and audit trails are configurable. +- PandaDoc's folder API supports listing, creating, and renaming folders. It does not support deleting folders or moving existing documents into folders through the public API. ### Template Management diff --git a/integrations/pandadoc/package.json b/integrations/pandadoc/package.json index 0626ed2f38..c5e0eafe86 100644 --- a/integrations/pandadoc/package.json +++ b/integrations/pandadoc/package.json @@ -4,15 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.8" } diff --git a/integrations/pandadoc/src/auth.ts b/integrations/pandadoc/src/auth.ts index d236c24082..8e303a0b17 100644 --- a/integrations/pandadoc/src/auth.ts +++ b/integrations/pandadoc/src/auth.ts @@ -1,9 +1,12 @@ import { createAxios, SlateAuth } from 'slates'; import { z } from 'zod'; +import { applyPandaDocApiErrorInterceptor } from './lib/client'; +import { pandadocServiceError } from './lib/errors'; let apiAxios = createAxios({ baseURL: 'https://api.pandadoc.com' }); +applyPandaDocApiErrorInterceptor(apiAxios); export let auth = SlateAuth.create() .output( @@ -81,7 +84,7 @@ export let auth = SlateAuth.create() handleTokenRefresh: async (ctx: any) => { if (!ctx.output.refreshToken) { - throw new Error('No refresh token available'); + throw pandadocServiceError('No PandaDoc refresh token is available.'); } let params = new URLSearchParams(); diff --git a/integrations/pandadoc/src/index.ts b/integrations/pandadoc/src/index.ts index c5b70d5c9e..fc92e8966e 100644 --- a/integrations/pandadoc/src/index.ts +++ b/integrations/pandadoc/src/index.ts @@ -10,6 +10,7 @@ import { deleteDocument, downloadDocument, getDocument, + getDocumentStatus, getTemplate, linkCrmObject, listContacts, @@ -20,11 +21,12 @@ import { listMembers, listTemplates, manageDocumentStatus, - moveDocumentToFolder, removeRecipient, + renameDocumentFolder, sendDocument, sendReminder, updateContact, + updateDocument, updateRecipient } from './tools'; import { documentEvents, templateEvents } from './triggers'; @@ -33,8 +35,10 @@ export let provider = Slate.create({ spec, tools: [ createDocument, + updateDocument, listDocuments, getDocument, + getDocumentStatus, sendDocument, manageDocumentStatus, deleteDocument, @@ -56,7 +60,7 @@ export let provider = Slate.create({ listContentLibrary, listDocumentFolders, createDocumentFolder, - moveDocumentToFolder + renameDocumentFolder ], triggers: [documentEvents, templateEvents] }); diff --git a/integrations/pandadoc/src/lib/client.ts b/integrations/pandadoc/src/lib/client.ts index 9b5ac3c9af..f48a044b49 100644 --- a/integrations/pandadoc/src/lib/client.ts +++ b/integrations/pandadoc/src/lib/client.ts @@ -1,10 +1,38 @@ -import { createAxios } from 'slates'; +import { createAxios, getResponseHeaderValue } from 'slates'; +import { pandadocApiError, pandadocServiceError } from './errors'; export interface ClientConfig { token: string; authType: 'oauth' | 'api_key'; } +let toBuffer = (data: unknown) => { + if (Buffer.isBuffer(data)) { + return data; + } + + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + + if (typeof data === 'string') { + return Buffer.from(data); + } + + return Buffer.from([]); +}; + +export let applyPandaDocApiErrorInterceptor = (axios: ReturnType) => { + (axios as any).interceptors?.response?.use( + (response: any) => response, + (error: unknown) => Promise.reject(pandadocApiError(error)) + ); +}; + export class PandaDocClient { private axios: ReturnType; @@ -19,6 +47,7 @@ export class PandaDocClient { 'Content-Type': 'application/json' } }); + applyPandaDocApiErrorInterceptor(this.axios); } // ─── Documents ─────────────────────────────────────────────────────── @@ -55,15 +84,39 @@ export class PandaDocClient { await this.axios.delete(`/public/v1/documents/${documentId}`); } - async downloadDocument(documentId: string): Promise<{ url: string }> { + async downloadDocument( + documentId: string, + params?: DownloadDocumentParams + ): Promise<{ contentBase64: string; mimeType: string; byteLength: number }> { let response = await this.axios.get(`/public/v1/documents/${documentId}/download`, { - maxRedirects: 0, - validateStatus: (status: number) => status >= 200 && status < 400 + params, + responseType: 'arraybuffer', + headers: { + Accept: 'application/pdf, application/zip' + } }); - if (response.status === 302 || response.status === 301) { - return { url: response.headers.location || response.headers.Location }; + + if (response.status === 202) { + let retryAfter = getResponseHeaderValue(response.headers, 'retry-after'); + throw pandadocServiceError( + `PandaDoc is still preparing the document download. Retry${retryAfter ? ` after ${retryAfter} seconds` : ' later'}.` + ); } - return { url: response.data?.url || response.request?.responseURL || '' }; + + let contentType = getResponseHeaderValue(response.headers, 'content-type'); + let mimeType = + typeof contentType === 'string' && contentType + ? contentType.split(';')[0]! + : params?.separate_files + ? 'application/zip' + : 'application/pdf'; + let buffer = toBuffer(response.data); + + return { + contentBase64: buffer.toString('base64'), + mimeType, + byteLength: buffer.byteLength + }; } async createDocumentLink( @@ -78,10 +131,6 @@ export class PandaDocClient { await this.axios.patch(`/public/v1/documents/${documentId}`, params); } - async moveDocumentToFolder(documentId: string, folderId: string): Promise { - await this.axios.post(`/public/v1/documents/${documentId}/move-to-folder/${folderId}`); - } - async transferDocumentOwnership(documentId: string, membershipId: string): Promise { await this.axios.patch(`/public/v1/documents/${documentId}/ownership`, { membership_id: membershipId @@ -104,7 +153,7 @@ export class PandaDocClient { async updateRecipient(documentId: string, recipientId: string, params: any): Promise { let response = await this.axios.patch( - `/public/v1/documents/${documentId}/recipients/${recipientId}`, + `/public/v1/documents/${documentId}/recipients/recipient/${recipientId}`, params ); return response.data; @@ -210,6 +259,11 @@ export class PandaDocClient { return response.data; } + async renameDocumentFolder(folderId: string, params: { name: string }): Promise { + let response = await this.axios.put(`/public/v1/documents/folders/${folderId}`, params); + return response.data; + } + async listTemplateFolders(params?: { parent_uuid?: string; count?: number; @@ -284,13 +338,18 @@ export interface CreateDocumentParams { name?: string; template_uuid?: string; url?: string; + parse_form_fields?: boolean; detect_title_variables?: boolean; folder_uuid?: string; owner?: { email?: string; membership_id?: string }; recipients: Array<{ - email: string; + email?: string; + phone?: string; first_name?: string; last_name?: string; + company?: string; + delivery_methods?: Record; + redirect?: Record; role?: string; signing_order?: number; }>; @@ -299,8 +358,10 @@ export interface CreateDocumentParams { metadata?: Record; tags?: string[]; pricing_tables?: any[]; + tables?: any[]; content_placeholders?: any[]; images?: any[]; + texts?: any[]; } export interface ListDocumentsParams { @@ -332,6 +393,17 @@ export interface SendDocumentParams { subject?: string; silent?: boolean; sender?: { email?: string; membership_id?: string }; + reply_to?: string; + forwarding_settings?: Record; + selected_approvers?: string[]; +} + +export interface DownloadDocumentParams { + watermark_text?: string; + watermark_color?: string; + watermark_font_size?: number; + watermark_opacity?: number; + separate_files?: boolean; } export interface CreateDocumentLinkParams { @@ -360,6 +432,8 @@ export interface ContactParams { country?: string; state?: string; street_address?: string; + city?: string; + postal_code?: string; } export interface CreateWebhookParams { diff --git a/integrations/pandadoc/src/lib/errors.ts b/integrations/pandadoc/src/lib/errors.ts new file mode 100644 index 0000000000..0d08ee2ff4 --- /dev/null +++ b/integrations/pandadoc/src/lib/errors.ts @@ -0,0 +1,120 @@ +import { badRequestError, ServiceError } from '@lowerdeck/error'; + +type ErrorResponse = { + status?: number; + statusText?: string; + data?: unknown; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +let addDetail = (details: string[], value: unknown) => { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + let detail = String(value).trim(); + if (detail && !details.includes(detail)) { + details.push(detail); + } +}; + +let collectDetails = (value: unknown, details: string[]) => { + if (Array.isArray(value)) { + for (let item of value) { + collectDetails(item, details); + } + return; + } + + if (!isRecord(value)) { + addDetail(details, value); + return; + } + + addDetail(details, value.detail); + addDetail(details, value.info_message); + addDetail(details, value.message); + addDetail(details, value.type); + addDetail(details, value.error); + addDetail(details, value.error_description); + addDetail(details, value.code); + collectDetails(value.details, details); + collectDetails(value.non_field_errors, details); + + for (let [key, child] of Object.entries(value)) { + if ( + [ + 'detail', + 'info_message', + 'message', + 'type', + 'error', + 'error_description', + 'code', + 'details', + 'non_field_errors' + ].includes(key) + ) { + continue; + } + collectDetails(child, details); + } +}; + +let extractPandaDocMessage = (error: unknown) => { + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let data = response?.data ?? (isRecord(error) ? error.data : undefined); + let details: string[] = []; + + collectDetails(data, details); + + if (details.length > 0) { + return details.join(' - '); + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; +}; + +let getPandaDocErrorStatus = (error: unknown) => { + if (!isRecord(error)) { + return undefined; + } + + let response = error.response as ErrorResponse | undefined; + let data = isRecord(error.data) ? error.data : undefined; + return response?.status ?? error.status ?? data?.status; +}; + +export let pandadocServiceError = (message: string) => + new ServiceError(badRequestError({ message })); + +export let pandadocApiError = (error: unknown, operation = 'request') => { + if (error instanceof ServiceError) { + return error; + } + + let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; + let status = getPandaDocErrorStatus(error); + let statusLabel = + status !== undefined + ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` + : ''; + + let serviceError = pandadocServiceError( + `PandaDoc API ${operation} failed: ${statusLabel}${extractPandaDocMessage(error)}` + ); + serviceError.data.reason = 'pandadoc_api_error'; + serviceError.data.upstreamStatus = status; + + if (error instanceof Error) { + serviceError.setParent(error); + } + + return serviceError; +}; diff --git a/integrations/pandadoc/src/tools.schema.test.ts b/integrations/pandadoc/src/tools.schema.test.ts new file mode 100644 index 0000000000..59e98231a6 --- /dev/null +++ b/integrations/pandadoc/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('PandaDoc tool input schemas', provider.actions); diff --git a/integrations/pandadoc/src/tools/create-document.ts b/integrations/pandadoc/src/tools/create-document.ts index 5c205c8d4a..95584f0411 100644 --- a/integrations/pandadoc/src/tools/create-document.ts +++ b/integrations/pandadoc/src/tools/create-document.ts @@ -1,17 +1,31 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { PandaDocClient } from '../lib/client'; +import { pandadocServiceError } from '../lib/errors'; import { spec } from '../spec'; let recipientSchema = z.object({ - email: z.string().describe('Recipient email address'), + email: z.string().optional().describe('Recipient email address'), + phone: z + .string() + .optional() + .describe('Recipient phone number. Required when creating SMS-only recipients.'), firstName: z.string().optional().describe('Recipient first name'), lastName: z.string().optional().describe('Recipient last name'), + company: z.string().optional().describe('Recipient company name'), role: z .string() .optional() .describe('Recipient role in the document (must match a role defined in the template)'), - signingOrder: z.number().optional().describe('Signing order for the recipient (1-based)') + signingOrder: z.number().optional().describe('Signing order for the recipient (1-based)'), + deliveryMethods: z + .record(z.string(), z.boolean()) + .optional() + .describe('Delivery methods to enable for this recipient, such as email or sms'), + redirect: z + .record(z.string(), z.any()) + .optional() + .describe('Recipient redirect settings after completing the document') }); let tokenSchema = z.object({ @@ -22,10 +36,11 @@ let tokenSchema = z.object({ export let createDocument = SlateTool.create(spec, { name: 'Create Document', key: 'create_document', - description: `Create a new PandaDoc document from a template, populating it with recipients, tokens, fields, metadata, and pricing data. The document is created in draft status and can then be sent for signing.`, + description: `Create a new PandaDoc document from a template or a publicly accessible PDF URL, populating it with recipients, tokens, fields, metadata, and content data. The document is created in draft status and can then be sent for signing.`, instructions: [ - 'The templateId must be the UUID of an existing PandaDoc template.', - 'Recipient roles must match the roles defined in the template.', + 'Use sourceType="template" with templateId for templates, or sourceType="pdf_url" with sourceUrl for a public PDF URL.', + 'Recipient roles must match roles defined in the template or PDF field mapping.', + 'Each recipient must include at least one of email or phone.', 'After creation, the document transitions from "uploaded" to "draft" status within a few seconds.' ], tags: { @@ -35,9 +50,21 @@ export let createDocument = SlateTool.create(spec, { }) .input( z.object({ + sourceType: z + .enum(['template', 'pdf_url']) + .optional() + .describe( + 'Document source. Use "template" with templateId or "pdf_url" with sourceUrl. Defaults from the supplied source field.' + ), templateId: z .string() + .optional() .describe('UUID of the PandaDoc template to create the document from'), + sourceUrl: z + .string() + .url() + .optional() + .describe('Publicly accessible PDF URL to create the document from'), name: z .string() .optional() @@ -63,14 +90,28 @@ export let createDocument = SlateTool.create(spec, { .optional() .describe('UUID of the folder to place the document in'), ownerEmail: z.string().optional().describe('Email of the document owner'), + ownerMembershipId: z.string().optional().describe('Membership ID of the document owner'), detectTitleVariables: z .boolean() .optional() .describe('Whether to resolve variables in the document title'), + parseFormFields: z + .boolean() + .optional() + .describe('Whether PandaDoc should parse form fields from a PDF URL source'), pricingTables: z .array(z.any()) .optional() .describe('Pricing table data to populate in the document'), + tables: z.array(z.any()).optional().describe('Table data to populate in the document'), + texts: z + .array(z.any()) + .optional() + .describe('Text block data to populate in the document'), + images: z + .array(z.any()) + .optional() + .describe('Image block data to populate in the document'), contentPlaceholders: z .array( z.object({ @@ -102,6 +143,33 @@ export let createDocument = SlateTool.create(spec, { authType: ctx.auth.authType }); + let sourceType = ctx.input.sourceType ?? (ctx.input.templateId ? 'template' : 'pdf_url'); + + if (sourceType === 'template' && !ctx.input.templateId) { + throw pandadocServiceError('templateId is required when sourceType is "template".'); + } + + if (sourceType === 'pdf_url' && !ctx.input.sourceUrl) { + throw pandadocServiceError('sourceUrl is required when sourceType is "pdf_url".'); + } + + if (ctx.input.templateId && ctx.input.sourceUrl) { + throw pandadocServiceError('Provide either templateId or sourceUrl, not both.'); + } + + if (ctx.input.ownerEmail && ctx.input.ownerMembershipId) { + throw pandadocServiceError('Provide either ownerEmail or ownerMembershipId, not both.'); + } + + let recipientWithoutDelivery = ctx.input.recipients.find( + recipient => !recipient.email && !recipient.phone + ); + if (recipientWithoutDelivery) { + throw pandadocServiceError( + 'Each recipient must include at least one of email or phone.' + ); + } + let fieldsPayload: Record | undefined; if (ctx.input.fields) { fieldsPayload = {}; @@ -118,23 +186,35 @@ export let createDocument = SlateTool.create(spec, { })); let result = await client.createDocument({ - template_uuid: ctx.input.templateId, + template_uuid: sourceType === 'template' ? ctx.input.templateId : undefined, + url: sourceType === 'pdf_url' ? ctx.input.sourceUrl : undefined, name: ctx.input.name, recipients: ctx.input.recipients.map(r => ({ email: r.email, + phone: r.phone, first_name: r.firstName, last_name: r.lastName, + company: r.company, role: r.role, - signing_order: r.signingOrder + signing_order: r.signingOrder, + delivery_methods: r.deliveryMethods, + redirect: r.redirect })), tokens: ctx.input.tokens?.map(t => ({ name: t.name, value: t.value })), fields: fieldsPayload, metadata: ctx.input.metadata, tags: ctx.input.tags, folder_uuid: ctx.input.folderUuid, - owner: ctx.input.ownerEmail ? { email: ctx.input.ownerEmail } : undefined, + owner: + ctx.input.ownerEmail || ctx.input.ownerMembershipId + ? { email: ctx.input.ownerEmail, membership_id: ctx.input.ownerMembershipId } + : undefined, detect_title_variables: ctx.input.detectTitleVariables, + parse_form_fields: ctx.input.parseFormFields, pricing_tables: ctx.input.pricingTables, + tables: ctx.input.tables, + texts: ctx.input.texts, + images: ctx.input.images, content_placeholders: contentPlaceholders }); diff --git a/integrations/pandadoc/src/tools/download-document.ts b/integrations/pandadoc/src/tools/download-document.ts index 000a71f3ec..d4eed57d9c 100644 --- a/integrations/pandadoc/src/tools/download-document.ts +++ b/integrations/pandadoc/src/tools/download-document.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createBase64Attachment, SlateTool } from 'slates'; import { z } from 'zod'; import { PandaDocClient } from '../lib/client'; import { spec } from '../spec'; @@ -6,21 +6,37 @@ import { spec } from '../spec'; export let downloadDocument = SlateTool.create(spec, { name: 'Download Document', key: 'download_document', - description: `Get the download URL for a completed PandaDoc document PDF.`, - constraints: ['The document must be in "completed" status to be downloaded.'], + description: `Download a PandaDoc document and return the file content as a Slate attachment, with structured output limited to file metadata.`, + constraints: [ + 'The standard PandaDoc download endpoint returns a PDF for documents that are ready to export.', + 'If PandaDoc is still preparing the file, retry after the service-provided delay.' + ], tags: { readOnly: true } }) .input( z.object({ - documentId: z.string().describe('UUID of the document to download') + documentId: z.string().describe('UUID of the document to download'), + watermarkText: z.string().optional().describe('Optional watermark text'), + watermarkColor: z + .string() + .optional() + .describe('Optional watermark color, such as "#FF0000"'), + watermarkFontSize: z.number().optional().describe('Optional watermark font size'), + watermarkOpacity: z.number().optional().describe('Optional watermark opacity'), + separateFiles: z + .boolean() + .optional() + .describe('If true, request separate files when PandaDoc supports it') }) ) .output( z.object({ - downloadUrl: z.string().describe('URL to download the document PDF'), - documentId: z.string().describe('UUID of the document') + documentId: z.string().describe('UUID of the document'), + mimeType: z.string().describe('MIME type of the returned attachment'), + byteLength: z.number().describe('Decoded byte length of the returned attachment'), + attachmentCount: z.number().describe('Number of attachments returned') }) ) .handleInvocation(async ctx => { @@ -29,14 +45,23 @@ export let downloadDocument = SlateTool.create(spec, { authType: ctx.auth.authType }); - let result = await client.downloadDocument(ctx.input.documentId); + let result = await client.downloadDocument(ctx.input.documentId, { + watermark_text: ctx.input.watermarkText, + watermark_color: ctx.input.watermarkColor, + watermark_font_size: ctx.input.watermarkFontSize, + watermark_opacity: ctx.input.watermarkOpacity, + separate_files: ctx.input.separateFiles + }); return { output: { - downloadUrl: result.url, - documentId: ctx.input.documentId + documentId: ctx.input.documentId, + mimeType: result.mimeType, + byteLength: result.byteLength, + attachmentCount: 1 }, - message: `Download URL generated for document \`${ctx.input.documentId}\`.` + attachments: [createBase64Attachment(result.contentBase64, result.mimeType)], + message: `Downloaded document \`${ctx.input.documentId}\` as an attachment.` }; }) .build(); diff --git a/integrations/pandadoc/src/tools/get-document-status.ts b/integrations/pandadoc/src/tools/get-document-status.ts new file mode 100644 index 0000000000..cb233ac568 --- /dev/null +++ b/integrations/pandadoc/src/tools/get-document-status.ts @@ -0,0 +1,56 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { PandaDocClient } from '../lib/client'; +import { spec } from '../spec'; + +export let getDocumentStatus = SlateTool.create(spec, { + name: 'Get Document Status', + key: 'get_document_status', + description: `Retrieve lightweight PandaDoc document status and lifecycle timestamps. Use this to poll newly created documents until they reach draft status before sending or updating.`, + tags: { + readOnly: true + } +}) + .input( + z.object({ + documentId: z.string().describe('UUID of the document to check') + }) + ) + .output( + z.object({ + documentId: z.string().describe('Document UUID'), + documentName: z.string().optional().describe('Document name'), + status: z.string().describe('Current document status'), + dateCreated: z.string().optional().describe('ISO 8601 creation timestamp'), + dateModified: z.string().optional().describe('ISO 8601 last modified timestamp'), + dateCompleted: z + .string() + .optional() + .describe('ISO 8601 completion timestamp, when available'), + expirationDate: z.string().nullable().optional().describe('ISO 8601 expiration date'), + version: z.string().optional().describe('Document version') + }) + ) + .handleInvocation(async ctx => { + let client = new PandaDocClient({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let document = await client.getDocumentStatus(ctx.input.documentId); + + return { + output: { + documentId: document.id || ctx.input.documentId, + documentName: document.name, + status: document.status, + dateCreated: document.date_created, + dateModified: document.date_modified, + dateCompleted: document.date_completed || undefined, + expirationDate: document.expiration_date || null, + version: document.version + }, + message: `Document \`${document.id || ctx.input.documentId}\` status is \`${document.status}\`.` + }; + }) + .build(); diff --git a/integrations/pandadoc/src/tools/index.ts b/integrations/pandadoc/src/tools/index.ts index d872fdc1a2..24c4206284 100644 --- a/integrations/pandadoc/src/tools/index.ts +++ b/integrations/pandadoc/src/tools/index.ts @@ -3,6 +3,7 @@ export * from './create-document-link'; export * from './delete-document'; export * from './download-document'; export * from './get-document'; +export * from './get-document-status'; export * from './get-template'; export * from './list-content-library'; export * from './list-documents'; @@ -16,3 +17,4 @@ export * from './manage-linked-objects'; export * from './manage-recipients'; export * from './send-document'; export * from './send-reminder'; +export * from './update-document'; diff --git a/integrations/pandadoc/src/tools/list-documents.ts b/integrations/pandadoc/src/tools/list-documents.ts index e11067cad5..48889fbdac 100644 --- a/integrations/pandadoc/src/tools/list-documents.ts +++ b/integrations/pandadoc/src/tools/list-documents.ts @@ -1,8 +1,43 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { PandaDocClient } from '../lib/client'; +import { pandadocServiceError } from '../lib/errors'; import { spec } from '../spec'; +let documentStatusSchema = z.enum([ + 'draft', + 'sent', + 'completed', + 'uploaded', + 'error', + 'viewed', + 'waiting_approval', + 'approved', + 'rejected', + 'waiting_pay', + 'paid', + 'voided', + 'declined', + 'external_review' +]); + +let statusMap: Record, number> = { + draft: 0, + sent: 1, + completed: 2, + uploaded: 3, + error: 4, + viewed: 5, + waiting_approval: 6, + approved: 7, + rejected: 8, + waiting_pay: 9, + paid: 10, + voided: 11, + declined: 12, + external_review: 13 +}; + export let listDocuments = SlateTool.create(spec, { name: 'List Documents', key: 'list_documents', @@ -14,29 +49,23 @@ export let listDocuments = SlateTool.create(spec, { .input( z.object({ query: z.string().optional().describe('Search by document name or reference number'), - status: z - .enum([ - 'draft', - 'sent', - 'completed', - 'uploaded', - 'error', - 'viewed', - 'waiting_approval', - 'approved', - 'rejected', - 'waiting_pay', - 'paid', - 'voided', - 'declined', - 'external_review' - ]) + documentId: z.string().optional().describe('Filter by exact document UUID'), + status: documentStatusSchema.optional().describe('Filter by document status'), + excludedStatus: documentStatusSchema .optional() - .describe('Filter by document status'), + .describe('Exclude documents with this status'), templateId: z.string().optional().describe('Filter by template UUID'), + formId: z + .string() + .optional() + .describe('Filter by form UUID. Cannot be combined with templateId.'), folderUuid: z.string().optional().describe('Filter by folder UUID'), tag: z.string().optional().describe('Filter by tag'), contactId: z.string().optional().describe('Filter by contact ID'), + membershipId: z + .string() + .optional() + .describe("Filter documents by the owner's PandaDoc membership ID"), createdFrom: z .string() .optional() @@ -62,9 +91,30 @@ export let listDocuments = SlateTool.create(spec, { .optional() .describe('Filter documents completed before this ISO 8601 date'), orderBy: z - .enum(['date_created', 'date_status_changed', 'date_modified', 'name']) + .enum([ + 'name', + 'date_created', + 'date_status_changed', + 'date_of_last_action', + 'date_modified', + 'date_sent', + 'date_completed', + 'date_expiration', + 'date_declined', + 'status', + '-name', + '-date_created', + '-date_status_changed', + '-date_of_last_action', + '-date_modified', + '-date_sent', + '-date_completed', + '-date_expiration', + '-date_declined', + '-status' + ]) .optional() - .describe('Sort order'), + .describe('Sort order. Prefix with "-" for descending order.'), page: z.number().optional().describe('Page number (starts at 1)'), count: z.number().optional().describe('Items per page (max 100, default 50)'), deleted: z.boolean().optional().describe('If true, show only deleted documents'), @@ -98,30 +148,21 @@ export let listDocuments = SlateTool.create(spec, { authType: ctx.auth.authType }); - let statusMap: Record = { - draft: 0, - sent: 1, - completed: 2, - uploaded: 3, - error: 4, - viewed: 5, - waiting_approval: 6, - approved: 7, - rejected: 8, - waiting_pay: 9, - paid: 10, - voided: 11, - declined: 12, - external_review: 13 - }; + if (ctx.input.templateId && ctx.input.formId) { + throw pandadocServiceError('templateId cannot be combined with formId.'); + } let params: any = {}; if (ctx.input.query) params.q = ctx.input.query; if (ctx.input.status) params.status = statusMap[ctx.input.status]; + if (ctx.input.excludedStatus) params.status__ne = statusMap[ctx.input.excludedStatus]; + if (ctx.input.documentId) params.id = ctx.input.documentId; if (ctx.input.templateId) params.template_id = ctx.input.templateId; + if (ctx.input.formId) params.form_id = ctx.input.formId; if (ctx.input.folderUuid) params.folder_uuid = ctx.input.folderUuid; if (ctx.input.tag) params.tag = ctx.input.tag; if (ctx.input.contactId) params.contact_id = ctx.input.contactId; + if (ctx.input.membershipId) params.membership_id = ctx.input.membershipId; if (ctx.input.createdFrom) params.created_from = ctx.input.createdFrom; if (ctx.input.createdTo) params.created_to = ctx.input.createdTo; if (ctx.input.modifiedFrom) params.modified_from = ctx.input.modifiedFrom; diff --git a/integrations/pandadoc/src/tools/manage-contacts.ts b/integrations/pandadoc/src/tools/manage-contacts.ts index 51d58e138a..64ca61c5e3 100644 --- a/integrations/pandadoc/src/tools/manage-contacts.ts +++ b/integrations/pandadoc/src/tools/manage-contacts.ts @@ -12,7 +12,9 @@ let contactFieldsSchema = z.object({ phone: z.string().optional().describe('Phone number'), country: z.string().optional().describe('Country'), state: z.string().optional().describe('State'), - streetAddress: z.string().optional().describe('Street address') + streetAddress: z.string().optional().describe('Street address'), + city: z.string().optional().describe('City'), + postalCode: z.string().optional().describe('Postal code') }); let contactOutputSchema = z.object({ @@ -25,7 +27,9 @@ let contactOutputSchema = z.object({ phone: z.string().optional().describe('Phone number'), country: z.string().optional().describe('Country'), state: z.string().optional().describe('State'), - streetAddress: z.string().optional().describe('Street address') + streetAddress: z.string().optional().describe('Street address'), + city: z.string().optional().describe('City'), + postalCode: z.string().optional().describe('Postal code') }); export let createContact = SlateTool.create(spec, { @@ -53,7 +57,9 @@ export let createContact = SlateTool.create(spec, { phone: ctx.input.phone, country: ctx.input.country, state: ctx.input.state, - street_address: ctx.input.streetAddress + street_address: ctx.input.streetAddress, + city: ctx.input.city, + postal_code: ctx.input.postalCode }); return { @@ -67,7 +73,9 @@ export let createContact = SlateTool.create(spec, { phone: result.phone, country: result.country, state: result.state, - streetAddress: result.street_address + streetAddress: result.street_address, + city: result.city, + postalCode: result.postal_code }, message: `Created contact **${ctx.input.email}** (ID: \`${result.id}\`).` }; @@ -110,7 +118,9 @@ export let listContacts = SlateTool.create(spec, { phone: c.phone, country: c.country, state: c.state, - streetAddress: c.street_address + streetAddress: c.street_address, + city: c.city, + postalCode: c.postal_code })); return { @@ -139,7 +149,9 @@ export let updateContact = SlateTool.create(spec, { phone: z.string().optional().describe('New phone number'), country: z.string().optional().describe('New country'), state: z.string().optional().describe('New state'), - streetAddress: z.string().optional().describe('New street address') + streetAddress: z.string().optional().describe('New street address'), + city: z.string().optional().describe('New city'), + postalCode: z.string().optional().describe('New postal code') }) ) .output(contactOutputSchema) @@ -159,6 +171,8 @@ export let updateContact = SlateTool.create(spec, { if (ctx.input.country) updateParams.country = ctx.input.country; if (ctx.input.state) updateParams.state = ctx.input.state; if (ctx.input.streetAddress) updateParams.street_address = ctx.input.streetAddress; + if (ctx.input.city) updateParams.city = ctx.input.city; + if (ctx.input.postalCode) updateParams.postal_code = ctx.input.postalCode; let result = await client.updateContact(ctx.input.contactId, updateParams); @@ -173,7 +187,9 @@ export let updateContact = SlateTool.create(spec, { phone: result.phone, country: result.country, state: result.state, - streetAddress: result.street_address + streetAddress: result.street_address, + city: result.city, + postalCode: result.postal_code }, message: `Updated contact \`${ctx.input.contactId}\`.` }; diff --git a/integrations/pandadoc/src/tools/manage-folders.ts b/integrations/pandadoc/src/tools/manage-folders.ts index 2ce1321057..3a72e5e9a6 100644 --- a/integrations/pandadoc/src/tools/manage-folders.ts +++ b/integrations/pandadoc/src/tools/manage-folders.ts @@ -6,7 +6,7 @@ import { spec } from '../spec'; export let listDocumentFolders = SlateTool.create(spec, { name: 'List Document Folders', key: 'list_document_folders', - description: `List document folders in the PandaDoc workspace. Optionally create a new folder or move a document into a folder.`, + description: `List document folders in the PandaDoc workspace.`, tags: { readOnly: true } @@ -98,25 +98,24 @@ export let createDocumentFolder = SlateTool.create(spec, { }) .build(); -export let moveDocumentToFolder = SlateTool.create(spec, { - name: 'Move Document to Folder', - key: 'move_document_to_folder', - description: `Move a PandaDoc document into a specific folder.`, +export let renameDocumentFolder = SlateTool.create(spec, { + name: 'Rename Document Folder', + key: 'rename_document_folder', + description: `Rename an existing PandaDoc document folder.`, tags: { readOnly: false } }) .input( z.object({ - documentId: z.string().describe('UUID of the document to move'), - folderId: z.string().describe('UUID of the destination folder') + folderId: z.string().describe('UUID of the document folder to rename'), + folderName: z.string().describe('New folder name') }) ) .output( z.object({ - moved: z.boolean().describe('Whether the document was successfully moved'), - documentId: z.string().describe('Document UUID'), - folderId: z.string().describe('Destination folder UUID') + folderId: z.string().describe('UUID of the renamed folder'), + folderName: z.string().describe('Updated folder name') }) ) .handleInvocation(async ctx => { @@ -125,15 +124,16 @@ export let moveDocumentToFolder = SlateTool.create(spec, { authType: ctx.auth.authType }); - await client.moveDocumentToFolder(ctx.input.documentId, ctx.input.folderId); + let result = await client.renameDocumentFolder(ctx.input.folderId, { + name: ctx.input.folderName + }); return { output: { - moved: true, - documentId: ctx.input.documentId, - folderId: ctx.input.folderId + folderId: result.uuid || result.id || ctx.input.folderId, + folderName: result.name || ctx.input.folderName }, - message: `Moved document \`${ctx.input.documentId}\` to folder \`${ctx.input.folderId}\`.` + message: `Renamed folder \`${ctx.input.folderId}\` to **${result.name || ctx.input.folderName}**.` }; }) .build(); diff --git a/integrations/pandadoc/src/tools/manage-linked-objects.ts b/integrations/pandadoc/src/tools/manage-linked-objects.ts index 68579ae353..c5baedc80d 100644 --- a/integrations/pandadoc/src/tools/manage-linked-objects.ts +++ b/integrations/pandadoc/src/tools/manage-linked-objects.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { PandaDocClient } from '../lib/client'; +import { pandadocServiceError } from '../lib/errors'; import { spec } from '../spec'; export let linkCrmObject = SlateTool.create(spec, { @@ -56,7 +57,9 @@ export let linkCrmObject = SlateTool.create(spec, { if (ctx.input.action === 'link') { if (!ctx.input.provider || !ctx.input.entityType || !ctx.input.entityId) { - throw new Error('provider, entityType, and entityId are required for link action'); + throw pandadocServiceError( + 'provider, entityType, and entityId are required for link action.' + ); } let result = await client.createLinkedObject(ctx.input.documentId, { @@ -97,7 +100,7 @@ export let linkCrmObject = SlateTool.create(spec, { if (ctx.input.action === 'unlink') { if (!ctx.input.linkedObjectId) { - throw new Error('linkedObjectId is required for unlink action'); + throw pandadocServiceError('linkedObjectId is required for unlink action.'); } await client.deleteLinkedObject(ctx.input.documentId, ctx.input.linkedObjectId); @@ -108,6 +111,6 @@ export let linkCrmObject = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${ctx.input.action}`); + throw pandadocServiceError(`Unknown action: ${ctx.input.action}`); }) .build(); diff --git a/integrations/pandadoc/src/tools/manage-recipients.ts b/integrations/pandadoc/src/tools/manage-recipients.ts index 2aac05c656..e012730461 100644 --- a/integrations/pandadoc/src/tools/manage-recipients.ts +++ b/integrations/pandadoc/src/tools/manage-recipients.ts @@ -1,8 +1,17 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { PandaDocClient } from '../lib/client'; +import { pandadocServiceError } from '../lib/errors'; import { spec } from '../spec'; +let deliveryMethodsSchema = z + .record(z.string(), z.boolean()) + .describe('PandaDoc delivery methods, such as email or sms'); + +let redirectSchema = z + .record(z.string(), z.any()) + .describe('PandaDoc recipient redirect settings'); + export let addRecipient = SlateTool.create(spec, { name: 'Add Recipient', key: 'add_recipient', @@ -14,17 +23,23 @@ export let addRecipient = SlateTool.create(spec, { .input( z.object({ documentId: z.string().describe('UUID of the document'), - email: z.string().describe('Recipient email address'), + email: z.string().optional().describe('Recipient email address'), + phone: z.string().optional().describe('Recipient phone number'), firstName: z.string().optional().describe('Recipient first name'), - lastName: z.string().optional().describe('Recipient last name') + lastName: z.string().optional().describe('Recipient last name'), + company: z.string().optional().describe('Recipient company name'), + deliveryMethods: deliveryMethodsSchema.optional(), + redirect: redirectSchema.optional() }) ) .output( z.object({ recipientId: z.string().describe('UUID of the added recipient'), - email: z.string().describe('Recipient email'), + email: z.string().optional().describe('Recipient email'), + phone: z.string().optional().describe('Recipient phone number'), firstName: z.string().optional().describe('Recipient first name'), - lastName: z.string().optional().describe('Recipient last name') + lastName: z.string().optional().describe('Recipient last name'), + company: z.string().optional().describe('Recipient company name') }) ) .handleInvocation(async ctx => { @@ -33,20 +48,30 @@ export let addRecipient = SlateTool.create(spec, { authType: ctx.auth.authType }); + if (!ctx.input.email && !ctx.input.phone) { + throw pandadocServiceError('Provide at least one of email or phone.'); + } + let result = await client.addRecipient(ctx.input.documentId, { email: ctx.input.email, + phone: ctx.input.phone, first_name: ctx.input.firstName, - last_name: ctx.input.lastName + last_name: ctx.input.lastName, + company: ctx.input.company, + delivery_methods: ctx.input.deliveryMethods, + redirect: ctx.input.redirect }); return { output: { recipientId: result.id, email: result.email || ctx.input.email, + phone: result.phone || ctx.input.phone, firstName: result.first_name || ctx.input.firstName, - lastName: result.last_name || ctx.input.lastName + lastName: result.last_name || ctx.input.lastName, + company: result.company || ctx.input.company }, - message: `Added recipient **${ctx.input.email}** to document \`${ctx.input.documentId}\`.` + message: `Added recipient **${ctx.input.email || ctx.input.phone}** to document \`${ctx.input.documentId}\`.` }; }) .build(); @@ -64,16 +89,31 @@ export let updateRecipient = SlateTool.create(spec, { documentId: z.string().describe('UUID of the document'), recipientId: z.string().describe('UUID of the recipient to update'), email: z.string().optional().describe('New email address'), + phone: z.string().optional().describe('New phone number'), firstName: z.string().optional().describe('New first name'), - lastName: z.string().optional().describe('New last name') + lastName: z.string().optional().describe('New last name'), + company: z.string().optional().describe('New company name'), + jobTitle: z.string().optional().describe('New job title'), + state: z.string().optional().describe('New state'), + streetAddress: z.string().optional().describe('New street address'), + city: z.string().optional().describe('New city'), + postalCode: z.string().optional().describe('New postal code'), + deliveryMethods: deliveryMethodsSchema.optional(), + redirect: redirectSchema.optional(), + verificationSettings: z + .record(z.string(), z.any()) + .optional() + .describe('PandaDoc verification_settings payload') }) ) .output( z.object({ recipientId: z.string().describe('UUID of the updated recipient'), email: z.string().optional().describe('Recipient email'), + phone: z.string().optional().describe('Recipient phone number'), firstName: z.string().optional().describe('Recipient first name'), - lastName: z.string().optional().describe('Recipient last name') + lastName: z.string().optional().describe('Recipient last name'), + company: z.string().optional().describe('Recipient company name') }) ) .handleInvocation(async ctx => { @@ -84,8 +124,24 @@ export let updateRecipient = SlateTool.create(spec, { let updateParams: any = {}; if (ctx.input.email) updateParams.email = ctx.input.email; + if (ctx.input.phone) updateParams.phone = ctx.input.phone; if (ctx.input.firstName) updateParams.first_name = ctx.input.firstName; if (ctx.input.lastName) updateParams.last_name = ctx.input.lastName; + if (ctx.input.company) updateParams.company = ctx.input.company; + if (ctx.input.jobTitle) updateParams.job_title = ctx.input.jobTitle; + if (ctx.input.state) updateParams.state = ctx.input.state; + if (ctx.input.streetAddress) updateParams.street_address = ctx.input.streetAddress; + if (ctx.input.city) updateParams.city = ctx.input.city; + if (ctx.input.postalCode) updateParams.postal_code = ctx.input.postalCode; + if (ctx.input.deliveryMethods) updateParams.delivery_methods = ctx.input.deliveryMethods; + if (ctx.input.redirect) updateParams.redirect = ctx.input.redirect; + if (ctx.input.verificationSettings) { + updateParams.verification_settings = ctx.input.verificationSettings; + } + + if (Object.keys(updateParams).length === 0) { + throw pandadocServiceError('Provide at least one recipient field to update.'); + } let result = await client.updateRecipient( ctx.input.documentId, @@ -95,10 +151,12 @@ export let updateRecipient = SlateTool.create(spec, { return { output: { - recipientId: result.id || ctx.input.recipientId, - email: result.email || ctx.input.email, - firstName: result.first_name || ctx.input.firstName, - lastName: result.last_name || ctx.input.lastName + recipientId: result?.id || ctx.input.recipientId, + email: result?.email || ctx.input.email, + phone: result?.phone || ctx.input.phone, + firstName: result?.first_name || ctx.input.firstName, + lastName: result?.last_name || ctx.input.lastName, + company: result?.company || ctx.input.company }, message: `Updated recipient \`${ctx.input.recipientId}\` on document \`${ctx.input.documentId}\`.` }; diff --git a/integrations/pandadoc/src/tools/send-document.ts b/integrations/pandadoc/src/tools/send-document.ts index 08def422c6..9b682ae949 100644 --- a/integrations/pandadoc/src/tools/send-document.ts +++ b/integrations/pandadoc/src/tools/send-document.ts @@ -1,6 +1,7 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; import { PandaDocClient } from '../lib/client'; +import { pandadocServiceError } from '../lib/errors'; import { spec } from '../spec'; export let sendDocument = SlateTool.create(spec, { @@ -25,7 +26,20 @@ export let sendDocument = SlateTool.create(spec, { .boolean() .optional() .describe('If true, no email is sent — used for embedded signing. Defaults to false.'), - senderEmail: z.string().optional().describe('Override the sender email address') + senderEmail: z.string().optional().describe('Override the sender email address'), + senderMembershipId: z + .string() + .optional() + .describe('Override the sender by PandaDoc membership ID'), + replyTo: z.string().optional().describe('Reply-to email address for recipient emails'), + forwardingSettings: z + .record(z.string(), z.any()) + .optional() + .describe('Raw PandaDoc forwarding_settings payload'), + selectedApprovers: z + .array(z.string()) + .optional() + .describe('Selected approver membership IDs for approval workflows') }) ) .output( @@ -40,11 +54,23 @@ export let sendDocument = SlateTool.create(spec, { authType: ctx.auth.authType }); + if (ctx.input.senderEmail && ctx.input.senderMembershipId) { + throw pandadocServiceError( + 'Provide either senderEmail or senderMembershipId, not both.' + ); + } + await client.sendDocument(ctx.input.documentId, { subject: ctx.input.subject, message: ctx.input.message, silent: ctx.input.silent, - sender: ctx.input.senderEmail ? { email: ctx.input.senderEmail } : undefined + sender: + ctx.input.senderEmail || ctx.input.senderMembershipId + ? { email: ctx.input.senderEmail, membership_id: ctx.input.senderMembershipId } + : undefined, + reply_to: ctx.input.replyTo, + forwarding_settings: ctx.input.forwardingSettings, + selected_approvers: ctx.input.selectedApprovers }); return { diff --git a/integrations/pandadoc/src/tools/update-document.ts b/integrations/pandadoc/src/tools/update-document.ts new file mode 100644 index 0000000000..c51f978f2b --- /dev/null +++ b/integrations/pandadoc/src/tools/update-document.ts @@ -0,0 +1,108 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { PandaDocClient } from '../lib/client'; +import { pandadocServiceError } from '../lib/errors'; +import { spec } from '../spec'; + +let tokenSchema = z.object({ + name: z.string().describe('Token name as defined in the document'), + value: z.string().describe('Value to populate the token with') +}); + +export let updateDocument = SlateTool.create(spec, { + name: 'Update Document', + key: 'update_document', + description: `Update a draft PandaDoc document with supported mutable values such as name, recipients, fields, tokens, tags, metadata, pricing tables, tables, text blocks, or image blocks.`, + constraints: [ + 'PandaDoc only allows document content updates while the document is in draft status.' + ], + tags: { + readOnly: false, + destructive: false + } +}) + .input( + z.object({ + documentId: z.string().describe('UUID of the draft document to update'), + name: z.string().optional().describe('New document name'), + recipients: z + .array(z.record(z.string(), z.any())) + .optional() + .describe('Replacement recipients payload in PandaDoc API shape'), + tokens: z.array(tokenSchema).optional().describe('Template tokens to update'), + fields: z + .record(z.string(), z.any()) + .optional() + .describe('Fields to update, keyed by field name with their values'), + metadata: z + .record(z.string(), z.string()) + .optional() + .describe('Custom metadata to update on the document'), + tags: z.array(z.string()).optional().describe('Tags to set on the document'), + pricingTables: z + .array(z.any()) + .optional() + .describe('Pricing table data to update in the document'), + tables: z.array(z.any()).optional().describe('Table data to update in the document'), + texts: z.array(z.any()).optional().describe('Text block data to update in the document'), + images: z + .array(z.any()) + .optional() + .describe('Image block data to update in the document') + }) + ) + .output( + z.object({ + documentId: z.string().describe('UUID of the updated document'), + updated: z.boolean().describe('Whether PandaDoc accepted the update request'), + updatedFields: z.array(z.string()).describe('Top-level document fields submitted') + }) + ) + .handleInvocation(async ctx => { + let client = new PandaDocClient({ + token: ctx.auth.token, + authType: ctx.auth.authType + }); + + let fieldsPayload: Record | undefined; + if (ctx.input.fields) { + fieldsPayload = {}; + for (let [key, value] of Object.entries(ctx.input.fields)) { + fieldsPayload[key] = { value }; + } + } + + let payload: Record = { + name: ctx.input.name, + recipients: ctx.input.recipients, + tokens: ctx.input.tokens?.map(token => ({ name: token.name, value: token.value })), + fields: fieldsPayload, + metadata: ctx.input.metadata, + tags: ctx.input.tags, + pricing_tables: ctx.input.pricingTables, + tables: ctx.input.tables, + texts: ctx.input.texts, + images: ctx.input.images + }; + + let updatePayload = Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined) + ); + let updatedFields = Object.keys(updatePayload); + + if (updatedFields.length === 0) { + throw pandadocServiceError('Provide at least one document field to update.'); + } + + await client.updateDocument(ctx.input.documentId, updatePayload); + + return { + output: { + documentId: ctx.input.documentId, + updated: true, + updatedFields + }, + message: `Updated document \`${ctx.input.documentId}\` fields: ${updatedFields.join(', ')}.` + }; + }) + .build(); diff --git a/integrations/pandadoc/vitest.config.ts b/integrations/pandadoc/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/pandadoc/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/pdf-api-io/README.md b/integrations/pdf-api-io/README.md index 41a4874306..41f461fef7 100644 --- a/integrations/pdf-api-io/README.md +++ b/integrations/pdf-api-io/README.md @@ -1,6 +1,6 @@ # Pdf Api.io -Generate PDF documents dynamically from pre-defined templates. List and retrieve template metadata including variable definitions and data types. Populate templates with dynamic data including text, images, tables, barcodes, QR codes, and charts. Merge multiple templates into a single PDF document. Output PDFs as binary data, base64-encoded strings, or temporary download URLs. Receive webhook notifications when PDFs are created. +Generate PDF documents dynamically from pre-defined templates. List and retrieve template metadata including variable definitions and data types. Populate templates with dynamic data including text, images, tables, barcodes, QR codes, and charts. Merge multiple templates into a single PDF document. Output PDFs as Slate attachments or temporary download URLs. Receive webhook notifications when PDFs are created. ## License diff --git a/integrations/pdf-api-io/docs/SPEC.md b/integrations/pdf-api-io/docs/SPEC.md index 2aa4eac163..3617d066cb 100644 --- a/integrations/pdf-api-io/docs/SPEC.md +++ b/integrations/pdf-api-io/docs/SPEC.md @@ -1,4 +1,4 @@ -Let me get more details on their webhooks and authentication documentation.# Slates Specification for PDF-API.io +# Slates Specification for PDF-API.io ## Overview @@ -29,7 +29,7 @@ Retrieve information about the PDF templates available in your account. You can Generate a PDF document by providing dynamic data that populates the placeholders defined in a template. The data is sent as key-value pairs matching the template's variables, supporting both simple string values and arrays of objects for dynamic tables with repeatable rows. -- **Output format**: The generated PDF can be returned as binary PDF data, a base64-encoded string (via JSON), or a temporary download URL (valid for 15 minutes). +- **Output format**: The generated PDF can be returned as a Slate attachment or a temporary download URL (valid for 15 minutes). The provider's JSON base64 response is converted to an attachment and is not exposed inline in tool output. - **Accept header**: Controls whether the response is binary PDF (`application/pdf`) or JSON with base64 content (`application/json`). - Templates support text, images, tables, barcodes, QR codes, charts, and conditional rendering of elements. @@ -37,7 +37,7 @@ Generate a PDF document by providing dynamic data that populates the placeholder Combine multiple templates into a single PDF document. Each template in the merge request can receive its own set of dynamic data. This is useful for generating multi-section documents (e.g., an invoice combined with a shipping label) in one API call. -- Supports the same output options as single-template PDF generation (binary, base64, or URL). +- Supports the same output options as single-template PDF generation (Slate attachment or URL). ## Events diff --git a/integrations/pdf-api-io/package.json b/integrations/pdf-api-io/package.json index 5eb1b54e9d..82ce57506a 100644 --- a/integrations/pdf-api-io/package.json +++ b/integrations/pdf-api-io/package.json @@ -7,12 +7,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.5" + "version": "0.2.0-rc.6" } diff --git a/integrations/pdf-api-io/slate.json b/integrations/pdf-api-io/slate.json index a1b8234c8e..6358d6e2b4 100644 --- a/integrations/pdf-api-io/slate.json +++ b/integrations/pdf-api-io/slate.json @@ -1,6 +1,6 @@ { "name": "@metorial/pdf-apiio", - "description": "Generate PDF documents dynamically from pre-defined templates. List and retrieve template metadata including variable definitions and data types. Populate templates with dynamic data including text, images, tables, barcodes, QR codes, and charts. Merge multiple templates into a single PDF document. Output PDFs as binary data, base64-encoded strings, or temporary download URLs. Receive webhook notifications when PDFs are created.", + "description": "Generate PDF documents dynamically from pre-defined templates. List and retrieve template metadata including variable definitions and data types. Populate templates with dynamic data including text, images, tables, barcodes, QR codes, and charts. Merge multiple templates into a single PDF document. Output PDFs as Slate attachments or temporary download URLs. Receive webhook notifications when PDFs are created.", "categories": ["document-processing"], "skills": [ "generate PDF from template", @@ -11,7 +11,7 @@ "render tables with dynamic rows", "generate QR codes and barcodes", "receive PDF creation webhooks", - "export PDF as base64 or URL" + "export PDF as attachment or URL" ], "logoUrl": "https://provider-logos.metorial-cdn.com/pdf-api-io.png" } diff --git a/integrations/pdf-api-io/src/auth.ts b/integrations/pdf-api-io/src/auth.ts index 56cf53deb7..3f1009e581 100644 --- a/integrations/pdf-api-io/src/auth.ts +++ b/integrations/pdf-api-io/src/auth.ts @@ -1,5 +1,6 @@ import { SlateAuth } from 'slates'; import { z } from 'zod'; +import { pdfApiIoServiceError } from './lib/errors'; export let auth = SlateAuth.create() .output( @@ -17,9 +18,15 @@ export let auth = SlateAuth.create() }), getOutput: async ctx => { + let token = ctx.input.token.trim(); + + if (!token) { + throw pdfApiIoServiceError('API token is required.'); + } + return { output: { - token: ctx.input.token + token } }; } diff --git a/integrations/pdf-api-io/src/lib/client.ts b/integrations/pdf-api-io/src/lib/client.ts index 75dd5d869c..5cff5f139a 100644 --- a/integrations/pdf-api-io/src/lib/client.ts +++ b/integrations/pdf-api-io/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { pdfApiIoApiError, pdfApiIoServiceError } from './errors'; let http = createAxios({ baseURL: 'https://pdf-api.io/api' @@ -31,10 +32,75 @@ export interface MergeTemplateEntry { } export interface JsonPdfResponse { - status: number; - data: string; + status?: number; + data?: string; + url?: string; } +export type PdfOutputOption = 'pdf' | 'url'; + +export type PdfResult = + | { + kind: 'attachment'; + contentBase64: string; + mimeType: 'application/pdf'; + byteLength: number; + } + | { + kind: 'url'; + url: string; + mimeType: null; + byteLength: null; + }; + +let decodedByteLength = (contentBase64: string) => { + let byteLength = Buffer.byteLength(contentBase64, 'base64'); + + if (byteLength <= 0) { + throw pdfApiIoServiceError('PDF-API.io returned empty PDF content.'); + } + + return byteLength; +}; + +let normalizePdfResponse = ( + response: JsonPdfResponse, + output: PdfOutputOption, + operation: string +): PdfResult => { + if (typeof response.status === 'number' && response.status >= 400) { + throw pdfApiIoServiceError(`PDF-API.io API ${operation} failed: HTTP ${response.status}.`); + } + + if (output === 'url') { + let url = response.url ?? response.data; + + if (typeof url !== 'string' || !url.startsWith('http')) { + throw pdfApiIoServiceError( + `PDF-API.io API ${operation} did not return a valid download URL.` + ); + } + + return { + kind: 'url', + url, + mimeType: null, + byteLength: null + }; + } + + if (typeof response.data !== 'string' || response.data.length === 0) { + throw pdfApiIoServiceError(`PDF-API.io API ${operation} did not return PDF content.`); + } + + return { + kind: 'attachment', + contentBase64: response.data, + mimeType: 'application/pdf', + byteLength: decodedByteLength(response.data) + }; +}; + export class Client { private token: string; @@ -51,61 +117,85 @@ export class Client { } async listTemplates(): Promise { - let response = await http.get('/templates', { - headers: this.headers() - }); - return response.data; + try { + let response = await http.get('/templates', { + headers: this.headers() + }); + + if (!Array.isArray(response.data)) { + throw pdfApiIoServiceError( + 'PDF-API.io API list templates returned an invalid response.' + ); + } + + return response.data; + } catch (error) { + throw pdfApiIoApiError(error, 'list templates'); + } } async getTemplate(templateId: string): Promise