UNPKG

@botpress/adk-cli

Version:

Command-line interface for the Botpress Agent Development Kit (ADK)

832 lines (648 loc) 23.6 kB
# Actions Actions are callable, strongly-typed functions that encapsulate reusable logic accessible throughout your agent. They can be invoked from workflows, conversations, other actions, or exposed as tools for AI. ## Overview Actions enable you to: - Create reusable business logic across your bot - Call integration APIs with type safety - Expose functionality as AI-callable tools - Compose complex operations from simple functions - Test logic independently from conversational flows ## File Location - **Location**: `src/actions/*.ts` - **Auto-registration**: Files in this directory automatically become available as `actions.{actionName}` - **Export pattern**: Both `export const` and `export default` work - **`export const`** (Recommended): Enables direct imports and passing to `execute()` as tools - **`export default`**: Simpler for single-action files ## Basic Action Structure ```typescript import { Action, z } from "@botpress/runtime"; // Option 1: Named export (recommended) export const myAction = new Action({ name: "myAction", input: z.object({ userId: z.string(), }), output: z.object({ data: z.string(), timestamp: z.string() }), handler: async ({ input, client }) { // Your business logic here const result = await fetchData(input.userId); return { data: result, timestamp: new Date().toISOString() }; }, }); // Option 2: Default export (also valid) export default new Action({ name: "myAction", // ... same configuration }); ``` > **`output` is required.** Declare it as a bare Zui schema (`output: z.object({ ... })`), never `output: { schema: ... }`. Omitting it fails the type check (`adk check` won't catch it). **Why use `export const`?** - Enables direct imports: `import { myAction } from "./actions/myAction"` - Can pass to `execute()`: `tools: [myAction.asTool()]` - Better for action composition and reusability ## Action Properties | Property | Type | Required | Description | | --------------- | ------------------------ | -------- | ---------------------------------------------------------------- | | **name** | `string` | Yes | Unique alphanumeric identifier (no spaces or special characters) | | **input** | `z.ZodTypeAny` | Yes | Zod schema defining input parameters | | **output** | `z.ZodTypeAny` | Yes | Zod schema defining return type | | **handler** | `function` | Yes | Async function implementing the action logic | | **title** | `string` | No | Human-readable display name | | **description** | `string` | No | Description of the action's functionality | | **attributes** | `Record<string, string>` | No | Custom metadata for categorization | | **cached** | `boolean` | No | Enable caching for identical inputs (defaults to `false`) | ## Handler Syntax Action handlers receive a props object containing `input` and `client`. Both method syntax and arrow functions are supported. ### Method Syntax (Recommended) ```typescript export const fetchUser = new Action({ name: 'fetchUser', input: z.object({ userId: z.string() }), output: z.object({ name: z.string(), email: z.string(), }), async handler({ input, client }) { // input: validated input matching your input schema // client: Botpress API client for making API calls const { user } = await client.getUser({ id: input.userId }) return { name: user.name, email: user.tags.email, } }, }) ``` ### Arrow Function Syntax ```typescript export const updateUser = new Action({ name: 'updateUser', input: z.object({ userId: z.string(), name: z.string(), }), output: z.object({ success: z.boolean() }), handler: async ({ input, client }) => { await client.updateUser({ id: input.userId, name: input.name, }) return { success: true } }, }) ``` ### Common Mistakes ```typescript // WRONG - Destructuring input fields directly export const fetchUser = new Action({ name: 'fetchUser', input: z.object({ userId: z.string() }), output: z.object({ name: z.string() }), async handler({ userId }) { // Wrong! Must be { input } return { name: userId } }, }) // CORRECT - Use { input } wrapper export const fetchUser = new Action({ name: 'fetchUser', input: z.object({ userId: z.string() }), output: z.object({ name: z.string() }), async handler({ input }) { // Correct const { userId } = input // Destructure inside handler return { name: userId } }, }) ``` **Key points:** - Handler receives `{ input, client }` props object - Both method syntax and arrow functions work - Destructure input fields **inside** the handler body, not in the parameter - **Actions CANNOT destructure input fields directly** (unlike Tools) - Omit `client` if not needed: `async handler({ input })` ## Calling Actions ### From Workflows ```typescript import { Workflow, actions } from '@botpress/runtime' export default new Workflow({ name: 'processOrder', input: z.object({ orderId: z.string() }), handler: async ({ input }) => { // Call your custom actions const order = await actions.fetchOrder({ orderId: input.orderId }) const result = await actions.processPayment({ order }) return result }, }) ``` ### From Other Actions ```typescript import { Action, actions, z } from '@botpress/runtime' export default new Action({ name: 'compositeAction', input: z.object({ userId: z.string() }), output: z.object({ success: z.boolean() }), async handler({ input }) { // Compose multiple actions const userData = await actions.fetchUser({ userId: input.userId }) const profile = await actions.buildProfile({ user: userData }) return { success: true } }, }) ``` ### Integration Actions Call integration actions from within your actions: ```typescript import { actions } from '@botpress/runtime' // Inside action handler await actions.slack.sendMessage({ channel: '#general', text: 'Message from bot', }) await actions.linear.issueList({ first: 10 }) ``` See **[Integration Actions](./integration-actions.md)** for complete guide. ## Accessing Additional Context Actions can access additional runtime context through **direct imports** or the **Context API**. Direct imports are best for the global proxies that actually exist at runtime, such as `user`, `bot`, `configuration`, and `adk`. For optional per-request values like `conversation` and `message`, use the Context API. ### Method 1: Direct Imports (Global Context Proxies) ```typescript import { Action, z, user, bot, adk } from '@botpress/runtime' import { context } from '@botpress/runtime' export default new Action({ name: 'updateUserProfile', input: z.object({ name: z.string() }), output: z.object({ success: z.boolean() }), async handler({ input }) { // Access user state directly (when in conversation context) user.state.profileName = input.name user.tags.profileComplete = 'true' // Access bot state bot.state.totalUsers += 1 // Access conversation (when in conversation context) const conversation = context.get('conversation', { optional: true }) console.log(conversation?.id) // Use ADK utilities const extracted = await adk.zai.extract(input.name, z.object({ firstName: z.string(), lastName: z.string() })) return { success: true } }, }) ``` ### Method 2: Context API (Explicit Retrieval) ```typescript import { Action, z, context } from '@botpress/runtime' export default new Action({ name: 'checkContext', input: z.object({}), output: z.object({ hasUser: z.boolean() }), async handler({ input }) { // Optional context (may not be available in all scenarios) const user = context.get('user', { optional: true }) const conversation = context.get('conversation', { optional: true }) const message = context.get('message', { optional: true }) // Always available const client = context.get('client') const citations = context.get('citations') if (user) { console.log(`User: ${user.id}`) } return { hasUser: !!user } }, }) ``` **Which method to use?** - **Direct imports**: Use for `user`, `bot`, `adk` when you know they're available (simpler syntax) - **Context API**: Use for optional context (`user`, `conversation`, `message`) or when you need `client` and `citations` **Always available:** `bot`, `adk`, `client`, `context` **Conditionally available:** `user`, `conversation`, `message` (only in conversation context) See **[Context API](./context-api.md)** for complete details on all context keys and availability. ## Advanced Action Patterns ### Pattern 1: Integration Wrapper Pattern **Purpose:** Wrap integration actions with bot-specific logic, data transformation, and local persistence. **When to use:** - Syncing external resources (products, issues, orders) to local tables - Normalizing data from different integrations - Adding business logic or validation - Maintaining local cache of external data **Abstract Structure:** ```typescript export default new Action({ name: 'syncResource', description: 'Fetch and sync external resource to local storage', input: z.object({ resourceId: z.string().describe('External resource identifier'), }), output: BotResourceSchema, // Your normalized schema async handler({ input }) { // 1. Call integration action const externalData = await actions.integration.getResource({ id: input.resourceId, }) // 2. Transform to bot schema const normalizedData = transformToBotSchema(externalData) // 3. Persist to table (prevents duplicates) await ResourceTable.upsertRows({ rows: [normalizedData], keyColumn: 'id', // Unique identifier }) // 4. Return normalized data return normalizedData }, }) ``` **Real Example - Shopify: Sync Products** ```typescript import { Action, actions, z } from '@botpress/runtime' import { ProductsTable } from '../tables/shopify' export default new Action({ name: 'syncProduct', description: 'Sync a Shopify product to local database', input: z.object({ productId: z.string().describe('Shopify product ID'), includeVariants: z.boolean().default(true), }), output: z.object({ id: z.string(), title: z.string(), price: z.number(), inventory: z.number(), lastSynced: z.string(), }), async handler({ input }) { // 1. Fetch from Shopify const shopifyProduct = await actions.shopify.getProduct({ id: input.productId, fields: ['id', 'title', 'variants', 'inventory'], }) // 2. Transform to bot schema const product = { id: shopifyProduct.id, title: shopifyProduct.title, price: shopifyProduct.variants[0].price, inventory: shopifyProduct.variants.reduce((sum, v) => sum + v.inventory_quantity, 0), lastSynced: new Date().toISOString(), } // 3. Upsert to table await ProductsTable.upsertRows({ rows: [product], keyColumn: 'id', }) return product }, }) ``` **Key Benefits:** - **Consistency**: All external data follows your bot's schema - **Performance**: Local table queries are faster than API calls - **Reliability**: Works even if integration is temporarily unavailable - **Extensibility**: Easy to add business logic or validation ### Pattern 2: Resource State Management **Purpose:** Update external resource state and keep local cache in sync. **Real Example - Linear: Update Issue Status** ```typescript import { Action, actions, z } from '@botpress/runtime' import { IssuesTable } from '../tables/linear' export default new Action({ name: 'updateIssueStatus', input: z.object({ issueId: z.string(), stateId: z.string(), comment: z.string().optional(), }), output: z.object({ id: z.string(), title: z.string(), state: z.string(), updatedAt: z.string(), }), async handler({ input }) { // 1. Update in Linear const issue = await actions.linear.updateIssue({ id: input.issueId, stateId: input.stateId, ...(input.comment && { commentBody: input.comment, }), }) // 2. Transform and sync to table const normalized = { id: issue.id, title: issue.title, state: issue.state.name, updatedAt: new Date().toISOString(), } await IssuesTable.upsertRows({ rows: [normalized], keyColumn: 'id', }) return normalized }, }) ``` **When to use this pattern:** - Tracking order fulfillment status - Managing ticket/issue lifecycle - Handling conversation states - Any resource with state transitions ## Schema Design Best Practices **Core Principles:** ```typescript import { z } from '@botpress/runtime' // 1. Always export both type and schema export type Product = z.infer<typeof ProductSchema> export const ProductSchema = z.object({ id: z.string(), name: z.string(), price: z.number(), }) // 2. Use .describe() on every field (helps AI tools) const schema = z.object({ productId: z.string().describe('Unique product identifier'), price: z.number().describe('Price in cents'), inStock: z.boolean().describe('Whether product is available'), }) // 3. ISO strings for dates (NOT z.date()) const timeFields = z.object({ createdAt: z.string().describe('Creation date as ISO 8601 string'), updatedAt: z.string().optional().describe('Last update date'), }) // 4. Use .optional() for optional fields const optionalFields = z.object({ name: z.string(), // Required description: z.string().optional(), // Optional metadata: z.record(z.unknown()).optional(), }) // 5. Use .passthrough() for external API data const externalData = z .object({ id: z.string(), // Allow unknown fields from external API }) .passthrough() // 6. Define nested objects explicitly const structured = z.object({ product: z.object({ // Explicit structure id: z.string(), name: z.string(), }), metadata: z.record(z.unknown()), // Use only when structure is truly unknown }) ``` **Key Patterns:** 1. **Export both type and schema** - TypeScript type from `z.infer<>` 2. **Describe every field** - Helps AI tools and documentation 3. **ISO strings for dates** - Use `z.string()` not `z.date()` 4. **Optional vs nullable** - Use `.optional()` for optional fields 5. **Passthrough for external APIs** - Use `.passthrough()` when structure varies 6. **Separate schemas file** - Keep complex schemas in `src/schemas.ts` 7. **Explicit nested objects** - Define structure instead of `z.unknown()` ## Converting Actions to Tools Actions can be converted to AI-callable tools using the `.asTool()` method. This allows AI agents to autonomously call your actions during conversations. ### Basic Usage ```typescript import { actions, Conversation } from '@botpress/runtime' export default new Conversation({ handler: async ({ execute }) => { await execute({ instructions: 'Help the user with their request', tools: [ // Convert existing actions to tools actions.fetchUser.asTool(), actions.sendEmail.asTool(), actions.searchDatabase.asTool(), ], }) }, }) ``` ### Tool Patterns: Blocking vs Non-Blocking #### Pattern Comparison | Pattern | Returns | Execution | Example | | -------------------- | ------------ | ------------ | ----------------- | | **Direct Action** | Final result | Synchronous | `fetchUser()` | | **Workflow Wrapper** | Workflow ID | Asynchronous | `startIndexing()` | #### 1. Direct Actions Actions that complete and return results immediately: ```typescript import { Action, z } from '@botpress/runtime' export default new Action({ name: 'calculateShipping', description: 'Calculate shipping cost for an order', input: z.object({ weight: z.number(), destination: z.string(), }), output: z.object({ cost: z.number(), estimatedDays: z.number(), }), async handler({ input }) { // Business logic that completes immediately const baseRate = 5.99 const perKg = 2.5 const cost = baseRate + input.weight * perKg const estimatedDays = input.destination === 'domestic' ? 3 : 7 return { cost, estimatedDays } }, }) // Use as tool - AI can call this during conversation await execute({ instructions: 'Help the user with shipping', tools: [actions.calculateShipping.asTool()], }) ``` #### 2. Workflow Wrappers Actions that start workflows and return workflow ID: ```typescript import { Action, z } from '@botpress/runtime' import { DataAnalysisWorkflow } from '../workflows/data-analysis' export default new Action({ name: 'startDataAnalysis', description: 'Start data analysis workflow', input: z.object({ datasetId: z.string(), }), output: z.object({ workflowId: z.string(), }), async handler({ input }) { // Calls workflow.start() - returns immediately const instance = await DataAnalysisWorkflow.start({ datasetId: input.datasetId, }) return { workflowId: instance.id } }, }) // Companion action to check status export const checkAnalysisStatus = new Action({ name: 'checkAnalysisStatus', description: 'Check workflow status', input: z.object({ workflowId: z.string(), }), output: z.object({ status: z.string(), }), async handler({ input, client }) { const { workflow } = await client.getWorkflow({ id: input.workflowId }) return { status: workflow.status } }, }) // Use both as tools await execute({ instructions: 'Help the user', tools: [actions.startDataAnalysis.asTool(), actions.checkAnalysisStatus.asTool()], }) ``` ### Custom Tool Conversion ```typescript // Basic conversion const shippingTool = actions.calculateShipping.asTool() // With custom description const analysisTool = actions.startDataAnalysis.asTool({ description: 'Analyze dataset and return workflow ID', }) ``` > **See Also:** [Exposing Workflows as Tools](./workflows.md#exposing-workflows-as-tools-non-blocking-pattern) for comprehensive workflow wrapper patterns and tracking examples. ## Best Practices 1. **Use descriptive names**: `fetchUserProfile` not `getUser` 2. **Validate inputs thoroughly**: Use Zod's validation features 3. **Handle errors gracefully**: Catch and wrap errors with context 4. **Keep actions focused**: One action = one responsibility 5. **Document complex logic**: Add comments for non-obvious operations 6. **Use caching wisely**: Enable `cached: true` for deterministic operations ```typescript export default new Action({ name: 'fetchUserProfile', description: 'Fetches complete user profile with preferences', input: z.object({ userId: z.string().uuid(), // Validate UUID format includePreferences: z.boolean().default(false), }), output: z.object({ user: z.object({ id: z.string(), name: z.string(), email: z.string().email(), }), preferences: z .object({ language: z.string(), timezone: z.string(), }) .optional(), }), cached: true, // Cache for performance async handler({ input, client }) { // Validate user exists const { user } = await client.getUser({ id: input.userId }) if (!user) { throw new Error(`User ${input.userId} not found`) } // Conditionally fetch preferences const preferences = input.includePreferences ? await fetchUserPreferences(input.userId) : undefined return { user: { id: user.id, name: user.name, email: user.tags.email, }, preferences, } }, }) ``` ## Common Patterns ### Composition Pattern Combine multiple actions for complex operations: ```typescript export default new Action({ name: 'processOrder', input: z.object({ orderId: z.string() }), output: z.object({ success: z.boolean() }), async handler({ input }) { // Compose multiple actions const order = await actions.fetchOrder({ id: input.orderId }) const validated = await actions.validateOrder({ order }) if (validated.isValid) { await actions.chargePayment({ amount: order.total, customerId: order.customerId, }) await actions.sendConfirmation({ order }) await actions.updateInventory({ items: order.items }) } return { success: validated.isValid } }, }) ``` ### Error Recovery Pattern Handle failures gracefully with retries: ```typescript export default new Action({ name: 'resilientFetch', input: z.object({ url: z.string().url() }), output: z.object({ data: z.unknown() }), async handler({ input }) { const maxRetries = 3 let lastError for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await fetch(input.url) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return { data: await response.json() } } catch (error) { lastError = error if (attempt < maxRetries) { // Exponential backoff await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt))) } } } throw new Error(`Failed after ${maxRetries} attempts: ${lastError.message}`) }, }) ``` ## Troubleshooting ### Common Issues 1. **"Cannot destructure property" error** - **Cause**: Destructuring input fields directly in Action handler parameter - **Wrong**: `async handler({ userId }) { ... }` - **Correct**: `async handler({ input }) { const { userId } = input; ... }` - **Note**: Tools can destructure directly, but Actions cannot 2. **"user is not defined" or context errors** - **Cause**: Accessing conversation context outside of conversation context - **Solution**: Use `context.get("user", { optional: true })` to check availability - **Note**: `user`, `conversation`, `message` are only available in conversation contexts 3. **Action not found** - Verify file is in `src/actions/` directory - Check import: `import { actions } from "@botpress/runtime"` - Ensure action name matches file export 4. **Type errors with inputs/outputs** - Verify Zod schemas match actual data - Use `.optional()` for optional fields - Check for typos in property names 5. **State properties undefined** - **Cause**: State properties must be defined in `agent.config.ts` schemas - **Solution**: Add properties to `user.state` or `bot.state` schema in config - **Example**: Can't use `user.state.characterName` unless defined in config 6. **Caching issues** - **Issue**: Cached results not updating when expected - **Solution**: Only use `cached: true` for deterministic functions - **Tip**: Avoid caching for time-sensitive or user-specific data ## See Also - [Tools](./tools.md) - AI-callable tools for autonomous execution - [Workflows](./workflows.md) - Multi-step processes with state management - [Context API](./context-api.md) - Access runtime context and services - [Integration Actions](./integration-actions.md) - Using integration actions - [Patterns & Common Mistakes](./patterns-mistakes.md) - Best practices for Zod schemas