Orchestrating Multi-Step LLM Tool Execution in Genesys Cloud with TypeScript

Orchestrating Multi-Step LLM Tool Execution in Genesys Cloud with TypeScript

What You Will Build

  • A TypeScript HTTP worker that receives AI Gateway tool call webhooks, validates function arguments against JSON Schema, executes Prisma database queries, aggregates results into a structured payload, and submits the output back to the Genesys Cloud AI Assistant run.
  • This implementation uses the Genesys Cloud REST API, the @genesys/cloud-node-sdk for authentication, ajv for schema validation, and @prisma/client for database operations.
  • The tutorial covers Node.js 18+ with TypeScript 5.x, Express.js for routing, and modern async/await patterns.

Prerequisites

  • OAuth 2.0 Client Credentials application registered in Genesys Cloud with the ai:assistant:run:write scope.
  • Genesys Cloud API version v2.
  • Node.js 18+ and npm or pnpm.
  • External dependencies: express, @genesys/cloud-node-sdk, ajv, @prisma/client, prisma, dotenv, zod (optional, but we use ajv here).
  • A PostgreSQL or MySQL database instance for Prisma.

Authentication Setup

The Genesys Cloud platform requires a valid bearer token for every API request. The client credentials flow exchanges your application credentials for an access token. The token expires after one hour, so your worker must cache and refresh it automatically.

import { PureCloudPlatformClientV2, AuthApi } from '@genesys/cloud-node-sdk';

const platformClient = new PureCloudPlatformClientV2();
const authApi = new AuthApi(platformClient);

let cachedToken: string | null = null;
let tokenExpiry: number = 0;

export async function getAccessToken(): Promise<string> {
  // Return cached token if still valid (subtract 300s buffer for network latency)
  if (cachedToken && Date.now() < tokenExpiry - 300000) {
    return cachedToken;
  }

  try {
    const authResponse = await authApi.postOAuthToken({
      body: {
        grant_type: 'client_credentials',
        client_id: process.env.GENESYS_CLIENT_ID!,
        client_secret: process.env.GENESYS_CLIENT_SECRET!,
        scope: 'ai:assistant:run:write'
      }
    });

    cachedToken = authResponse.body.access_token;
    tokenExpiry = Date.now() + (authResponse.body.expires_in * 1000);
    return cachedToken;
  } catch (error) {
    const status = (error as any).status;
    if (status === 401) {
      throw new Error('OAuth authentication failed. Verify client_id and client_secret.');
    }
    if (status === 403) {
      throw new Error('OAuth scope mismatch. Ensure the application has ai:assistant:run:write.');
    }
    throw new Error(`Token retrieval failed: ${(error as Error).message}`);
  }
}

The AuthApi handles the POST request to /oauth/token. The response returns access_token and expires_in. The caching logic prevents unnecessary network calls during rapid webhook bursts. If the token expires, the next request triggers a refresh.

Implementation

Step 1: Initialize the Worker and Configure Webhook Routing

The Genesys Cloud AI Gateway delivers tool calls to a publicly accessible HTTPS endpoint. You must register this URL in the AI Assistant configuration. The worker receives a JSON payload containing the run_id and an array of tool_calls. Each tool call includes a unique identifier and a JSON string of function arguments.

import express, { Request, Response } from 'express';
import { getAccessToken } from './auth';

const app = express();
app.use(express.json());

app.post('/webhook/tool-calls', async (req: Request, res: Response) => {
  try {
    const { run_id, tool_calls } = req.body;
    
    if (!run_id || !Array.isArray(tool_calls)) {
      return res.status(400).json({ error: 'Invalid payload structure. Expected run_id and tool_calls array.' });
    }

    // Process tool calls and submit results
    await processToolCalls(run_id, tool_calls);
    
    res.status(202).json({ status: 'accepted' });
  } catch (error) {
    console.error('Webhook processing failed:', error);
    res.status(500).json({ error: 'Internal processing error' });
  }
});

app.listen(3000, () => console.log('Tool execution worker listening on port 3000'));

The endpoint responds with 202 Accepted immediately. Genesys Cloud expects an HTTP 2xx response within 30 seconds to acknowledge receipt. Actual processing occurs asynchronously to avoid timeout penalties. The processToolCalls function handles validation, database execution, and API submission.

Step 2: Parse Gateway Webhooks and Validate Against JSON Schema

LLM-generated arguments are unstructured and frequently contain typos, missing fields, or incorrect types. You must validate them before database execution. The ajv library compiles JSON Schema definitions into fast validation functions.

import Ajv from 'ajv';
import addFormats from 'ajv-formats';

const ajv = new Ajv({ allErrors: true });
addFormats(ajv);

