From 2ab28ba091a3bc09ba361c3f2b4ee6958bea8d44 Mon Sep 17 00:00:00 2001 From: Selinali01 Date: Sun, 1 Mar 2026 11:05:31 -0800 Subject: [PATCH] added assembled --- .../src/pages/CredentialsPage.tsx | 1 + packages/bubble-core/package.json | 2 +- packages/bubble-core/src/bubble-factory.ts | 6 + .../assembled/assembled.integration.flow.ts | 171 +++++++ .../assembled/assembled.schema.ts | 339 +++++++++++++ .../service-bubble/assembled/assembled.ts | 475 ++++++++++++++++++ .../assembled/assembled.utils.ts | 110 ++++ .../bubbles/service-bubble/assembled/index.ts | 9 + packages/bubble-core/src/index.ts | 8 + packages/bubble-runtime/package.json | 2 +- packages/bubble-scope-manager/package.json | 2 +- packages/bubble-shared-schemas/package.json | 2 +- .../src/bubble-definition-schema.ts | 1 + .../src/capability-schema.ts | 3 +- .../src/credential-schema.ts | 10 + packages/bubble-shared-schemas/src/types.ts | 6 +- packages/create-bubblelab-app/package.json | 2 +- .../templates/basic/package.json | 6 +- .../templates/reddit-scraper/package.json | 4 +- 19 files changed, 1147 insertions(+), 12 deletions(-) create mode 100644 packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.integration.flow.ts create mode 100644 packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.schema.ts create mode 100644 packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.ts create mode 100644 packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.utils.ts create mode 100644 packages/bubble-core/src/bubbles/service-bubble/assembled/index.ts diff --git a/apps/bubble-studio/src/pages/CredentialsPage.tsx b/apps/bubble-studio/src/pages/CredentialsPage.tsx index 647c98fa..a6a7dcdb 100644 --- a/apps/bubble-studio/src/pages/CredentialsPage.tsx +++ b/apps/bubble-studio/src/pages/CredentialsPage.tsx @@ -104,6 +104,7 @@ const getServiceNameForCredentialType = ( [CredentialType.ATTIO_CRED]: 'Attio', [CredentialType.HUBSPOT_CRED]: 'HubSpot', [CredentialType.SORTLY_API_KEY]: 'Sortly', + [CredentialType.ASSEMBLED_CRED]: 'Assembled', }; return typeToServiceMap[credentialType] || credentialType; diff --git a/packages/bubble-core/package.json b/packages/bubble-core/package.json index 1d4655ba..e856efa4 100644 --- a/packages/bubble-core/package.json +++ b/packages/bubble-core/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/bubble-core", - "version": "0.1.210", + "version": "0.1.211", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/bubble-core/src/bubble-factory.ts b/packages/bubble-core/src/bubble-factory.ts index 426ed0d9..d735ec1f 100644 --- a/packages/bubble-core/src/bubble-factory.ts +++ b/packages/bubble-core/src/bubble-factory.ts @@ -177,6 +177,7 @@ export class BubbleFactory { 'attio', 'hubspot', 's3-storage', + 'assembled', ]; } @@ -406,6 +407,9 @@ export class BubbleFactory { './bubbles/service-bubble/hubspot/index.js' ); const { S3Bubble } = await import('./bubbles/service-bubble/s3/index.js'); + const { AssembledBubble } = await import( + './bubbles/service-bubble/assembled/index.js' + ); // Create the default factory instance this.register('hello-world', HelloWorldBubble as BubbleClassWithMetadata); @@ -565,6 +569,7 @@ export class BubbleFactory { this.register('attio', AttioBubble as BubbleClassWithMetadata); this.register('hubspot', HubSpotBubble as BubbleClassWithMetadata); this.register('s3-storage', S3Bubble as BubbleClassWithMetadata); + this.register('assembled', AssembledBubble as BubbleClassWithMetadata); // After all default bubbles are registered, auto-populate bubbleDependencies if (!BubbleFactory.dependenciesPopulated) { @@ -869,6 +874,7 @@ import { AttioBubble, // bubble name: 'attio' HubSpotBubble, // bubble name: 'hubspot' S3Bubble, // bubble name: 's3-storage' + AssembledBubble, // bubble name: 'assembled' // Tool Bubbles (Perform useful actions) ResearchAgentTool, // bubble name: 'research-agent-tool' diff --git a/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.integration.flow.ts b/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.integration.flow.ts new file mode 100644 index 00000000..8936af58 --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.integration.flow.ts @@ -0,0 +1,171 @@ +import { BubbleFlow } from '../../../bubble-flow/bubble-flow-class.js'; +import type { WebhookEvent } from '@bubblelab/shared-schemas'; +import { AssembledBubble } from './assembled.js'; + +export interface Output { + testResults: { + operation: string; + success: boolean; + details?: string; + }[]; + summary: string; +} + +export interface TestPayload extends WebhookEvent { + testName?: string; +} + +/** + * Integration flow that exercises all Assembled bubble operations end-to-end. + * + * Tests: + * 1. list_queues - List all queues + * 2. list_teams - List all teams + * 3. list_people - List people with pagination + * 4. create_person - Create a test person + * 5. get_person - Get the created person + * 6. update_person - Update the created person + * 7. list_activities - List activities in a time window + * 8. list_time_off - List time off requests + */ +export class AssembledIntegrationFlow extends BubbleFlow<'webhook/http'> { + async handle(_payload: TestPayload): Promise { + const results: Output['testResults'] = []; + + // 1. List queues + const queuesResult = await new AssembledBubble({ + operation: 'list_queues', + }).action(); + results.push({ + operation: 'list_queues', + success: queuesResult.success, + details: queuesResult.success + ? `Found ${queuesResult.data?.queues?.length ?? 0} queues` + : queuesResult.error, + }); + + // 2. List teams + const teamsResult = await new AssembledBubble({ + operation: 'list_teams', + }).action(); + results.push({ + operation: 'list_teams', + success: teamsResult.success, + details: teamsResult.success + ? `Found ${teamsResult.data?.teams?.length ?? 0} teams` + : teamsResult.error, + }); + + // 3. List people + const listPeopleResult = await new AssembledBubble({ + operation: 'list_people', + limit: 5, + offset: 0, + }).action(); + results.push({ + operation: 'list_people', + success: listPeopleResult.success, + details: listPeopleResult.success + ? `Found ${listPeopleResult.data?.people?.length ?? 0} people` + : listPeopleResult.error, + }); + + // 4. Create a test person (with edge-case unicode name) + const testEmail = `bubble-test-${Date.now()}@example.com`; + const createResult = await new AssembledBubble({ + operation: 'create_person', + first_name: 'BubbleLab Tëst', + last_name: "O'Connor-López", + email: testEmail, + channels: ['email', 'chat'], + staffable: true, + }).action(); + results.push({ + operation: 'create_person', + success: createResult.success, + details: createResult.success + ? `Created person: ${JSON.stringify(createResult.data?.person)}` + : createResult.error, + }); + + // 5. Get the created person (if create succeeded) + const personId = (createResult.data?.person as Record) + ?.id as string | undefined; + if (personId) { + const getResult = await new AssembledBubble({ + operation: 'get_person', + person_id: personId, + }).action(); + results.push({ + operation: 'get_person', + success: getResult.success, + details: getResult.success + ? `Retrieved person ID: ${personId}` + : getResult.error, + }); + + // 6. Update the person + const updateResult = await new AssembledBubble({ + operation: 'update_person', + person_id: personId, + first_name: 'Updated Tëst', + channels: ['email', 'chat', 'phone'], + }).action(); + results.push({ + operation: 'update_person', + success: updateResult.success, + details: updateResult.success + ? `Updated person ID: ${personId}` + : updateResult.error, + }); + } else { + results.push({ + operation: 'get_person', + success: false, + details: 'Skipped: no person ID from create', + }); + results.push({ + operation: 'update_person', + success: false, + details: 'Skipped: no person ID from create', + }); + } + + // 7. List activities (last 24 hours) + const now = Math.floor(Date.now() / 1000); + const listActivitiesResult = await new AssembledBubble({ + operation: 'list_activities', + start_time: now - 86400, + end_time: now, + include_agents: true, + }).action(); + results.push({ + operation: 'list_activities', + success: listActivitiesResult.success, + details: listActivitiesResult.success + ? `Found ${Object.keys(listActivitiesResult.data?.activities || {}).length} activities` + : listActivitiesResult.error, + }); + + // 8. List time off requests + const listTimeOffResult = await new AssembledBubble({ + operation: 'list_time_off', + limit: 5, + }).action(); + results.push({ + operation: 'list_time_off', + success: listTimeOffResult.success, + details: listTimeOffResult.success + ? `Found ${listTimeOffResult.data?.requests?.length ?? 0} time off requests` + : listTimeOffResult.error, + }); + + const passed = results.filter((r) => r.success).length; + const total = results.length; + + return { + testResults: results, + summary: `${passed}/${total} operations passed`, + }; + } +} diff --git a/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.schema.ts b/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.schema.ts new file mode 100644 index 00000000..f0865e94 --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.schema.ts @@ -0,0 +1,339 @@ +import { z } from 'zod'; + +// ─── Credentials ──────────────────────────────────────────────────────────── + +const credentialsField = z + .record(z.string()) + .optional() + .describe('Credentials for authentication'); + +// ─── Shared field helpers ─────────────────────────────────────────────────── + +const paginationFields = { + limit: z + .number() + .optional() + .default(20) + .describe('Maximum number of results to return (default 20, max 500)'), + offset: z + .number() + .optional() + .default(0) + .describe('Number of results to skip for pagination'), +}; + +// ─── People schemas ───────────────────────────────────────────────────────── + +const listPeopleSchema = z.object({ + operation: z.literal('list_people').describe('List people/agents'), + credentials: credentialsField, + ...paginationFields, + channel: z + .string() + .optional() + .describe( + 'Filter by channel (phone, email, chat, sms, social, back_office)' + ), + team: z.string().optional().describe('Filter by team name'), + site: z.string().optional().describe('Filter by site name'), + queue: z.string().optional().describe('Filter by queue name'), + search: z + .string() + .optional() + .describe('Search by name, email, or imported_id'), +}); + +const getPersonSchema = z.object({ + operation: z.literal('get_person').describe('Get a single person by ID'), + credentials: credentialsField, + person_id: z.string().describe('The Assembled person ID'), +}); + +const createPersonSchema = z.object({ + operation: z.literal('create_person').describe('Create a new person/agent'), + credentials: credentialsField, + first_name: z.string().describe('First name of the person'), + last_name: z.string().describe('Last name of the person'), + email: z.string().optional().describe('Email address of the person'), + imported_id: z + .string() + .optional() + .describe('External/imported ID for the person'), + channels: z + .array(z.string()) + .optional() + .describe( + 'Channels the person works on (phone, email, chat, sms, social, back_office)' + ), + teams: z + .array(z.string()) + .optional() + .describe('Team names to assign the person to'), + queues: z + .array(z.string()) + .optional() + .describe('Queue names to assign the person to'), + site: z.string().optional().describe('Site name for the person'), + timezone: z + .string() + .optional() + .describe('Timezone for the person (e.g. America/Los_Angeles)'), + roles: z + .array(z.string()) + .optional() + .describe('Roles to assign (e.g. agent, admin)'), + staffable: z + .boolean() + .optional() + .default(true) + .describe('Whether the person can be scheduled'), +}); + +const updatePersonSchema = z.object({ + operation: z.literal('update_person').describe('Update an existing person'), + credentials: credentialsField, + person_id: z.string().describe('The Assembled person ID to update'), + first_name: z.string().optional().describe('Updated first name'), + last_name: z.string().optional().describe('Updated last name'), + email: z.string().optional().describe('Updated email address'), + channels: z.array(z.string()).optional().describe('Updated channels list'), + teams: z.array(z.string()).optional().describe('Updated team names'), + queues: z.array(z.string()).optional().describe('Updated queue names'), + site: z.string().optional().describe('Updated site name'), + timezone: z.string().optional().describe('Updated timezone'), + staffable: z.boolean().optional().describe('Updated staffable status'), +}); + +// ─── Activities schemas ───────────────────────────────────────────────────── + +const listActivitiesSchema = z.object({ + operation: z + .literal('list_activities') + .describe('List activities/schedule events in a time window'), + credentials: credentialsField, + start_time: z + .number() + .describe('Start of time window as Unix timestamp (seconds)'), + end_time: z + .number() + .describe('End of time window as Unix timestamp (seconds)'), + agent_ids: z + .array(z.string()) + .optional() + .describe('Filter by specific agent IDs'), + queue: z.string().optional().describe('Filter by queue name'), + include_agents: z + .boolean() + .optional() + .default(false) + .describe('Include agent details in response'), +}); + +const createActivitySchema = z.object({ + operation: z + .literal('create_activity') + .describe('Create a new activity/schedule event'), + credentials: credentialsField, + agent_id: z.string().describe('Agent ID to assign the activity to'), + type_id: z.string().describe('Activity type ID'), + start_time: z.number().describe('Activity start as Unix timestamp (seconds)'), + end_time: z.number().describe('Activity end as Unix timestamp (seconds)'), + channels: z + .array(z.string()) + .optional() + .describe('Channels for this activity (phone, email, chat, etc.)'), + description: z.string().optional().describe('Description of the activity'), + allow_conflicts: z + .boolean() + .optional() + .default(false) + .describe('Whether to allow overlapping activities'), +}); + +const deleteActivitiesSchema = z.object({ + operation: z + .literal('delete_activities') + .describe('Delete activities for specified agents within a time window'), + credentials: credentialsField, + agent_ids: z + .array(z.string()) + .describe('Agent IDs whose activities to delete'), + start_time: z + .number() + .describe('Start of deletion window as Unix timestamp (seconds)'), + end_time: z + .number() + .describe('End of deletion window as Unix timestamp (seconds)'), +}); + +// ─── Time Off schemas ─────────────────────────────────────────────────────── + +const createTimeOffSchema = z.object({ + operation: z.literal('create_time_off').describe('Create a time off request'), + credentials: credentialsField, + agent_id: z.string().describe('Agent ID requesting time off'), + start_time: z.number().describe('Time off start as Unix timestamp (seconds)'), + end_time: z.number().describe('Time off end as Unix timestamp (seconds)'), + type_id: z + .string() + .optional() + .describe('Activity type ID for the time off (must be a time-off type)'), + status: z + .enum(['approved', 'pending']) + .optional() + .default('pending') + .describe('Initial status of the time off request'), + notes: z.string().optional().describe('Notes for the time off request'), +}); + +const listTimeOffSchema = z.object({ + operation: z.literal('list_time_off').describe('List time off requests'), + credentials: credentialsField, + ...paginationFields, + agent_ids: z.array(z.string()).optional().describe('Filter by agent IDs'), + status: z + .enum(['approved', 'pending', 'denied', 'cancelled']) + .optional() + .describe('Filter by time off request status'), +}); + +const cancelTimeOffSchema = z.object({ + operation: z.literal('cancel_time_off').describe('Cancel a time off request'), + credentials: credentialsField, + time_off_id: z.string().describe('ID of the time off request to cancel'), +}); + +// ─── Filter schemas (queues, teams) ───────────────────────────────────────── + +const listQueuesSchema = z.object({ + operation: z.literal('list_queues').describe('List all queues'), + credentials: credentialsField, +}); + +const listTeamsSchema = z.object({ + operation: z.literal('list_teams').describe('List all teams'), + credentials: credentialsField, +}); + +// ─── Combined Params Schema ───────────────────────────────────────────────── + +export const AssembledParamsSchema = z.discriminatedUnion('operation', [ + listPeopleSchema, + getPersonSchema, + createPersonSchema, + updatePersonSchema, + listActivitiesSchema, + createActivitySchema, + deleteActivitiesSchema, + createTimeOffSchema, + listTimeOffSchema, + cancelTimeOffSchema, + listQueuesSchema, + listTeamsSchema, +]); + +export type AssembledParams = z.output; +export type AssembledParamsInput = z.input; + +// ─── Result Schemas ───────────────────────────────────────────────────────── + +const personResultSchema = z.object({ + operation: z.literal('list_people'), + success: z.boolean(), + error: z.string(), + people: z.array(z.record(z.unknown())).optional(), + total: z.number().optional(), +}); + +const getPersonResultSchema = z.object({ + operation: z.literal('get_person'), + success: z.boolean(), + error: z.string(), + person: z.record(z.unknown()).optional(), +}); + +const createPersonResultSchema = z.object({ + operation: z.literal('create_person'), + success: z.boolean(), + error: z.string(), + person: z.record(z.unknown()).optional(), +}); + +const updatePersonResultSchema = z.object({ + operation: z.literal('update_person'), + success: z.boolean(), + error: z.string(), + person: z.record(z.unknown()).optional(), +}); + +const listActivitiesResultSchema = z.object({ + operation: z.literal('list_activities'), + success: z.boolean(), + error: z.string(), + activities: z.record(z.record(z.unknown())).optional(), + agents: z.record(z.record(z.unknown())).optional(), +}); + +const createActivityResultSchema = z.object({ + operation: z.literal('create_activity'), + success: z.boolean(), + error: z.string(), + activity: z.record(z.unknown()).optional(), +}); + +const deleteActivitiesResultSchema = z.object({ + operation: z.literal('delete_activities'), + success: z.boolean(), + error: z.string(), +}); + +const createTimeOffResultSchema = z.object({ + operation: z.literal('create_time_off'), + success: z.boolean(), + error: z.string(), + time_off: z.record(z.unknown()).optional(), +}); + +const listTimeOffResultSchema = z.object({ + operation: z.literal('list_time_off'), + success: z.boolean(), + error: z.string(), + requests: z.array(z.record(z.unknown())).optional(), +}); + +const cancelTimeOffResultSchema = z.object({ + operation: z.literal('cancel_time_off'), + success: z.boolean(), + error: z.string(), +}); + +const listQueuesResultSchema = z.object({ + operation: z.literal('list_queues'), + success: z.boolean(), + error: z.string(), + queues: z.array(z.record(z.unknown())).optional(), +}); + +const listTeamsResultSchema = z.object({ + operation: z.literal('list_teams'), + success: z.boolean(), + error: z.string(), + teams: z.array(z.record(z.unknown())).optional(), +}); + +export const AssembledResultSchema = z.discriminatedUnion('operation', [ + personResultSchema, + getPersonResultSchema, + createPersonResultSchema, + updatePersonResultSchema, + listActivitiesResultSchema, + createActivityResultSchema, + deleteActivitiesResultSchema, + createTimeOffResultSchema, + listTimeOffResultSchema, + cancelTimeOffResultSchema, + listQueuesResultSchema, + listTeamsResultSchema, +]); + +export type AssembledResult = z.infer; diff --git a/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.ts b/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.ts new file mode 100644 index 00000000..1245d2df --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.ts @@ -0,0 +1,475 @@ +import { CredentialType } from '@bubblelab/shared-schemas'; +import { ServiceBubble } from '../../../types/service-bubble-class.js'; +import type { BubbleContext } from '../../../types/bubble.js'; +import { + AssembledParamsSchema, + AssembledResultSchema, + type AssembledParams, + type AssembledParamsInput, + type AssembledResult, +} from './assembled.schema.js'; +import { makeAssembledRequest } from './assembled.utils.js'; + +export class AssembledBubble< + T extends AssembledParamsInput = AssembledParamsInput, +> extends ServiceBubble< + T, + Extract +> { + static readonly type = 'service' as const; + static readonly service = 'assembled'; + static readonly authType = 'apikey' as const; + static readonly bubbleName = 'assembled' as const; + static readonly schema = AssembledParamsSchema; + static readonly resultSchema = AssembledResultSchema; + static readonly shortDescription = + 'Workforce management platform for scheduling, time off, and agent management'; + static readonly longDescription = + 'Assembled is a workforce management platform. This integration supports managing people/agents, activities/schedules, time off requests, queues, and teams via the Assembled REST API.'; + static readonly alias = 'assembled'; + + constructor(params?: T, context?: BubbleContext) { + super( + { + ...params, + } as T, + context + ); + } + + protected chooseCredential(): string | undefined { + const { credentials } = this.params as { + credentials?: Record; + }; + return credentials?.[CredentialType.ASSEMBLED_CRED]; + } + + private getApiKey(): string { + const apiKey = this.chooseCredential(); + if (!apiKey) { + throw new Error( + 'Assembled API key not found. Please provide an ASSEMBLED_CRED credential.' + ); + } + return apiKey; + } + + public async testCredential(): Promise { + try { + const apiKey = this.getApiKey(); + // Use list_queues as a lightweight credential test + await makeAssembledRequest({ + method: 'GET', + path: '/queues', + apiKey, + }); + return true; + } catch { + return false; + } + } + + protected async performAction( + _context?: BubbleContext + ): Promise> { + const params = this.params as AssembledParams; + const { operation } = params; + + try { + switch (operation) { + case 'list_people': + return (await this.listPeople(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'get_person': + return (await this.getPerson(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'create_person': + return (await this.createPerson(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'update_person': + return (await this.updatePerson(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'list_activities': + return (await this.listActivities(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'create_activity': + return (await this.createActivity(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'delete_activities': + return (await this.deleteActivities(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'create_time_off': + return (await this.createTimeOff(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'list_time_off': + return (await this.listTimeOff(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'cancel_time_off': + return (await this.cancelTimeOff(params)) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'list_queues': + return (await this.listQueues()) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + case 'list_teams': + return (await this.listTeams()) as Extract< + AssembledResult, + { operation: T['operation'] } + >; + default: + throw new Error(`Unsupported operation: ${operation}`); + } + } catch (error) { + return { + operation, + success: false, + error: + error instanceof Error ? error.message : 'An unknown error occurred', + } as Extract; + } + } + + // ─── People operations ────────────────────────────────────────────────── + + private async listPeople( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + const data = await makeAssembledRequest<{ + people: Record[]; + total?: number; + }>({ + method: 'GET', + path: '/people', + apiKey, + queryParams: { + limit: params.limit, + offset: params.offset, + channel: params.channel, + team: params.team, + site: params.site, + queue: params.queue, + search: params.search, + }, + }); + + return { + operation: 'list_people', + success: true, + error: '', + people: data.people || [], + total: data.total, + }; + } + + private async getPerson( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + const data = await makeAssembledRequest>({ + method: 'GET', + path: `/people/${params.person_id}`, + apiKey, + }); + + return { + operation: 'get_person', + success: true, + error: '', + person: data, + }; + } + + private async createPerson( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + const body: Record = { + first_name: params.first_name, + last_name: params.last_name, + }; + if (params.email) body.email = params.email; + if (params.imported_id) body.imported_id = params.imported_id; + if (params.channels) body.channels = params.channels; + if (params.teams) body.teams = params.teams; + if (params.queues) body.queues = params.queues; + if (params.site) body.site = params.site; + if (params.timezone) body.timezone = params.timezone; + if (params.roles) body.roles = params.roles; + if (params.staffable !== undefined) body.staffable = params.staffable; + + const data = await makeAssembledRequest>({ + method: 'POST', + path: '/people', + apiKey, + body, + }); + + return { + operation: 'create_person', + success: true, + error: '', + person: data, + }; + } + + private async updatePerson( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + const body: Record = {}; + if (params.first_name) body.first_name = params.first_name; + if (params.last_name) body.last_name = params.last_name; + if (params.email) body.email = params.email; + if (params.channels) body.channels = params.channels; + if (params.teams) body.teams = params.teams; + if (params.queues) body.queues = params.queues; + if (params.site) body.site = params.site; + if (params.timezone) body.timezone = params.timezone; + if (params.staffable !== undefined) body.staffable = params.staffable; + + const data = await makeAssembledRequest>({ + method: 'PATCH', + path: `/people/${params.person_id}`, + apiKey, + body, + }); + + return { + operation: 'update_person', + success: true, + error: '', + person: data, + }; + } + + // ─── Activities operations ────────────────────────────────────────────── + + private async listActivities( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + + const queryParams: Record = { + start_time: params.start_time, + end_time: params.end_time, + include_agents: params.include_agents, + }; + if (params.queue) queryParams.queue = params.queue; + if (params.agent_ids?.length) { + queryParams.agent_ids = params.agent_ids.join(','); + } + + const data = await makeAssembledRequest<{ + activities?: Record>; + agents?: Record>; + }>({ + method: 'GET', + path: '/activities', + apiKey, + queryParams, + }); + + return { + operation: 'list_activities', + success: true, + error: '', + activities: data.activities || {}, + agents: data.agents || {}, + }; + } + + private async createActivity( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + const body: Record = { + agent_id: params.agent_id, + type_id: params.type_id, + start_time: params.start_time, + end_time: params.end_time, + }; + if (params.channels) body.channels = params.channels; + if (params.description) body.description = params.description; + if (params.allow_conflicts) body.allow_conflicts = params.allow_conflicts; + + const data = await makeAssembledRequest>({ + method: 'POST', + path: '/activities', + apiKey, + body, + }); + + return { + operation: 'create_activity', + success: true, + error: '', + activity: data, + }; + } + + private async deleteActivities( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + + await makeAssembledRequest({ + method: 'DELETE', + path: '/activities', + apiKey, + body: { + agent_ids: params.agent_ids, + start_time: params.start_time, + end_time: params.end_time, + }, + }); + + return { + operation: 'delete_activities', + success: true, + error: '', + }; + } + + // ─── Time Off operations ──────────────────────────────────────────────── + + private async createTimeOff( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + const body: Record = { + agent_id: params.agent_id, + start_time: params.start_time, + end_time: params.end_time, + }; + if (params.type_id) body.type_id = params.type_id; + if (params.status) body.status = params.status; + if (params.notes) body.notes = params.notes; + + const data = await makeAssembledRequest>({ + method: 'POST', + path: '/time_off', + apiKey, + body, + }); + + return { + operation: 'create_time_off', + success: true, + error: '', + time_off: data, + }; + } + + private async listTimeOff( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + + const queryParams: Record = { + limit: params.limit, + offset: params.offset, + }; + if (params.status) queryParams.status = params.status; + if (params.agent_ids?.length) { + queryParams.agent_ids = params.agent_ids.join(','); + } + + const data = await makeAssembledRequest<{ + requests?: Record[]; + }>({ + method: 'GET', + path: '/time_off/requests', + apiKey, + queryParams, + }); + + return { + operation: 'list_time_off', + success: true, + error: '', + requests: data.requests || [], + }; + } + + private async cancelTimeOff( + params: Extract + ): Promise> { + const apiKey = this.getApiKey(); + + await makeAssembledRequest({ + method: 'POST', + path: `/time_off/${params.time_off_id}/cancel`, + apiKey, + }); + + return { + operation: 'cancel_time_off', + success: true, + error: '', + }; + } + + // ─── Filter operations (queues, teams) ────────────────────────────────── + + private async listQueues(): Promise< + Extract + > { + const apiKey = this.getApiKey(); + const data = await makeAssembledRequest<{ + queues?: Record[]; + }>({ + method: 'GET', + path: '/queues', + apiKey, + }); + + return { + operation: 'list_queues', + success: true, + error: '', + queues: data.queues || [], + }; + } + + private async listTeams(): Promise< + Extract + > { + const apiKey = this.getApiKey(); + const data = await makeAssembledRequest<{ + teams?: Record[]; + }>({ + method: 'GET', + path: '/teams', + apiKey, + }); + + return { + operation: 'list_teams', + success: true, + error: '', + teams: data.teams || [], + }; + } +} diff --git a/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.utils.ts b/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.utils.ts new file mode 100644 index 00000000..b4c5e20e --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/assembled/assembled.utils.ts @@ -0,0 +1,110 @@ +const BASE_URL = 'https://api.assembledhq.com/v0'; +const REQUEST_TIMEOUT_MS = 30_000; + +/** + * Builds Basic Auth header from an Assembled API key. + * Assembled uses the API key as the Basic Auth username with no password. + */ +function buildAuthHeader(apiKey: string): string { + return `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`; +} + +/** + * Enhance error messages with helpful hints based on HTTP status + */ +function enhanceErrorMessage(status: number, body: string): string { + let detail = body; + try { + const parsed = JSON.parse(body); + detail = parsed.error || parsed.message || parsed.detail || body; + } catch { + // keep raw body + } + + switch (status) { + case 401: + return `Authentication failed (HTTP 401): Invalid API key. Ensure your Assembled API key starts with "sk_live_". ${detail}`; + case 403: + return `Access denied (HTTP 403): Your API key does not have permission for this operation. ${detail}`; + case 404: + return `Not found (HTTP 404): The requested resource does not exist. ${detail}`; + case 429: + return `Rate limited (HTTP 429): Too many requests. Assembled allows 300 requests/min. ${detail}`; + default: + return `HTTP ${status}: ${detail}`; + } +} + +export interface AssembledRequestOptions { + method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + path: string; + apiKey: string; + body?: Record; + queryParams?: Record; +} + +/** + * Make an authenticated request to the Assembled API. + */ +export async function makeAssembledRequest( + options: AssembledRequestOptions +): Promise { + const { method, path, apiKey, body, queryParams } = options; + + // Build URL with query params + const url = new URL(`${BASE_URL}${path}`); + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, String(value)); + } + } + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const headers: Record = { + Authorization: buildAuthHeader(apiKey), + 'Content-Type': 'application/json', + }; + + const fetchOptions: RequestInit = { + method, + headers, + signal: controller.signal, + }; + + if (body && (method === 'POST' || method === 'PATCH')) { + fetchOptions.body = JSON.stringify(body); + } + + // For DELETE with body, we still need to send it + if (body && method === 'DELETE') { + fetchOptions.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), fetchOptions); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(enhanceErrorMessage(response.status, errorBody)); + } + + // Some DELETE operations may return empty body + const text = await response.text(); + if (!text) return {} as T; + + return JSON.parse(text) as T; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + `Request to Assembled API timed out after ${REQUEST_TIMEOUT_MS / 1000}s` + ); + } + throw error; + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/bubble-core/src/bubbles/service-bubble/assembled/index.ts b/packages/bubble-core/src/bubbles/service-bubble/assembled/index.ts new file mode 100644 index 00000000..e4750060 --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/assembled/index.ts @@ -0,0 +1,9 @@ +export { AssembledBubble } from './assembled.js'; +export { + AssembledParamsSchema, + AssembledResultSchema, + type AssembledParams, + type AssembledParamsInput, + type AssembledResult, +} from './assembled.schema.js'; +export { makeAssembledRequest } from './assembled.utils.js'; diff --git a/packages/bubble-core/src/index.ts b/packages/bubble-core/src/index.ts index e037c2ca..867cb734 100644 --- a/packages/bubble-core/src/index.ts +++ b/packages/bubble-core/src/index.ts @@ -129,6 +129,14 @@ export { export { CrustdataBubble } from './bubbles/service-bubble/crustdata/index.js'; export { PosthogBubble } from './bubbles/service-bubble/posthog/index.js'; export type { PosthogParamsInput } from './bubbles/service-bubble/posthog/index.js'; +export { + AssembledBubble, + AssembledParamsSchema, + AssembledResultSchema, + type AssembledParams, + type AssembledParamsInput, + type AssembledResult, +} from './bubbles/service-bubble/assembled/index.js'; export type { CrustdataParams, CrustdataParamsInput, diff --git a/packages/bubble-runtime/package.json b/packages/bubble-runtime/package.json index 51be4d2e..97defd74 100644 --- a/packages/bubble-runtime/package.json +++ b/packages/bubble-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/bubble-runtime", - "version": "0.1.210", + "version": "0.1.211", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/bubble-scope-manager/package.json b/packages/bubble-scope-manager/package.json index 9159d65b..f1c8efeb 100644 --- a/packages/bubble-scope-manager/package.json +++ b/packages/bubble-scope-manager/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/ts-scope-manager", - "version": "0.1.210", + "version": "0.1.211", "private": false, "license": "MIT", "type": "commonjs", diff --git a/packages/bubble-shared-schemas/package.json b/packages/bubble-shared-schemas/package.json index bec7ed6d..3be21d4d 100644 --- a/packages/bubble-shared-schemas/package.json +++ b/packages/bubble-shared-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/shared-schemas", - "version": "0.1.210", + "version": "0.1.211", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/bubble-shared-schemas/src/bubble-definition-schema.ts b/packages/bubble-shared-schemas/src/bubble-definition-schema.ts index c9afe62b..38a95dab 100644 --- a/packages/bubble-shared-schemas/src/bubble-definition-schema.ts +++ b/packages/bubble-shared-schemas/src/bubble-definition-schema.ts @@ -74,6 +74,7 @@ export const CREDENTIAL_CONFIGURATION_MAP: Record< [CredentialType.ATTIO_CRED]: {}, [CredentialType.HUBSPOT_CRED]: {}, [CredentialType.SORTLY_API_KEY]: {}, + [CredentialType.ASSEMBLED_CRED]: {}, [CredentialType.CREDENTIAL_WILDCARD]: {}, // Wildcard marker, not a real credential }; diff --git a/packages/bubble-shared-schemas/src/capability-schema.ts b/packages/bubble-shared-schemas/src/capability-schema.ts index 04f02dff..12841718 100644 --- a/packages/bubble-shared-schemas/src/capability-schema.ts +++ b/packages/bubble-shared-schemas/src/capability-schema.ts @@ -38,7 +38,8 @@ export type CapabilityId = | 'hubspot-assistant' | 'flow-assistant' | 'research-assistant' - | 'sortly-assistant'; + | 'sortly-assistant' + | 'assembled-assistant'; /** * Schema for a provider entry in a capability's metadata. diff --git a/packages/bubble-shared-schemas/src/credential-schema.ts b/packages/bubble-shared-schemas/src/credential-schema.ts index af65f60f..d36600fb 100644 --- a/packages/bubble-shared-schemas/src/credential-schema.ts +++ b/packages/bubble-shared-schemas/src/credential-schema.ts @@ -505,6 +505,14 @@ export const CREDENTIAL_TYPE_CONFIG: Record = namePlaceholder: 'My Attio Connection', credentialConfigurations: {}, }, + [CredentialType.ASSEMBLED_CRED]: { + label: 'Assembled', + description: + 'API key for Assembled workforce management (schedules, agents, time off)', + placeholder: 'sk_live_...', + namePlaceholder: 'My Assembled API Key', + credentialConfigurations: {}, + }, [CredentialType.CREDENTIAL_WILDCARD]: { label: 'Any Credential', description: @@ -577,6 +585,7 @@ export const CREDENTIAL_ENV_MAP: Record = { [CredentialType.HUBSPOT_CRED]: '', // OAuth credential, no env var [CredentialType.ATTIO_CRED]: '', // OAuth credential, no env var [CredentialType.SORTLY_API_KEY]: 'SORTLY_API_KEY', + [CredentialType.ASSEMBLED_CRED]: 'ASSEMBLED_API_KEY', [CredentialType.CREDENTIAL_WILDCARD]: '', // Wildcard marker, not a real credential }; @@ -1930,6 +1939,7 @@ export const BUBBLE_CREDENTIAL_OPTIONS: Record< linear: [CredentialType.LINEAR_CRED], attio: [CredentialType.ATTIO_CRED], hubspot: [CredentialType.HUBSPOT_CRED], + assembled: [CredentialType.ASSEMBLED_CRED], }; export interface CredentialSiblingEntry { diff --git a/packages/bubble-shared-schemas/src/types.ts b/packages/bubble-shared-schemas/src/types.ts index c6604759..845f3825 100644 --- a/packages/bubble-shared-schemas/src/types.ts +++ b/packages/bubble-shared-schemas/src/types.ts @@ -98,6 +98,9 @@ export enum CredentialType { // Sortly Credentials SORTLY_API_KEY = 'SORTLY_API_KEY', + + // Assembled Credentials + ASSEMBLED_CRED = 'ASSEMBLED_CRED', } // Define all bubble names as a union type for type safety @@ -173,4 +176,5 @@ export type BubbleName = | 'posthog' | 'linear' | 'attio' - | 'hubspot'; + | 'hubspot' + | 'assembled'; diff --git a/packages/create-bubblelab-app/package.json b/packages/create-bubblelab-app/package.json index a93c10c6..d1f2c685 100644 --- a/packages/create-bubblelab-app/package.json +++ b/packages/create-bubblelab-app/package.json @@ -1,6 +1,6 @@ { "name": "create-bubblelab-app", - "version": "0.1.210", + "version": "0.1.211", "type": "module", "license": "Apache-2.0", "description": "Create BubbleLab AI agent applications with one command", diff --git a/packages/create-bubblelab-app/templates/basic/package.json b/packages/create-bubblelab-app/templates/basic/package.json index 15f3371b..dd98bd88 100644 --- a/packages/create-bubblelab-app/templates/basic/package.json +++ b/packages/create-bubblelab-app/templates/basic/package.json @@ -11,9 +11,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@bubblelab/bubble-core": "^0.1.210", - "@bubblelab/bubble-runtime": "^0.1.210", - "@bubblelab/shared-schemas": "^0.1.210", + "@bubblelab/bubble-core": "^0.1.211", + "@bubblelab/bubble-runtime": "^0.1.211", + "@bubblelab/shared-schemas": "^0.1.211", "dotenv": "^16.4.5" }, "devDependencies": { diff --git a/packages/create-bubblelab-app/templates/reddit-scraper/package.json b/packages/create-bubblelab-app/templates/reddit-scraper/package.json index 4541e4b9..6542df7b 100644 --- a/packages/create-bubblelab-app/templates/reddit-scraper/package.json +++ b/packages/create-bubblelab-app/templates/reddit-scraper/package.json @@ -11,8 +11,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@bubblelab/bubble-core": "^0.1.210", - "@bubblelab/bubble-runtime": "^0.1.210", + "@bubblelab/bubble-core": "^0.1.211", + "@bubblelab/bubble-runtime": "^0.1.211", "dotenv": "^16.4.5" }, "devDependencies": {