Title: Actions Developer Guide Description: Technical guide for extending the Actions system with custom blocks Tags: actions, developer, blocks, workflow --- Actions Developer Guide This guide provides step-by-step instructions for developers to extend the Actions system by creating custom blocks, triggers, and implementing new functionality. User Documentation: See Actions User Guide for non-technical workflow creation instructions. Creating Custom Action Blocks Action blocks are the core processing units in workflows. Here's how to create new ones: Define Block Constants First, define your block name constant in the shared package: // packages/shared/src/constants.ts (or appropriate constants file) export const BLOCKTRANSFORMDATA = "transform-data"; export const BLOCKHTTPREQUEST = "http-request"; export const BLOCKDATABASEQUERY = "database-query"; Implement Block Function Create your block implementation following the ActionBlockImpl interface: // packages/server/src/actions/blocks/transform-data.block.ts import type { ActionBlockResult } from "@smallstack/shared"; export const BLOCKTRANSFORMDATA = "transform-data"; export async function transformDataBlockFn( socketName: string, data: any ): Promise { try { // Validate input socket if (socketName !== "input") { throw new Error(Unexpected socket name: ${socketName}); } // Perform transformation logic const transformedData = { ...data, transformedAt: new Date().toISOString(), processed: true }; return { socketName: "output", // Define output socket data: transformedData, logs: [{ timestamp: Date.now(), message: Data transformed successfully for ${Object.keys(data).length} fields, level: "info" }] }; } catch (error) { return { socketName: "error", logs: [{ timestamp: Date.now(), message: Transform failed: ${error.message}, level: "error" }] }; } } Register Block in Registry Add your block to the server registry: // packages/server/src/actions/create-server-block-registry.ts import { ActionBlockRegistry } from "@smallstack/shared"; import { BLOCKSENDMAIL, sendMailBlockFn } from "./blocks/send-mail.block"; import { BLOCKTRANSFORMDATA, transformDataBlockFn } from "./blocks/transform-data.block"; export function createServerBlockRegistry(): ActionBlockRegistry { const serverActionBlocksRegistry = new ActionBlockRegistry(); // Existing blocks serverActionBlocksRegistry.registerBlock(BLOCKSENDMAIL, { execute: sendMailBlockFn }); // Your new block serverActionBlocksRegistry.registerBlock(BLOCKTRANSFORMDATA, { execute: transformDataBlockFn }); return serverActionBlocksRegistry; } Block Implementation Patterns Configuration-Based Blocks Many blocks need configuration parameters: interface HttpRequestConfig { url: string; method: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record; timeout?: number; } export async function httpRequestBlockFn( socketName: string, data: any, configuration?: HttpRequestConfig ): Promise { const config = configuration || {}; if (!config.url) { throw new Error("URL is required in block configuration"); } try { const response = await fetch(config.url, { method: config.method || "GET", headers: config.headers, body: config.method !== "GET" ? JSON.stringify(data) : undefined, signal: AbortSignal.timeout(config.timeout || 5000) }); const responseData = await response.json(); return { socketName: response.ok ? "success" : "error", data: responseData, logs: [{ timestamp: Date.now(), message: HTTP ${config.method} to ${config.url}: ${response.status}, level: response.ok ? "info" : "warn" }] }; } catch (error) { return { socketName: "error", logs: [{ timestamp: Date.now(), message: HTTP request failed: ${error.message}, level: "error" }] }; } } Multi-Output Blocks Blocks can have multiple output sockets for different scenarios: export async function conditionalBlockFn( socketName: string, data: any ): Promise { const value = data.value; if (typeof value === "number") { return { socketName: value > 0 ? "positive" : "negative", data: { result: value, type: "number" }, logs: [{ timestamp: Date.now(), message: Number ${value} routed to ${value > 0 ? "positive" : "negative"} path, level: "info" }] }; } return { socketName: "invalid", data: { error: "Expected number input" }, logs: [{ timestamp: Date.now(), message: Invalid input type: ${typeof value}, level: "warn" }] }; } Async Operations Handle long-running operations properly: export async function databaseQueryBlockFn( socketName: string, data: any ): Promise { const startTime = Date.now(); try { // Simulate database operation const results = await performDatabaseQuery(data.query, data.params); const duration = Date.now() - startTime; return { socketName: "results", data: { results, duration }, logs: [{ timestamp: Date.now(), message: Query completed in ${duration}ms, returned ${results.length} rows, level: "info" }] }; } catch (error) { return { socketName: "error", logs: [{ timestamp: Date.now(), message: Database query failed after ${Date.now() - startTime}ms: ${error.message}, level: "error" }] }; } } Testing Action Blocks Unit Testing Create focused tests for individual blocks: // packages/server/src/actions/blocks/transform-data.block.test.ts import { describe, expect, test } from "vitest"; import { transformDataBlockFn } from "./transform-data.block"; describe("Transform Data Block", () => { test("transforms data successfully", async () => { const inputData = { name: "John", age: 30 }; const result = await transformDataBlockFn("input", inputData); expect(result.socketName).toBe("output"); expect(result.data).toMatchObject({ name: "John", age: 30, processed: true }); expect(result.data.transformedAt).toBeDefined(); expect(result.logs).toHaveLength(1); expect(result.logs[0].level).toBe("info"); }); test("handles invalid socket name", async () => { const result = await transformDataBlockFn("wrongSocket", {}); expect(result.socketName).toBe("error"); expect(result.logs[0].level).toBe("error"); expect(result.logs[0].message).toContain("Unexpected socket name"); }); }); Integration Testing Test blocks within the execution context: // packages/shared/src/actions/action-executor.test.ts test("can execute transform data action", async () => { const blockRegistry = new ActionBlockRegistry(); blockRegistry.registerBlock(BLOCKTRANSFORMDATA, { execute: transformDataBlockFn }); const actions: Action[] = [{ triggers: [{ triggerName: "testTrigger", id: "trigger1" }], blocks: [{ id: "block1", blockName: BLOCKTRANSFORMDATA, configuration: {} }], connections: [{ sourceBlockId: "trigger1", sourceSocketName: TRIGGERSOCKETNAME, targetBlockId: "block1", targetSocketName: "input" }] }]; const results = await executeActionsByTrigger({ triggerName: "testTrigger", triggerData: { name: "Test", value: 42 }, blockRegistry, actions }); expect(results).toHaveLength(1); expect(results[0].success).toBe(true); }); Adding Custom Triggers Define Trigger Type Add new trigger types to the enum: // packages/shared/src/actions/action-trigger.ts export enum ActionTriggers { DOCUMENT_CREATED = "documentCreated", DOCUMENT_UPDATED = "documentUpdated", DOCUMENT_DELETED = "documentDeleted", // New triggers USER_LOGIN = "userLogin", PAYMENT_PROCESSED = "paymentProcessed", SCHEDULED_TIME = "scheduledTime" } Implement Trigger Detection Create server-side logic to detect and fire triggers: // packages/server/src/services/user-trigger.service.ts import { executeActionsByTrigger } from "@smallstack/shared"; import { createServerBlockRegistry } from "../actions/create-server-block-registry"; export class UserTriggerService { private blockRegistry = createServerBlockRegistry(); async onUserLogin(userId: string, userData: any) { // Get actions for this tenant that have userLogin triggers const actions = await this.getActionsByTrigger("userLogin"); if (actions.length > 0) { const results = await executeActionsByTrigger({ triggerName: "userLogin", triggerData: { userId, userData, timestamp: Date.now() }, blockRegistry: this.blockRegistry, actions }); // Log or handle results as needed console.log(Executed ${results.length} user login actions); } } private async getActionsByTrigger(triggerName: string): Promise { // Implementation depends on your data layer // Return actions that have triggers matching the triggerName } } Integrate with Application Events Connect triggers to your application's event system: // In your authentication service export class AuthService { constructor(private userTriggerService: UserTriggerService) {} async loginUser(credentials: LoginCredentials) { const user = await this.validateCredentials(credentials); // Perform login logic... // Trigger Actions await this.userTriggerService.onUserLogin(user.id, { email: user.email, loginTime: new Date(), ipAddress: credentials.ipAddress }); return user; } } Best Practices Error Handling Always return a result: Never throw unhandled exceptions Provide meaningful logs: Include context and error details Use appropriate log levels: info, warn, error Handle socket validation: Check input socket names Performance Keep blocks lightweight: Avoid heavy computations Use async/await properly: Don't block unnecessarily Implement timeouts: For external API calls Consider memory usage: Don't store large objects Configuration Validate configuration: Check required parameters early Provide defaults: For optional configuration values Document configuration: Clear parameter descriptions Type configuration: Use TypeScript interfaces Multi-Tenancy Respect tenant boundaries: Never access other tenant's data Include tenant context: Pass tenant information to external services Validate access: Ensure user has permission for actions Isolate data: Keep tenant data completely separate Testing Test happy path: Normal execution scenarios Test error cases: Invalid inputs, network failures Test configuration: Various configuration combinations Test multi-tenancy: Data isolation and access control Common Patterns Data Transformation Pipeline Action with sequence: Trigger → Transform Block → Validate Block → Save Block Conditional Workflows Action with branching: Trigger → Condition Block → { Success Block | Error Block } External Integration Action with external calls: Trigger → HTTP Block → Parse Block → Store Block Notification Workflows Action with notifications: Trigger → Format Block → { Email Block | SMS Block | Slack Block } Debugging Actions Enable Detailed Logging export async function debuggableBlockFn( socketName: string, data: any ): Promise { const logs: ActionExecutionResultLog[] = []; logs.push({ timestamp: Date.now(), message: Starting execution with socket: ${socketName}, level: "info" }); logs.push({ timestamp: Date.now(), message: Input data: ${JSON.stringify(data)}, level: "info" }); // ... block logic ... return { socketName: "output", data: result, logs }; } Use Development Tools Unit tests: Test blocks in isolation Integration tests: Test full workflows Console logging: During development Execution results: Review logs from actual runs Common Issues Socket name mismatches: Ensure connections use correct socket names Missing block registration: Verify blocks are registered in registry Configuration errors: Validate block configuration parameters Async handling: Properly await asynchronous operations