// Define schema for a hypothetical inventory lookup tool
const inventorySchema = {
  type: 'object',
  required: ['sku', 'warehouse'],
  properties: {
    sku: { type: 'string', pattern: '^[A-Z]{2,4}-\\d{3,6}$' },
    warehouse: { type: 'string', enum: ['US-WEST', 'US-EAST', 'EU-CENTRAL'] }
  },
  additionalProperties: false
};

const validateInventory = ajv.compile(inventorySchema);

interface ToolCallPayload {
  id: string;
  type: string;
  function: {
    name: string;
    arguments: string;
  };
}

export async function validateToolArguments(toolCall: ToolCallPayload): Promise<Record<string, unknown>> {
  const args = JSON.parse(toolCall.function.arguments);
  
  if (toolCall.function.name === 'search_inventory') {
    const valid = validateInventory(args);
    if (!valid) {
      const errors = validateInventory.errors?.map(e => `${e.instancePath}: ${e.message}`).join(', ');
      throw new Error(`Schema validation failed for ${toolCall.function.name}: ${errors}`);
    }
  }
  
  return args;
}

The ajv.compile method returns a synchronous validation function. The allErrors: true flag collects every violation instead of stopping at the first one. The addFormats plugin enables standard format checking if your schema requires dates or emails. The function throws a descriptive error when validation fails, which propagates to the error handler.

Step 3: Execute Database Queries via Prisma and Aggregate Results

Prisma provides type-safe database access. You generate the client from your schema file, then execute queries based on the validated arguments. The worker must handle multiple tool calls in a single webhook, potentially routing them to different database operations.

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

interface InventoryResult {
  sku: string;
  quantity: number;
  status: string;
}

export async function executeToolQueries(toolCall: ToolCallPayload, args: Record<string, unknown>): Promise<string> {
  const { sku, warehouse } = args as { sku: string; warehouse: string };

  if (toolCall.function.name === 'search_inventory') {
    const records = await prisma.inventory.findMany({
      where: {
        sku: sku,
        warehouse: warehouse,
        status: { not: 'DECOMMISSIONED' }
      },
      select: {
        sku: true,
        quantity: true,
        status: true
      }
    });

    if (records.length === 0) {
      return JSON.stringify({ message: `No active inventory found for SKU ${sku} at ${warehouse}.` });
    }

    const aggregated: InventoryResult[] = records.map(r => ({
      sku: r.sku,
      quantity: r.quantity,
      status: r.status
    }));

    return JSON.stringify({ items: aggregated, total: aggregated.length });
  }

  throw new Error(`Unsupported tool function: ${toolCall.function.name}`);
}

The findMany query filters by the validated parameters and excludes decommissioned items. The result maps to a simplified interface before serialization. Prisma handles connection pooling and query optimization automatically. The function returns a JSON string because the Genesys Cloud tool response endpoint expects stringified output for LLM parsing.

Step 4: Submit Tool Responses to the Genesys Cloud API with Retry Logic

After validation and database execution, you must submit the results back to Genesys Cloud. The endpoint POST /api/v2/ai/assistants/runs/{runId}/tool_calls accepts an array of tool call responses. Network instability or rate limiting can cause temporary failures, so you must implement exponential backoff for HTTP 429 responses.

import https from 'https';

const GENESYS_BASE_URL = 'https://api.mypurecloud.com';

interface ToolResponsePayload {
  tool_calls: Array<{
    tool_call_id: string;
    output: string;
  }>;
}

