@nuxtagentic/integrations 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +175 -2
  3. package/client.ts +124 -0
  4. package/module.ts +44 -0
  5. package/package.json +52 -3
  6. package/runtime/assets/logos/discord.svg +1 -0
  7. package/runtime/assets/logos/github.svg +1 -0
  8. package/runtime/assets/logos/gmail.svg +1 -0
  9. package/runtime/assets/logos/googleads.svg +1 -0
  10. package/runtime/assets/logos/googleanalytics.svg +1 -0
  11. package/runtime/assets/logos/googlecalendar.svg +1 -0
  12. package/runtime/assets/logos/googledrive.svg +1 -0
  13. package/runtime/assets/logos/googlesearchconsole.svg +1 -0
  14. package/runtime/assets/logos/hubspot.svg +1 -0
  15. package/runtime/assets/logos/intercom.svg +1 -0
  16. package/runtime/assets/logos/linear.svg +1 -0
  17. package/runtime/assets/logos/mcp.svg +1 -0
  18. package/runtime/assets/logos/meta.svg +1 -0
  19. package/runtime/assets/logos/notion.svg +1 -0
  20. package/runtime/assets/logos/reddit.svg +1 -0
  21. package/runtime/assets/logos/sentry.svg +1 -0
  22. package/runtime/assets/logos/shopify.svg +1 -0
  23. package/runtime/assets/logos/slack.svg +1 -0
  24. package/runtime/assets/logos/stripe.svg +1 -0
  25. package/runtime/assets/logos/tiktok-dark.svg +1 -0
  26. package/runtime/assets/logos/tiktok.svg +1 -0
  27. package/runtime/assets/logos/vercel.svg +1 -0
  28. package/runtime/assets/logos/x.svg +1 -0
  29. package/runtime/components/IntegrationCard.vue +82 -0
  30. package/runtime/components/IntegrationCatalog.vue +130 -0
  31. package/runtime/components/IntegrationConnectionPanel.vue +589 -0
  32. package/runtime/components/IntegrationLogo.vue +41 -0
  33. package/runtime/pages/integrations.vue +481 -0
  34. package/runtime/server/api/integrations/approvals/[id].post.ts +69 -0
  35. package/runtime/server/api/integrations/catalog/[provider]/action.post.ts +35 -0
  36. package/runtime/server/api/integrations/catalog/[provider]/connection.post.ts +242 -0
  37. package/runtime/server/api/integrations/catalog/[provider]/tools.get.ts +40 -0
  38. package/runtime/server/api/integrations/catalog/index.get.ts +48 -0
  39. package/runtime/server/api/integrations/catalog/tools.get.ts +27 -0
  40. package/runtime/server/plugins/connect-maintenance.ts +30 -0
  41. package/runtime/server/utils/approval-continuation.ts +37 -0
  42. package/runtime/server/utils/google-mcp.ts +296 -0
  43. package/runtime/server/utils/integration-adapters.ts +836 -0
  44. package/runtime/server/utils/integration-context.ts +510 -0
  45. package/runtime/server/utils/integration-crypto.ts +13 -0
  46. package/runtime/server/utils/request-body.ts +38 -0
  47. package/runtime/shared/agent.ts +14 -0
  48. package/runtime/shared/catalog.ts +566 -0
  49. package/runtime/shared/google-mcp.ts +8 -0
  50. package/runtime/workspace.ts +15 -0
  51. package/schema.ts +179 -0
