Actions Developer Guide

Technical guide for extending the Actions system with custom blocks

actionsdeveloperblocksworkflow

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:

1. Define Block Constants

First, define your block name constant in the shared package:

// packages/shared/src/constants.ts (or appropriate constants file)
export const BLOCK_TRANSFORM_DATA = "transform-data";
export const BLOCK_HTTP_REQUEST = "http-request"; 
export const BLOCK_DATABASE_QUERY = "database-query";

2. 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 BLOCK_TRANSFORM_DATA = "transform-data";

export async function transformDataBlockFn(
  socketName: string,
  data: any
): Promise<ActionBlockResult> {
  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"
      }]
    };
  }
}

3. 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 { BLOCK_SEND_MAIL, sendMailBlockFn } from "./blocks/send-mail.block";
import { BLOCK_TRANSFORM_DATA, transformDataBlockFn } from "./blocks/transform-data.block";

export function createServerBlockRegistry(): ActionBlockRegistry {
  const serverActionBlocksRegistry = new ActionBlockRegistry();
  
  // Existing blocks
  serverActionBlocksRegistry.registerBlock(BLOCK_SEND_MAIL, {
    execute: sendMailBlockFn
  });
  
  // Your new block
  serverActionBlocksRegistry.registerBlock(BLOCK_TRANSFORM_DATA, {
    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<string, string>;
  timeout?: number;
}

export async function httpRequestBlockFn(
  socketName: string,
  data: any,
  configuration?: HttpRequestConfig
): Promise<ActionBlockResult> {
  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<ActionBlockResult> {
  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<ActionBlockResult> {
  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(BLOCK_TRANSFORM_DATA, {
    execute: transformDataBlockFn
  });

  const actions: Action[] = [{
    triggers: [{ triggerName: "testTrigger", id: "trigger1" }],
    blocks: [{
      id: "block1",
      blockName: BLOCK_TRANSFORM_DATA,
      configuration: {}
    }],
    connections: [{
      sourceBlockId: "trigger1",
      sourceSocketName: TRIGGER_SOCKET_NAME,
      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

1. 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"
}

2. 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<Action[]> {
    // Implementation depends on your data layer
    // Return actions that have triggers matching the triggerName
  }
}

3. 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

1. 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

2. 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

3. Configuration

  • Validate configuration: Check required parameters early
  • Provide defaults: For optional configuration values
  • Document configuration: Clear parameter descriptions
  • Type configuration: Use TypeScript interfaces

4. 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

5. 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

1. Data Transformation Pipeline

Action with sequence:
Trigger → Transform Block → Validate Block → Save Block

2. Conditional Workflows

Action with branching:
Trigger → Condition Block → { Success Block | Error Block }

3. External Integration

Action with external calls:
Trigger → HTTP Block → Parse Block → Store Block

4. Notification Workflows

Action with notifications:
Trigger → Format Block → { Email Block | SMS Block | Slack Block }

Debugging Actions

1. Enable Detailed Logging

export async function debuggableBlockFn(
  socketName: string,
  data: any
): Promise<ActionBlockResult> {
  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
  };
}

2. 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

3. 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