Actions Developer Guide
Technical guide for extending the Actions system with custom blocks
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