@@ -0,0 +1,836 @@
1
+ import { getIntegrationDefinition, getIntegrationToolName } from '../../shared/catalog'
2
+
3
+ type Input = Record<string, unknown>
4
+ export type Credentials = Record<string, string>
5
+ type Endpoint = {
6
+ method?: string
7
+ path: string
8
+ form?: boolean
9
+ graphql?: string
10
+ approval?: boolean
11
+ }
12
+ export type IntegrationAction = Endpoint & {
13
+ id: string
14
+ name: string
15
+ label: string
16
+ parameters: Record<string, unknown>
17
+ }
18
+
19
+ // Paths and methods are owned by adapters. Agents never supply an API URL or headers.
20
+ const endpoints: Record<string, Endpoint[]> = {
21
+ github: [
22
+ { path: '/user/repos' },
23
+ { method: 'POST', path: '/repos/{owner}/{repo}/issues/{number}/comments' },
24
+ { method: 'POST', path: '/repos/{owner}/{repo}/issues' },
25
+ ],
26
+ ga: [
27
+ { method: 'POST', path: '/v1beta/properties/{propertyId}:runReport' },
28
+ { method: 'POST', path: '/v1beta/properties/{propertyId}:runReport' },
29
+ { method: 'POST', path: '/v1alpha/properties/{propertyId}/audiences' },
30
+ ],
31
+ gsc: [
32
+ { method: 'POST', path: '/webmasters/v3/sites/{siteUrl}/searchAnalytics/query' },
33
+ { method: 'POST', path: '/v1/urlInspection/index:inspect' },
34
+ { method: 'POST', path: '/v3/urlNotifications:publish' },
35
+ ],
36
+ gads: [
37
+ { method: 'POST', path: '/v25/customers/{customerId}/googleAds:search' },
38
+ { method: 'POST', path: '/v25/customers/{customerId}/campaignBudgets:mutate' },
39
+ { method: 'POST', path: '/v25/customers/{customerId}/adGroupCriteria:mutate' },
40
+ ],
41
+ meta: [
42
+ { path: '/{accountId}/insights' },
43
+ { method: 'POST', path: '/{accountId}/campaigns' },
44
+ { method: 'POST', path: '/{campaignId}' },
45
+ { method: 'POST', path: '/{adId}' },
46
+ ],
47
+ x: [
48
+ { path: '/12/stats/accounts/{accountId}' },
49
+ { method: 'POST', path: '/12/accounts/{accountId}/promoted_tweets', form: true },
50
+ { method: 'PUT', path: '/12/accounts/{accountId}/line_items/{lineItemId}', form: true },
51
+ ],
52
+ tiktok: [
53
+ { path: '/open_api/v1.3/report/integrated/get/' },
54
+ { method: 'POST', path: '/open_api/v1.3/campaign/update/' },
55
+ { method: 'POST', path: '/open_api/v1.3/adgroup/status/update/' },
56
+ ],
57
+ reddit: [
58
+ { method: 'POST', path: '/api/v3/ad_accounts/{accountId}/reports' },
59
+ { method: 'PATCH', path: '/api/v3/campaigns/{campaignId}' },
60
+ ],
61
+ stripe: [
62
+ { path: '/v1/payment_intents' },
63
+ { method: 'POST', path: '/v1/invoices', form: true },
64
+ { method: 'POST', path: '/v1/refunds', form: true, approval: true },
65
+ ],
66
+ shopify: [
67
+ {
68
+ path: '',
69
+ graphql:
70
+ 'query Orders($first: Int!, $after: String) { orders(first: $first, after: $after) { nodes { id name createdAt displayFinancialStatus totalPriceSet { shopMoney { amount currencyCode } } } pageInfo { hasNextPage endCursor } } }',
71
+ },
72
+ {
73
+ path: '',
74
+ graphql:
75
+ 'mutation UpdateProduct($product: ProductUpdateInput!) { productUpdate(product: $product) { product { id title } userErrors { field message } } }',
76
+ },
77
+ {
78
+ path: '',
79
+ graphql:
80
+ 'mutation TagCustomer($id: ID!, $tags: [String!]!) { tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } } }',
81
+ },
82
+ ],
83
+ hubspot: [
84
+ { path: '/crm/v3/objects/contacts' },
85
+ { method: 'PATCH', path: '/crm/v3/objects/deals/{dealId}' },
86
+ { method: 'POST', path: '/crm/v3/objects/notes' },
87
+ ],
88
+ linear: [
89
+ {
90
+ path: '',
91
+ graphql:
92
+ 'query Issues($first: Int) { issues(first: $first) { nodes { id identifier title url state { name } } } }',
93
+ },
94
+ {
95
+ path: '',
96
+ graphql:
97
+ 'mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title url } } }',
98
+ },
99
+ {
100
+ path: '',
101
+ graphql:
102
+ 'mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { id identifier title url } } }',
103
+ },
104
+ ],
105
+ vercel: [
106
+ { path: '/v6/deployments' },
107
+ { path: '/v3/deployments/{deploymentId}/events' },
108
+ { method: 'POST', path: '/v10/projects/{projectId}/promote/{deploymentId}' },
109
+ ],
110
+ gmail: [
111
+ { path: '/gmail/v1/users/me/messages' },
112
+ { method: 'POST', path: '/gmail/v1/users/me/drafts' },
113
+ { method: 'POST', path: '/gmail/v1/users/me/messages/send' },
114
+ ],
115
+ discord: [
116
+ { path: '/guilds/{guildId}/channels' },
117
+ { method: 'POST', path: '/channels/{channelId}/messages' },
118
+ ],
119
+ intercom: [
120
+ { path: '/conversations' },
121
+ { method: 'POST', path: '/conversations/{conversationId}/reply', approval: true },
122
+ { method: 'POST', path: '/conversations/{conversationId}/tags' },
123
+ ],
124
+ notion: [
125
+ { path: '/v1/pages/{pageId}' },
126
+ { method: 'POST', path: '/v1/pages' },
127
+ { method: 'PATCH', path: '/v1/databases/{databaseId}' },
128
+ ],
129
+ gcal: [
130
+ { path: '/calendar/v3/calendars/{calendarId}/events' },
131
+ { method: 'POST', path: '/calendar/v3/calendars/{calendarId}/events' },
132
+ ],
133
+ gdrive: [
134
+ { path: '/drive/v3/files' },
135
+ { method: 'POST', path: '/drive/v3/files' },
136
+ { method: 'PATCH', path: '/drive/v3/files/{fileId}' },
137
+ ],
138
+ slack: [{ path: '/conversations.list' }, { method: 'POST', path: '/chat.postMessage' }],
139
+ }
140
+
141
+ const bases: Record<string, string> = {
142
+ github: 'https://api.github.com',
143
+ ga: 'https://analyticsdata.googleapis.com',
144
+ gsc: 'https://www.googleapis.com',
145
+ gads: 'https://googleads.googleapis.com',
146
+ meta: 'https://graph.facebook.com',
147
+ x: 'https://ads-api.x.com',
148
+ tiktok: 'https://business-api.tiktok.com',
149
+ reddit: 'https://ads-api.reddit.com',
150
+ stripe: 'https://api.stripe.com',
151
+ hubspot: 'https://api.hubapi.com',
152
+ linear: 'https://api.linear.app/graphql',
153
+ vercel: 'https://api.vercel.com',
154
+ gmail: 'https://gmail.googleapis.com',
155
+ discord: 'https://discord.com/api/v10',
156
+ intercom: 'https://api.intercom.io',
157
+ notion: 'https://api.notion.com',
158
+ gcal: 'https://www.googleapis.com',
159
+ gdrive: 'https://www.googleapis.com',
160
+ slack: 'https://slack.com/api',
161
+ }
162
+ const bodyHints: Record<string, string> = {
163
+ ga_run_reports:
164
+ 'dateRanges: [{startDate, endDate}], metrics: [{name}], optional dimensions: [{name}].',
165
+ ga_compare_date_ranges:
166
+ 'Two dateRanges: [{startDate, endDate}, {startDate, endDate}], metrics: [{name}], optional dimensions: [{name}].',
167
+ ga_create_audiences:
168
+ 'displayName, description, membershipDurationDays, filterClauses with audience filters.',
169
+ gsc_read_search_performance:
170
+ 'startDate and endDate (YYYY-MM-DD), optional dimensions and rowLimit.',
171
+ gsc_inspect_urls: 'inspectionUrl and siteUrl.',
172
+ gsc_request_indexing:
173
+ 'url and type: URL_UPDATED or URL_DELETED. Google restricts eligible indexing content.',
174
+ gads_read_campaign_stats: 'query: a Google Ads Query Language SELECT query.',
175
+ gads_adjust_bids_and_budgets:
176
+ 'operations: [{update:{resourceName, amountMicros}}] for a budget, or operations: [{update:{resourceName, cpcBidMicros}}] with resource: bid.',
177
+ gads_pause_keywords: 'operations: [{update:{resourceName}}]. The server enforces PAUSED status.',
178
+ meta_create_campaigns:
179
+ 'name, objective, special_ad_categories and optional status. Use PAUSED to create a draft campaign.',
180
+ meta_adjust_budgets: 'daily_budget or lifetime_budget in the account currency’s minor units.',
181
+ meta_pause_ads: 'An empty object. The server enforces PAUSED status.',
182
+ x_create_promoted_posts: 'line_item_id and tweet_ids.',
183
+ x_adjust_bids: 'bid_amount_local_micro and optional bid_type.',
184
+ tiktok_adjust_budgets: 'campaign_id and budget.',
185
+ tiktok_pause_ad_groups: 'adgroup_ids. The server enforces DISABLE status.',
186
+ reddit_read_campaign_stats: 'data: {starts_at, ends_at, fields, breakdowns}.',
187
+ reddit_adjust_budgets:
188
+ 'data: {goal_value, spend_cap}, in microcurrency. goal_value requires a campaign with budget optimization enabled.',
189
+ stripe_create_invoices:
190
+ 'customer and optional collection_method, days_until_due, description. Returns a draft invoice.',
191
+ stripe_issue_refunds:
192
+ 'payment_intent or charge, optional amount and reason. Requires owner approval before execution.',
193
+ hubspot_update_deal_stages: 'properties: {dealstage}.',
194
+ hubspot_log_activity: 'properties: {hs_timestamp, hs_note_body}, optional associations.',
195
+ discord_post_messages:
196
+ 'content, optional allowed_mentions. The bot needs permission to send in the selected channel.',
197
+ intercom_draft_replies:
198
+ 'body: the reply text, admin_id: the sender ID. Creates an approval draft; approval sends the reply.',
199
+ intercom_tag_conversations: 'id: tag ID, admin_id: sender ID.',
200
+ gcal_create_events:
201
+ 'summary, start: {dateTime, timeZone}, end: {dateTime, timeZone}, optional attendees.',
202
+ gdrive_create_files: 'name, mimeType and optional parents. Creates a file or folder.',
203
+ gdrive_move_files:
204
+ 'An empty object. Supply query.addParents and query.removeParents with folder IDs.',
205
+ }
206
+
207
+ const providerBodyHints: Record<string, string> = {
208
+ gmail: 'Email fields: to, subject, text, optional threadId.',
209
+ slack: 'channel and text.',
210
+ }
211
+
212
+ const verificationPaths: Record<string, string> = {
213
+ github: '/user',
214
+ ga: '/v1beta/accountSummaries',
215
+ gsc: '/webmasters/v3/sites',
216
+ gads: '/v25/customers:listAccessibleCustomers',
217
+ meta: '/me',
218
+ x: '/12/accounts',
219
+ tiktok: '/open_api/v1.3/advertiser/info/',
220
+ reddit: '/api/v3/me',
221
+ stripe: '/v1/account',
222
+ hubspot: '/account-info/v3/details',
223
+ vercel: '/v2/user',
224
+ gmail: '/gmail/v1/users/me/profile',
225
+ discord: '/users/@me',
226
+ intercom: '/me',
227
+ notion: '/v1/users/me',
228
+ gcal: '/calendar/v3/users/me/calendarList',
229
+ gdrive: '/drive/v3/about',
230
+ slack: '/auth.test',
231
+ }
232
+
233
+ const verificationQueries: Record<string, string> = {
234
+ linear: 'query { viewer { id name email } }',
235
+ shopify: 'query { shop { name myshopifyDomain } }',
236
+ }
237
+
238
+ /** Builds the agent-facing tool definitions for every action an integration exposes. */
239
+ export function listIntegrationActions(provider: string): IntegrationAction[] {
240
+ const definition = getIntegrationDefinition(provider)
241
+ return (endpoints[provider] ?? []).map((endpoint, index) => {
242
+ const action = definition!.actions[index]!
243
+ const toolName = getIntegrationToolName(provider, action.id)
244
+ const pathFields = [...endpoint.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]!)
245
+ const properties: Record<string, unknown> = Object.fromEntries(
246
+ pathFields.map((field) => [
247
+ field,
248
+ { type: 'string', description: `The provider's ${field}.` },
249
+ ]),
250
+ )
251
+ // Google Ads falls back to the connected customer and calendars default to "primary".
252
+ const required = pathFields.filter(
253
+ (field) => !(provider === 'gads' && field === 'customerId') && field !== 'calendarId',
254
+ )
255
+ if (endpoint.graphql) {
256
+ properties.body = {
257
+ type: 'object',
258
+ description:
259
+ 'GraphQL variables. Shopify Orders: first, after; UpdateProduct: product; TagCustomer: id, tags. Linear: first, input, or id and input.',
260
+ }
261
+ required.push('body')
262
+ } else if (endpoint.method && endpoint.method !== 'GET') {
263
+ properties.body = {
264
+ type: 'object',
265
+ description:
266
+ bodyHints[toolName] ??
267
+ providerBodyHints[provider] ??
268
+ `JSON fields accepted by ${definition!.name} for ${action.label}.`,
269
+ }
270
+ required.push('body')
271
+ }
272
+ properties.query = {
273
+ type: 'object',
274
+ description: 'Optional provider query parameters, including filters and pagination.',
275
+ }
276
+ if (provider === 'gmail' && index === 0) {
277
+ properties.messageId = {
278
+ type: 'string',
279
+ description:
280
+ 'Read a specific message with full headers and content. Omit to list messages.',
281
+ }
282
+ }
283
+ if (provider === 'gads' && index === 1) {
284
+ properties.resource = {
285
+ type: 'string',
286
+ enum: ['budget', 'bid'],
287
+ description: 'Choose campaign budget (default) or ad-group CPC bid updates.',
288
+ }
289
+ }
290
+ return {
291
+ ...endpoint,
292
+ ...action,
293
+ name: toolName,
294
+ parameters: { type: 'object', properties, required, additionalProperties: false },
295
+ }
296
+ })
297
+ }
298
+
299
+ /**
300
+ * Calls a cheap read-only endpoint to prove the credentials work.
301
+ *
302
+ * @returns The account name to show on the connection card.
303
+ */
304
+ export async function verifyIntegration(provider: string, credentials: Credentials) {
305
+ const graphql = verificationQueries[provider]
306
+ const response = graphql
307
+ ? await sendProviderRequest({
308
+ provider,
309
+ credentials,
310
+ endpoint: { graphql, path: '' },
311
+ input: { body: {} },
312
+ })
313
+ : await sendProviderRequest({
314
+ provider,
315
+ credentials,
316
+ endpoint: { path: verificationPaths[provider] ?? '/', method: 'GET' },
317
+ input: { query: getVerificationQuery(provider, credentials) },
318
+ })
319
+ const value = toRecord(response)
320
+ const data = toRecord(value.data)
321
+ return String(
322
+ value.login ??
323
+ value.emailAddress ??
324
+ value.name ??
325
+ value.account_name ??
326
+ toRecord(value.user).name ??
327
+ toRecord(data.shop).name ??
328
+ toRecord(data.viewer).name ??
329
+ toRecord(data.user).display_name ??
330
+ value.team ??
331
+ getIntegrationDefinition(provider)!.name,
332
+ )
333
+ }
334
+
335
+ function getVerificationQuery(provider: string, credentials: Credentials): Input {
336
+ if (provider === 'gdrive') {
337
+ return { fields: 'user' }
338
+ }
339
+ if (provider === 'tiktok') {
340
+ return { advertiser_ids: [credentials.advertiserId] }
341
+ }
342
+ return {}
343
+ }
344
+
345
+ export interface ProviderRequestOptions {
346
+ signal?: AbortSignal
347
+ /** Lets the provider deduplicate retried writes. Only Stripe honours it today. */
348
+ idempotencyKey?: string
349
+ }
350
+
351
+ /** Runs one catalog action against the provider API with the stored credentials. */
352
+ export async function executeIntegrationAction(
353
+ provider: string,
354
+ actionId: string,
355
+ { credentials, input }: { credentials: Credentials; input: Input },
356
+ options: ProviderRequestOptions = {},
357
+ ) {
358
+ const action = listIntegrationActions(provider).find((entry) => entry.id === actionId)
359
+ if (!action) {
360
+ throw new Error('Choose an action supported by this integration.')
361
+ }
362
+ const query = getActionQuery(provider, action, toRecord(input.query))
363
+ const parameters = {
364
+ ...input,
365
+ body: getActionBody(provider, actionId, toRecord(input.body)),
366
+ query:
367
+ provider === 'vercel' && actionId === 'read_build_logs' ? { ...query, follow: 0 } : query,
368
+ }
369
+ const endpoint = getActionEndpoint(provider, action, input, credentials)
370
+ const result = await sendProviderRequest(
371
+ { provider, credentials, endpoint, input: parameters },
372
+ options,
373
+ )
374
+
375
+ // These read actions combine two listings into a single tool result.
376
+ if (provider === 'stripe' && actionId === 'read_payments_and_customers') {
377
+ const customers = await sendProviderRequest(
378
+ { provider, credentials, endpoint: { path: '/v1/customers' }, input },
379
+ { signal: options.signal },
380
+ )
381
+ return { payments: result, customers }
382
+ }
383
+ if (provider === 'hubspot' && actionId === 'read_contacts_and_deals') {
384
+ const deals = await sendProviderRequest(
385
+ { provider, credentials, endpoint: { path: '/crm/v3/objects/deals' }, input },
386
+ { signal: options.signal },
387
+ )
388
+ return { contacts: result, deals }
389
+ }
390
+ return result
391
+ }
392
+
393
+ /** Write actions only forward the query parameters their provider documents. */
394
+ function getActionQuery(provider: string, action: IntegrationAction, query: Input) {
395
+ const isWrite = (action.method && action.method !== 'GET') || action.graphql
396
+ if (!isWrite) {
397
+ return query
398
+ }
399
+ const allowedKeys =
400
+ provider === 'gdrive' && action.id === 'move_files'
401
+ ? ['addParents', 'removeParents', 'fields', 'supportsAllDrives']
402
+ : ['teamId']
403
+ return pick(query, allowedKeys)
404
+ }
405
+
406
+ function getActionEndpoint(
407
+ provider: string,
408
+ action: IntegrationAction,
409
+ input: Input,
410
+ credentials: Credentials,
411
+ ): Endpoint {
412
+ // Installation tokens list the installation's repositories, not a user's.
413
+ if (
414
+ provider === 'github' &&
415
+ action.id === 'read_repositories' &&
416
+ credentials.githubInstallation
417
+ ) {
418
+ return { path: '/installation/repositories' }
419
+ }
420
+ if (provider === 'gmail' && action.id === 'read_email' && input.messageId) {
421
+ return { path: '/gmail/v1/users/me/messages/{messageId}' }
422
+ }
423
+ if (provider === 'gads' && action.id === 'adjust_bids_and_budgets' && input.resource === 'bid') {
424
+ return { ...action, path: '/v25/customers/{customerId}/adGroups:mutate' }
425
+ }
426
+ return action
427
+ }
428
+
429
+ interface ProviderRequest {
430
+ provider: string
431
+ credentials: Credentials
432
+ endpoint: Endpoint
433
+ input: Input
434
+ }
435
+
436
+ async function sendProviderRequest(
437
+ { provider, credentials, endpoint, input }: ProviderRequest,
438
+ { signal, idempotencyKey }: ProviderRequestOptions = {},
439
+ ) {
440
+ const url = buildRequestUrl(provider, credentials, endpoint, input)
441
+ const method = endpoint.graphql ? 'POST' : (endpoint.method ?? 'GET')
442
+ const body = buildRequestBody(provider, credentials, endpoint, input)
443
+ const headers = buildRequestHeaders(provider, credentials)
444
+ if (provider === 'x') {
445
+ headers.Authorization = await signOAuth1Request(
446
+ method,
447
+ url,
448
+ credentials,
449
+ endpoint.form ? encodeFormData(body) : undefined,
450
+ )
451
+ }
452
+ let encodedBody: string | URLSearchParams | undefined
453
+ if (method !== 'GET') {
454
+ headers['Content-Type'] = endpoint.form
455
+ ? 'application/x-www-form-urlencoded'
456
+ : 'application/json'
457
+ encodedBody = endpoint.form
458
+ ? encodeFormData(body)
459
+ : JSON.stringify(endpoint.graphql ? { query: endpoint.graphql, variables: body } : body)
460
+ }
461
+ if (provider === 'stripe' && idempotencyKey && method === 'POST') {
462
+ headers['Idempotency-Key'] = idempotencyKey
463
+ }
464
+ const response = await fetch(url, {
465
+ method,
466
+ headers,
467
+ body: encodedBody,
468
+ signal: signal ?? AbortSignal.timeout(30000),
469
+ redirect: 'error',
470
+ })
471
+ const providerName = getIntegrationDefinition(provider)!.name
472
+ if (!response.ok) {
473
+ throw new Error(
474
+ `${providerName} returned ${response.status}. Check the account, token permissions, and action parameters.`,
475
+ )
476
+ }
477
+ const result = await readJson(response)
478
+ assertProviderAccepted(provider, toRecord(result))
479
+ return result
480
+ }
481
+
482
+ function getBaseUrl(provider: string, credentials: Credentials, path: string) {
483
+ if (provider === 'shopify') {
484
+ const shop = credentials.shop?.replace(/^https:\/\//, '').replace(/\/$/, '')
485
+ if (!shop || !/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(shop)) {
486
+ throw new Error('Enter your store.myshopify.com domain.')
487
+ }
488
+ return `https://${shop}/admin/api/2026-07/graphql.json`
489
+ }
490
+ const base = bases[provider]
491
+ if (!base) {
492
+ throw new Error('This integration has no adapter.')
493
+ }
494
+ // Some Google products split their API across hosts by version.
495
+ if (provider === 'ga' && (path.includes('/audiences') || path === '/v1beta/accountSummaries')) {
496
+ return 'https://analyticsadmin.googleapis.com'
497
+ }
498
+ if (provider === 'gsc' && path.startsWith('/v1/')) {
499
+ return 'https://searchconsole.googleapis.com'
500
+ }
501
+ if (provider === 'gsc' && path.startsWith('/v3/')) {
502
+ return 'https://indexing.googleapis.com'
503
+ }
504
+ return base
505
+ }
506
+
507
+ function buildRequestUrl(
508
+ provider: string,
509
+ credentials: Credentials,
510
+ endpoint: Endpoint,
511
+ input: Input,
512
+ ) {
513
+ const path = endpoint.path.replace(/\{(\w+)\}/g, (_match, name: string) => {
514
+ const value =
515
+ input[name] ?? credentials[name] ?? (name === 'calendarId' ? 'primary' : undefined)
516
+ if (typeof value !== 'string' && typeof value !== 'number') {
517
+ throw new Error(`Provide ${name} for this action.`)
518
+ }
519
+ return encodeURIComponent(String(value))
520
+ })
521
+ const url = new URL(getBaseUrl(provider, credentials, endpoint.path) + path)
522
+ for (const [key, value] of Object.entries(toRecord(input.query))) {
523
+ if (value != null) {
524
+ url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value))
525
+ }
526
+ }
527
+ const isTiktokRead = provider === 'tiktok' && !endpoint.method
528
+ if (isTiktokRead && credentials.advertiserId && !url.searchParams.has('advertiser_id')) {
529
+ url.searchParams.set('advertiser_id', credentials.advertiserId)
530
+ }
531
+ return url
532
+ }
533
+
534
+ function buildRequestHeaders(provider: string, credentials: Credentials) {
535
+ const headers: Record<string, string> = {
536
+ Accept: 'application/json',
537
+ Authorization: `Bearer ${credentials.token}`,
538
+ 'User-Agent': 'nuxt-agentic-integrations',
539
+ }
540
+ switch (provider) {
541
+ case 'github':
542
+ headers['X-GitHub-Api-Version'] = '2022-11-28'
543
+ break
544
+ case 'gads':
545
+ headers['developer-token'] = credentials.developerToken ?? ''
546
+ if (credentials.loginCustomerId) {
547
+ headers['login-customer-id'] = credentials.loginCustomerId
548
+ }
549
+ break
550
+ case 'tiktok':
551
+ headers['Access-Token'] = credentials.token ?? ''
552
+ delete headers.Authorization
553
+ break
554
+ case 'shopify':
555
+ headers['X-Shopify-Access-Token'] = credentials.token ?? ''
556
+ delete headers.Authorization
557
+ break
558
+ case 'discord':
559
+ headers.Authorization = `Bot ${credentials.token}`
560
+ break
561
+ case 'intercom':
562
+ headers['Intercom-Version'] = '2.14'
563
+ break
564
+ case 'notion':
565
+ headers['Notion-Version'] = '2022-06-28'
566
+ break
567
+ }
568
+ return headers
569
+ }
570
+
571
+ function buildRequestBody(
572
+ provider: string,
573
+ credentials: Credentials,
574
+ endpoint: Endpoint,
575
+ input: Input,
576
+ ): Input {
577
+ const body = toRecord(input.body)
578
+ const method = endpoint.graphql ? 'POST' : (endpoint.method ?? 'GET')
579
+ if (provider === 'gmail' && method === 'POST') {
580
+ const message = {
581
+ raw: encodeRawEmail(body),
582
+ ...(body.threadId ? { threadId: body.threadId } : {}),
583
+ }
584
+ return endpoint.path.endsWith('/drafts') ? { message } : message
585
+ }
586
+ if (provider === 'intercom' && endpoint.path.endsWith('/reply')) {
587
+ return { ...body, message_type: 'comment', type: 'admin' }
588
+ }
589
+ if (provider === 'tiktok' && credentials.advertiserId) {
590
+ return { ...body, advertiser_id: credentials.advertiserId }
591
+ }
592
+ return body
593
+ }
594
+
595
+ /** Some providers report failures inside a 200 response. */
596
+ function assertProviderAccepted(provider: string, value: Input) {
597
+ const providerName = getIntegrationDefinition(provider)!.name
598
+ const hasTiktokError = provider === 'tiktok' && value.code !== 0 && value.code !== undefined
599
+ if (value.ok === false || hasTiktokError || Array.isArray(value.errors)) {
600
+ throw new Error(
601
+ `${providerName} could not complete this action. Check its permissions and parameters.`,
602
+ )
603
+ }
604
+ // GraphQL mutations return user errors next to a successful status.
605
+ for (const payload of Object.values(toRecord(value.data))) {
606
+ const userErrors = toRecord(payload).userErrors
607
+ if (Array.isArray(userErrors) && userErrors.length) {
608
+ throw new Error(`${providerName} rejected these action parameters.`)
609
+ }
610
+ }
611
+ }
612
+
613
+ function toRecord(value: unknown): Input {
614
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Input) : {}
615
+ }
616
+
617
+ function pick(value: Input, keys: string[]) {
618
+ return Object.fromEntries(
619
+ keys.filter((key) => value[key] !== undefined).map((key) => [key, value[key]]),
620
+ )
621
+ }
622
+
623
+ /** Narrows agent input to the fields each write action may change. */
624
+ function getActionBody(provider: string, action: string, body: Input): Input {
625
+ if (provider === 'meta' && action === 'pause_ads') {
626
+ return { status: 'PAUSED' }
627
+ }
628
+ if (provider === 'meta' && action === 'adjust_budgets') {
629
+ return pick(body, ['daily_budget', 'lifetime_budget'])
630
+ }
631
+ if (provider === 'x' && action === 'adjust_bids') {
632
+ return pick(body, ['bid_amount_local_micro', 'bid_type'])
633
+ }
634
+ if (provider === 'tiktok' && action === 'pause_ad_groups') {
635
+ return { adgroup_ids: body.adgroup_ids, operation_status: 'DISABLE' }
636
+ }
637
+ if (provider === 'tiktok' && action === 'adjust_budgets') {
638
+ return pick(body, ['campaign_id', 'budget'])
639
+ }
640
+ if (provider === 'hubspot' && action === 'update_deal_stages') {
641
+ return { properties: { dealstage: toRecord(body.properties).dealstage } }
642
+ }
643
+ if (provider === 'linear' && action === 'update_status') {
644
+ return { id: body.id, input: { stateId: toRecord(body.input).stateId } }
645
+ }
646
+ if (provider === 'gdrive' && action === 'move_files') {
647
+ return {}
648
+ }
649
+ if (provider === 'gads' && action === 'pause_keywords') {
650
+ return {
651
+ operations: toArray(body.operations).map((operation) => ({
652
+ update: {
653
+ resourceName: toRecord(toRecord(operation).update).resourceName,
654
+ status: 'PAUSED',
655
+ },
656
+ updateMask: 'status',
657
+ })),
658
+ }
659
+ }
660
+ if (provider === 'gads' && action === 'adjust_bids_and_budgets') {
661
+ return {
662
+ operations: toArray(body.operations).map((operation) => {
663
+ const update = pick(toRecord(toRecord(operation).update), [
664
+ 'resourceName',
665
+ 'amountMicros',
666
+ 'cpcBidMicros',
667
+ ])
668
+ return {
669
+ update,
670
+ updateMask: update.cpcBidMicros !== undefined ? 'cpc_bid_micros' : 'amount_micros',
671
+ }
672
+ }),
673
+ }
674
+ }
675
+ if (provider === 'reddit' && action === 'adjust_budgets') {
676
+ return { data: pick(toRecord(body.data), ['goal_value', 'spend_cap']) }
677
+ }
678
+ return body
679
+ }
680
+
681
+ function toArray(value: unknown): unknown[] {
682
+ return Array.isArray(value) ? value : []
683
+ }
684
+
685
+ /** Encodes nested objects with bracket keys (`a[b]=c`), as Stripe and X expect. */
686
+ function encodeFormData(value: Input, prefix = '', form = new URLSearchParams()) {
687
+ for (const [key, item] of Object.entries(value)) {
688
+ const name = prefix ? `${prefix}[${key}]` : key
689
+ if (item !== null && typeof item === 'object') {
690
+ encodeFormData(item as Input, name, form)
691
+ } else if (item !== undefined) {
692
+ form.set(name, String(item))
693
+ }
694
+ }
695
+ return form
696
+ }
697
+
698
+ /** Builds the base64url RFC 2822 message Gmail's send and draft endpoints require. */
699
+ function encodeRawEmail(body: Input) {
700
+ const to = String(body.to ?? '')
701
+ const subject = String(body.subject ?? '')
702
+ // Line breaks would let input inject extra headers.
703
+ if (!to || /[\r\n]/.test(to + subject)) {
704
+ throw new Error('Provide a recipient and a subject without line breaks.')
705
+ }
706
+ const encodedSubject = encodeBase64(new TextEncoder().encode(subject))
707
+ const message = [
708
+ `To: ${to}`,
709
+ `Subject: =?UTF-8?B?${encodedSubject}?=`,
710
+ 'MIME-Version: 1.0',
711
+ 'Content-Type: text/plain; charset=UTF-8',
712
+ '',
713
+ String(body.text ?? ''),
714
+ ].join('\r\n')
715
+ return encodeBase64(new TextEncoder().encode(message))
716
+ .replace(/\+/g, '-')
717
+ .replace(/\//g, '_')
718
+ .replace(/=+$/, '')
719
+ }
720
+
721
+ function encodeBase64(bytes: Uint8Array) {
722
+ return btoa(String.fromCharCode(...bytes))
723
+ }
724
+
725
+ const maxResponseBytes = 2_000_000
726
+
727
+ /**
728
+ * Reads a JSON or NDJSON response, refusing bodies large enough to flood an agent's context.
729
+ */
730
+ export async function readJson(response: Response) {
731
+ if (!response.body) {
732
+ return null
733
+ }
734
+ const text = new TextDecoder().decode(await readLimitedBody(response.body))
735
+ if (!text) {
736
+ return null
737
+ }
738
+ if (response.headers.get('content-type')?.includes('ndjson')) {
739
+ return text
740
+ .trim()
741
+ .split('\n')
742
+ .filter(Boolean)
743
+ .map((line) => JSON.parse(line) as unknown)
744
+ }
745
+ return JSON.parse(text) as unknown
746
+ }
747
+
748
+ async function readLimitedBody(stream: ReadableStream<Uint8Array>) {
749
+ const reader = stream.getReader()
750
+ const chunks: Uint8Array[] = []
751
+ let length = 0
752
+ while (true) {
753
+ const { value, done } = await reader.read()
754
+ if (done) {
755
+ break
756
+ }
757
+ length += value.length
758
+ if (length > maxResponseBytes) {
759
+ await reader.cancel()
760
+ throw new Error('The result is too large. Use a smaller date range or page size.')
761
+ }
762
+ chunks.push(value)
763
+ }
764
+ const bytes = new Uint8Array(length)
765
+ let offset = 0
766
+ for (const chunk of chunks) {
767
+ bytes.set(chunk, offset)
768
+ offset += chunk.length
769
+ }
770
+ return bytes
771
+ }
772
+
773
+ /** RFC 3986 encoding, which OAuth 1.0a requires instead of `encodeURIComponent`'s. */
774
+ function encodeOAuthValue(value: string) {
775
+ return encodeURIComponent(value).replace(
776
+ /[!'()*]/g,
777
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
778
+ )
779
+ }
780
+
781
+ function compareOAuthPairs([leftKey, leftValue]: string[], [rightKey, rightValue]: string[]) {
782
+ if (leftKey !== rightKey) {
783
+ return leftKey! < rightKey! ? -1 : 1
784
+ }
785
+ if (leftValue === rightValue) {
786
+ return 0
787
+ }
788
+ return leftValue! < rightValue! ? -1 : 1
789
+ }
790
+
791
+ /** Signs a request with OAuth 1.0a HMAC-SHA1, which the X Ads API still requires. */
792
+ async function signOAuth1Request(
793
+ method: string,
794
+ url: URL,
795
+ credentials: Credentials,
796
+ form?: URLSearchParams,
797
+ ) {
798
+ const parameters: Record<string, string> = {
799
+ oauth_consumer_key: credentials.consumerKey ?? '',
800
+ oauth_token: credentials.token ?? '',
801
+ oauth_nonce: crypto.randomUUID(),
802
+ oauth_timestamp: String(Math.floor(Date.now() / 1000)),
803
+ oauth_signature_method: 'HMAC-SHA1',
804
+ oauth_version: '1.0',
805
+ }
806
+ const normalizedParameters = [
807
+ ...Object.entries(parameters),
808
+ ...url.searchParams.entries(),
809
+ ...(form?.entries() ?? []),
810
+ ]
811
+ .map(([key, value]) => [encodeOAuthValue(key), encodeOAuthValue(value)])
812
+ .sort(compareOAuthPairs)
813
+ .map(([key, value]) => `${key}=${value}`)
814
+ .join('&')
815
+ const signatureBase = [
816
+ method,
817
+ encodeOAuthValue(url.origin + url.pathname),
818
+ encodeOAuthValue(normalizedParameters),
819
+ ].join('&')
820
+ const signingKey = [credentials.consumerSecret ?? '', credentials.tokenSecret ?? '']
821
+ .map(encodeOAuthValue)
822
+ .join('&')
823
+ const key = await crypto.subtle.importKey(
824
+ 'raw',
825
+ new TextEncoder().encode(signingKey),
826
+ { name: 'HMAC', hash: 'SHA-1' },
827
+ false,
828
+ ['sign'],
829
+ )
830
+ const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signatureBase))
831
+ parameters.oauth_signature = encodeBase64(new Uint8Array(signature))
832
+ const header = Object.entries(parameters)
833
+ .map(([key, value]) => `${encodeOAuthValue(key)}="${encodeOAuthValue(value)}"`)
834
+ .join(', ')
835
+ return `OAuth ${header}`
836
+ }