# 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 }); ``` **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` | 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; 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.50; 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