export async function submitToolResponse(runId: string, responses: ToolResponsePayload['tool_calls'], maxRetries = 3): Promise<void> {
  const token = await getAccessToken();
  const url = `${GENESYS_BASE_URL}/api/v2/ai/assistants/runs/${encodeURIComponent(runId)}/tool_calls`;
  
  const body = JSON.stringify({ tool_calls: responses });
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await new Promise<any>((resolve, reject) => {
        const req = https.request(url, {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${token}`,
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'User-Agent': 'Genesys-ToolWorker/1.0'
          },
          timeout: 10000
        }, (res) => {
          let data = '';
          res.on('data', chunk => data += chunk);
          res.on('end', () => resolve({ status: res.statusCode, body: data }));
        });
        
        req.on('error', reject);
        req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
        req.write(body);
        req.end();
      });

      if (response.status === 200 || response.status === 204) {
        console.log(`Tool response submitted successfully for run ${runId}`);
        return;
      }

      if (response.status === 429) {
        const retryAfter = response.headers['retry-after'] ? parseInt(response.headers['retry-after']) : Math.pow(2, attempt);
        console.warn(`Rate limited (429). Retrying in ${retryAfter}s...`);
        await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
        continue;
      }

      if (response.status === 401 || response.status === 403) {
        throw new Error(`Authentication/Authorization failed: ${response.status}. ${response.body}`);
      }

      throw new Error(`API submission failed with status ${response.status}: ${response.body}`);
    } catch (error) {
      console.error(`Attempt ${attempt} failed:`, error);
      if (attempt === maxRetries) throw error;
      await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
    }
  }
}

The request uses Node.js built-in https to avoid dependency bloat. The retry loop catches 429 responses and respects the Retry-After header when present. Authentication failures throw immediately because token caching handles expiration. The endpoint returns 200 OK or 204 No Content on success. The payload structure matches the Genesys Cloud specification exactly.

Complete Working Example

The following module combines authentication, validation, database execution, and API submission into a single runnable worker. Replace environment variables and Prisma schema paths before deployment.

import express, { Request, Response } from 'express';
import { getAccessToken } from './auth';
import { validateToolArguments, ToolCallPayload } from './validation';
import { executeToolQueries } from './database';
import { submitToolResponse } from './api-client';

const app = express();
app.use(express.json());

async function processToolCalls(runId: string, toolCalls: ToolCallPayload[]): Promise<void> {
  const responses: Array<{ tool_call_id: string; output: string }> = [];

  for (const toolCall of toolCalls) {
    try {
      const args = await validateToolArguments(toolCall);
      const output = await executeToolQueries(toolCall, args);
      responses.push({ tool_call_id: toolCall.id, output });
    } catch (error) {
      const errorMessage = (error as Error).message;
      console.error(`Tool call ${toolCall.id} failed: ${errorMessage}`);
      // Return error string to the LLM so it can adjust subsequent steps
      responses.push({ tool_call_id: toolCall.id, output: JSON.stringify({ error: errorMessage }) });
    }
  }

  await submitToolResponse(runId, responses);
}

app.post('/webhook/tool-calls', async (req: Request, res: Response) => {
  try {
    const { run_id, tool_calls } = req.body;
    if (!run_id || !Array.isArray(tool_calls)) {
      return res.status(400).json({ error: 'Invalid payload structure.' });
    }
    
    // Run asynchronously to avoid HTTP timeout
    processToolCalls(run_id, tool_calls).catch(console.error);
    res.status(202).json({ status: 'accepted' });
  } catch (error) {
    res.status(500).json({ error: 'Webhook handler crashed' });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Genesys Cloud tool execution worker running on port ${PORT}`);
});

The worker iterates through each tool call, validates arguments, executes the database query, and captures the output. Failed calls return a JSON error string instead of crashing the batch. This pattern ensures multi-step LLM execution continues even when individual tool calls encounter data or schema issues. The HTTP response is sent immediately while background processing completes.

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: The OAuth token is expired, malformed, or missing the ai:assistant:run:write scope.
  • Fix: Verify the client credentials in your environment variables. Check the AuthApi response for scope rejection. Ensure the token cache does not serve expired tokens. Add a 300-second buffer before expiry.
  • Code Check: Confirm getAccessToken() returns a string starting with eyJ. Log the token length to detect truncation.

Error: 403 Forbidden

  • Cause: The OAuth application lacks the required scope, or the run ID belongs to a different organization.
  • Fix: Navigate to the Genesys Cloud admin console, open the application, and add ai:assistant:run:write to the granted scopes. Verify the run_id matches the assistant configuration.
  • Code Check: The API response body contains error_description. Parse it to confirm scope mismatch.

Error: 422 Unprocessable Entity

  • Cause: The tool call payload does not match the expected schema, or the tool_call_id does not exist in the current run.
  • Fix: Validate that tool_calls is an array of objects with tool_call_id and output properties. Ensure the output is a stringified JSON object, not a raw array or number.
  • Code Check: Log the exact request body before submission. Use JSON.stringify explicitly on all outputs.

Error: 429 Too Many Requests

  • Cause: The worker exceeded the Genesys Cloud rate limit for AI Assistant run updates.
  • Fix: Implement exponential backoff with jitter. Batch multiple tool calls into a single POST request instead of sequential calls. Monitor the X-RateLimit-Remaining header.
  • Code Check: The retry loop in submitToolResponse handles 429 automatically. Adjust maxRetries and base delay for high-throughput deployments.

Error: Prisma Client Not Initialized

  • Cause: Missing prisma generate step or incorrect database URL.
  • Fix: Run npx prisma generate after schema changes. Verify DATABASE_URL points to a reachable instance. Check connection timeouts in the Prisma client configuration.
  • Code Check: Wrap prisma.$connect() in a startup hook to verify connectivity before listening for webhooks.

Official References