Skip to main content

Automations

Learn how to build powerful event-driven automations in Stack9. This guide covers automation triggers, action chaining, conditional logic, error handling, and real-world implementation patterns from production systems.

What You'll Learn

  • Automation structure and configuration
  • All trigger types (entity lifecycle, webhooks, scheduled, queues)
  • Event-driven automation patterns
  • Conditional logic and action chaining
  • Template expressions and dynamic parameters
  • Error handling strategies
  • Testing and debugging automations
  • Real production examples

Time Required: 45-60 minutes

Prerequisites

  • Completed Building Custom Actions guide
  • Understanding of Action Types
  • Basic knowledge of JSON and template expressions
  • Familiarity with entity definitions

Understanding Stack9 Automations

Automations in Stack9 are configuration-driven workflows that respond to events and execute actions automatically. They consist of:

  1. Trigger - Event that starts the automation (afterCreate, webhook, cron, etc.)
  2. Actions - Sequence of operations to execute
  3. Conditional Actions - Actions that run only when conditions are met
  4. Parameters - Dynamic data passed using template expressions

Automations are defined as JSON files in src/automations/ and automatically registered when your Stack9 application starts.

Automation File Structure

Automations live in your project's automations directory:

src/
└── automations/
├── when_customer_created.json
├── after_wf_move_subscription.json
├── webhook_resend_receipt.json
├── sync_customer_data.json
└── trigger_send_deferral_reminder.json

Basic Automation Structure

Every automation follows this structure:

{
"key": "unique_automation_key",
"name": "Human-readable name",
"entityKey": "entity_name",
"app": "module_name",
"triggerType": "afterCreate",
"triggerParams": {},
"actions": [
{
"name": "Action description",
"key": "action_key",
"actionTypeKey": "action_type_to_execute",
"params": {
"param1": "{{trigger.entity.field}}",
"param2": "static_value"
}
}
],
"conditionalActions": []
}

Required Properties

PropertyTypeDescription
keystringUnique identifier (snake_case)
namestringDisplay name for the automation
entityKeystringEntity this automation relates to (optional for some triggers)
triggerTypestringType of trigger (see Trigger Types)
triggerParamsobjectConfiguration for the trigger
actionsarrayList of actions to execute
conditionalActionsarrayActions with conditions (optional)

Trigger Types

Stack9 supports several trigger types:

1. afterCreate - Entity Creation

Fires after a new entity record is created.

{
"key": "when_customer_created",
"name": "When Customer Created",
"entityKey": "customer",
"app": "crm",
"triggerType": "afterCreate",
"triggerParams": {},
"actions": [
{
"name": "Send welcome email",
"key": "send_welcome",
"actionTypeKey": "send_welcome_email",
"params": {
"customerId": "{{trigger.entity.id}}",
"email": "{{trigger.entity.email}}",
"firstName": "{{trigger.entity.first_name}}"
}
}
]
}

Available trigger data:

  • trigger.entity - The created entity record
  • trigger.entityId - The ID of the created entity
  • trigger.userId - User who created the record

2. afterUpdate - Entity Update

Fires after an entity record is updated.

{
"key": "when_subscription_updated",
"name": "When Subscription Updated",
"entityKey": "subscription",
"triggerType": "afterUpdate",
"triggerParams": {},
"actions": [
{
"name": "Add to queue subscription",
"key": "add_to_queue_subscription",
"actionTypeKey": "add_message_queue",
"params": {
"queueName": "index_subscription",
"message": "{{{subscriptionId: trigger.entity.id}}}",
"priority": "long"
}
},
{
"name": "After subscription updated",
"key": "after_subscription_updated",
"actionTypeKey": "after_subscription_updated",
"params": {
"entityId": "{{trigger.entity.id}}"
}
},
{
"name": "Check Subscription Condition",
"key": "check_subscription_condition",
"actionTypeKey": "check_subscription_workflow_conditions",
"params": {
"subscriptionId": "{{trigger.entity.id}}"
}
},
{
"name": "Update Subscription WF Condition",
"key": "update_subscription_wf_condition",
"actionTypeKey": "workflow_step_condition_upsert",
"params": {
"entityKey": "subscription",
"entityId": "{{runbook.outputs.check_subscription_condition.subscriptionId}}",
"conditions": "{{runbook.outputs.check_subscription_condition.conditions}}"
}
}
]
}

Available trigger data:

  • trigger.entity - The updated entity (new values)
  • trigger.oldEntity - The entity before update (original values)
  • trigger.entityId - The ID of the updated entity
  • trigger.userId - User who updated the record

Use case: Detect field changes, sync to external systems, update related records

3. afterDelete - Entity Deletion

Fires after an entity record is deleted.

{
"key": "when_customer_deleted",
"name": "When Customer Deleted",
"entityKey": "customer",
"triggerType": "afterDelete",
"triggerParams": {},
"actions": [
{
"name": "Clean up related data",
"key": "cleanup",
"actionTypeKey": "cleanup_customer_data",
"params": {
"customerId": "{{trigger.entityId}}"
}
}
]
}

4. afterWorkflowMove - Workflow State Change

Fires after an entity moves to a different workflow step.

{
"key": "after_wf_move_subscription",
"name": "After WF Move Subscription",
"entityKey": "subscription",
"triggerType": "afterWorkflowMove",
"triggerParams": {},
"actions": [
{
"name": "Update customer flags",
"key": "update_customer_flags",
"actionTypeKey": "update_customer_flags_from_subscription",
"params": {
"subscriptionId": "{{trigger.entityId}}"
}
},
{
"name": "Get subscription",
"key": "get_subscription",
"actionTypeKey": "entity_find",
"params": {
"entityKey": "subscription",
"query": "{{{$select: ['id', 'customer_id', '_workflow_outcome'], $where: {id: trigger.entityId}}}}"
}
}
],
"conditionalActions": [
{
"condition": {
"rules": [
{
"field": "{{trigger.actionKey}}",
"value": "reprocess_validation",
"operator": "equals"
}
],
"combinator": "or"
},
"actions": [
{
"name": "Execute validation step",
"key": "handle_step",
"actionTypeKey": "handle_subscription_validation_wf_step",
"params": {
"subscriptionId": "{{trigger.entityId}}"
}
}
]
},
{
"condition": {
"rules": [
{
"field": "{{trigger.actionKey}}",
"value": "pending_customer_match",
"operator": "equals"
},
{
"field": "{{trigger.actionKey}}",
"value": "reprocess_matching",
"operator": "equals"
}
],
"combinator": "or"
},
"actions": [
{
"name": "Execute customer match step",
"key": "handle_step",
"actionTypeKey": "handle_subscription_customer_match_wf_step",
"params": {
"subscriptionId": "{{trigger.entityId}}"
}
}
]
}
]
}

Available trigger data:

  • trigger.entityId - Entity ID
  • trigger.entity - Current entity state
  • trigger.actionKey - Workflow action that was taken
  • trigger.workflowData - Workflow transition details

Use case: Execute logic based on workflow steps, implement multi-stage processes

5. webhook - HTTP Endpoint

Creates an HTTP endpoint that triggers the automation.

{
"key": "webhook_resend_receipt",
"name": "Resend Receipt",
"entityKey": "sales_order",
"triggerType": "webhook",
"triggerParams": {
"method": "post",
"path": "/resend-receipt"
},
"actions": [
{
"name": "Resend receipt",
"key": "resend_receipt",
"actionTypeKey": "resend_receipt",
"params": {
"outbound": "{{trigger.body}}"
}
}
]
}

triggerParams for webhook:

  • method - HTTP method: "post", "get", "put", "all"
  • path - URL path (e.g., "/resend-receipt")

Available trigger data:

  • trigger.body - Request body (JSON)
  • trigger.query - Query parameters
  • trigger.params - URL parameters
  • trigger.headers - HTTP headers

Webhook URL: https://your-app.com/webhooks/{path}

Use case: External integrations, API endpoints, third-party webhooks

6. cronJob - Scheduled Execution

Runs on a schedule (requires registration in app.json).

{
"key": "scheduled_cleanup",
"name": "Daily Cleanup Job",
"triggerType": "cronJob",
"triggerParams": {
"cronExpression": "0 2 * * *",
"timeoutMs": 300000
},
"actions": [
{
"name": "Clean old records",
"key": "cleanup",
"actionTypeKey": "cleanup_old_records",
"params": {}
}
]
}

triggerParams for cronJob:

  • cronExpression - Cron schedule expression
  • timeoutMs - Maximum execution time in milliseconds

Cron schedule examples:

  • 0 * * * * - Every hour
  • 0 9 * * * - Every day at 9 AM
  • 0 9 * * 1 - Every Monday at 9 AM
  • */15 * * * * - Every 15 minutes
  • 0 0 1 * * - First day of every month

Use case: Batch processing, cleanup jobs, reports, synchronization

7. mqHandler - Message Queue Consumer

Processes messages from a queue.

{
"key": "sync_customer_data",
"name": "Sync Customer Data",
"entityKey": "customer",
"triggerType": "mqHandler",
"triggerParams": {
"queueName": "index_customer",
"timeoutMs": 5000
},
"actions": [
{
"name": "Sync customer data",
"key": "sync_customer_data",
"actionTypeKey": "sync_customer_data",
"params": {
"customerId": "{{trigger.message.body.customerId}}"
}
}
]
}

triggerParams for mqHandler:

  • queueName - Name of the queue to consume from
  • timeoutMs - Processing timeout in milliseconds

Available trigger data:

  • trigger.message.body - Message payload
  • trigger.automationKey - Automation key

Use case: Asynchronous processing, background jobs, scalable processing

Actions

Actions are the operations executed when an automation triggers. They run sequentially in the order defined.

Action Structure

{
"name": "Human-readable description",
"key": "unique_action_key",
"actionTypeKey": "action_type_to_execute",
"params": {
"param1": "{{dynamic_value}}",
"param2": "static_value"
}
}

Accessing Previous Action Results

Actions can reference outputs from previous actions:

{
"actions": [
{
"name": "Fetch customer",
"key": "fetch_customer",
"actionTypeKey": "entity_find",
"params": {
"entityKey": "customer",
"query": "{{{$where: {id: trigger.entity.customer_id}}}}"
}
},
{
"name": "Send email to customer",
"key": "send_email",
"actionTypeKey": "send_email",
"params": {
"to": "{{runbook.outputs.fetch_customer.email}}",
"subject": "Hello {{runbook.outputs.fetch_customer.first_name}}!"
}
}
]
}

Pattern: Use runbook.outputs.{action_key}.{field} to access action results.

Conditional Actions

Conditional actions only execute when specific conditions are met.

Simple Condition

{
"conditionalActions": [
{
"condition": {
"rules": [
{
"field": "{{trigger.entity.status}}",
"value": "active",
"operator": "equals"
}
],
"combinator": "and"
},
"actions": [
{
"name": "Send activation email",
"key": "send_activation",
"actionTypeKey": "send_email",
"params": {
"to": "{{trigger.entity.email}}"
}
}
]
}
]
}

Multiple Conditions (OR Logic)

{
"condition": {
"rules": [
{
"field": "{{trigger.entity.status}}",
"value": "pending",
"operator": "equals"
},
{
"field": "{{trigger.entity.status}}",
"value": "review",
"operator": "equals"
}
],
"combinator": "or"
},
"actions": [...]
}

Multiple Conditions (AND Logic)

{
"condition": {
"rules": [
{
"field": "{{trigger.entity.status}}",
"value": "active",
"operator": "equals"
},
{
"field": "{{trigger.entity.subscription_tier}}",
"value": "premium",
"operator": "equals"
}
],
"combinator": "and"
},
"actions": [...]
}

Supported Operators

  • equals - Exact match
  • notEquals - Not equal
  • contains - String contains substring
  • startsWith - String starts with
  • endsWith - String ends with
  • greaterThan - Numeric greater than
  • lessThan - Numeric less than
  • in - Value in array
  • changed - Field changed (afterUpdate only)

Template Expressions

Use double curly braces {{expression}} for dynamic values.

Trigger Context

{
"params": {
"entityId": "{{trigger.entity.id}}",
"email": "{{trigger.entity.email}}",
"oldStatus": "{{trigger.oldEntity.status}}",
"newStatus": "{{trigger.entity.status}}",
"userId": "{{trigger.userId}}",
"actionKey": "{{trigger.actionKey}}",
"webhookBody": "{{trigger.body.data}}",
"queryParam": "{{trigger.query.param_name}}"
}
}

Action Results

{
"params": {
"previousResult": "{{runbook.outputs.action_key.field_name}}",
"customerId": "{{runbook.outputs.fetch_customer.id}}",
"customerEmail": "{{runbook.outputs.fetch_customer.email}}"
}
}

Object Expressions

Use triple braces {{{object}}} for objects:

{
"params": {
"data": "{{{customer_id: trigger.entity.id, status: trigger.entity.status}}}"
}
}

Real Production Examples

Example 1: Customer Update Workflow

Full automation that handles customer updates with indexing and history tracking:

File: src/automations/when_customer_updated.json

{
"key": "when_customer_updated",
"name": "When Customer Updated",
"entityKey": "customer",
"triggerType": "afterUpdate",
"triggerParams": {},
"actions": [
{
"name": "Add to search index queue",
"key": "add_to_index",
"actionTypeKey": "add_message_queue",
"params": {
"queueName": "index_customer",
"message": "{{{customerId: trigger.entity.id}}}",
"priority": "high"
}
},
{
"name": "Check if email changed",
"key": "email_changed",
"actionTypeKey": "check_field_changed",
"params": {
"entity": "{{trigger.entity}}",
"oldEntity": "{{trigger.oldEntity}}",
"fieldName": "email"
}
},
{
"name": "Track history event",
"key": "track_history",
"actionTypeKey": "track_customer_event",
"params": {
"customerId": "{{trigger.entity.id}}",
"eventType": "customer_updated",
"changes": "{{{old: trigger.oldEntity, new: trigger.entity}}}"
}
}
],
"conditionalActions": [
{
"condition": {
"rules": [
{
"field": "{{runbook.outputs.email_changed.changed}}",
"value": "true",
"operator": "equals"
}
],
"combinator": "and"
},
"actions": [
{
"name": "Send email verification",
"key": "send_verification",
"actionTypeKey": "send_email_verification",
"params": {
"customerId": "{{trigger.entity.id}}",
"newEmail": "{{trigger.entity.email}}"
}
}
]
}
]
}

Example 2: Webhook Processing with Queue

Webhook that triggers async processing:

File: src/automations/trigger_send_deferral_reminder.json

{
"key": "trigger_send_deferral_reminder",
"name": "Trigger Send Deferral Reminder",
"entityKey": "subscription",
"triggerType": "webhook",
"triggerParams": {
"method": "post",
"path": "/trigger-send-deferral-reminder"
},
"actions": [
{
"name": "Add to queue",
"key": "add_to_queue",
"actionTypeKey": "add_message_queue",
"params": {
"queueName": "execute_send_deferral_reminder",
"message": "{{{}}}",
"priority": "long"
}
}
]
}

Example 3: Complex Workflow Move Handler

Handles workflow transitions with multiple conditional paths:

File: src/automations/after_wf_move_sales_order.json

{
"key": "after_wf_move_sales_order",
"name": "After WF Move Sales Order",
"entityKey": "sales_order",
"triggerType": "afterWorkflowMove",
"triggerParams": {},
"actions": [
{
"name": "Get sales order",
"key": "get_order",
"actionTypeKey": "entity_find",
"params": {
"entityKey": "sales_order",
"query": "{{{$where: {id: trigger.entityId}}}}"
}
},
{
"name": "Update index",
"key": "update_index",
"actionTypeKey": "add_message_queue",
"params": {
"queueName": "index_sales_order",
"message": "{{{salesOrderId: trigger.entityId}}}"
}
}
],
"conditionalActions": [
{
"condition": {
"rules": [
{
"field": "{{trigger.actionKey}}",
"value": "sendForPayment",
"operator": "equals"
}
],
"combinator": "and"
},
"actions": [
{
"name": "Process payment",
"key": "process_payment",
"actionTypeKey": "handle_sales_order_payment_wf_step",
"params": {
"salesOrderId": "{{trigger.entityId}}"
}
}
]
},
{
"condition": {
"rules": [
{
"field": "{{trigger.actionKey}}",
"value": "sendForReceipt",
"operator": "equals"
},
{
"field": "{{trigger.actionKey}}",
"value": "sendForPendingReceipt",
"operator": "equals"
}
],
"combinator": "or"
},
"actions": [
{
"name": "Generate receipt",
"key": "generate_receipt",
"actionTypeKey": "handle_sales_order_receipt_wf_step",
"params": {
"salesOrderId": "{{trigger.entityId}}"
}
}
]
},
{
"condition": {
"rules": [
{
"field": "{{trigger.actionKey}}",
"value": "success",
"operator": "equals"
}
],
"combinator": "and"
},
"actions": [
{
"name": "Handle success outcome",
"key": "handle_success",
"actionTypeKey": "handle_sales_order_success_wf_outcome",
"params": {
"salesOrderId": "{{trigger.entityId}}"
}
}
]
}
]
}

Example 4: Queue Handler with Timeout

Message queue processor with timeout:

File: src/automations/execute_check_bank_run.json

{
"key": "execute_check_bank_run",
"name": "Execute Check Bank Run",
"entityKey": "bank_payment_file",
"app": "fin",
"triggerType": "mqHandler",
"triggerParams": {
"queueName": "execute_check_bank_run",
"timeoutMs": 30000
},
"actions": [
{
"name": "Execute check bank run",
"key": "execute_check_bank_run",
"actionTypeKey": "execute_check_bank_run",
"params": {}
}
]
}

Example 5: Workflow Move with Queue

Trigger workflow transitions via queue:

File: src/automations/move_wf_subscription.json

{
"key": "move_wf_subscription",
"name": "Move WF Subscription",
"entityKey": "subscription",
"app": "crm",
"triggerType": "mqHandler",
"triggerParams": {
"queueName": "move_wf_subscription",
"timeoutMs": 30000
},
"actions": [
{
"name": "Handle workflow action",
"key": "handle_action",
"actionTypeKey": "workflow_move",
"params": {
"entityKey": "subscription",
"entityId": "{{trigger.message.body.entityId}}",
"actionKey": "{{trigger.message.body.actionKey}}",
"body": "{{{}}}"
}
}
]
}

Error Handling

Strategy 1: Graceful Degradation

Continue automation even if non-critical actions fail:

// In your action type
export class SyncToCRM extends S9AutomationActionType {
async exec(params: any) {
try {
// Attempt sync
await externalApi.sync(params);
return { success: true };
} catch (error) {
// Log error but don't fail automation
this.context.logger.error(`CRM sync failed: ${error.message}`);
return {
success: false,
skipped: true,
error: error.message
};
}
}
}

Strategy 2: Retry Logic

Implement retry for transient failures:

export class RetryableAction extends S9AutomationActionType {
async exec(params: any) {
const maxRetries = 3;
let lastError;

for (let i = 0; i < maxRetries; i++) {
try {
return await this.executeOperation(params);
} catch (error) {
lastError = error;
this.context.logger.warn(`Attempt ${i + 1} failed: ${error.message}`);
await this.delay(1000 * Math.pow(2, i)); // Exponential backoff
}
}

throw lastError;
}
}

Strategy 3: Dead Letter Queue

Move failed messages to error queue:

{
"actions": [
{
"name": "Process message",
"key": "process",
"actionTypeKey": "process_with_error_handling",
"params": {
"data": "{{trigger.message.body}}",
"errorQueue": "failed_messages"
}
}
]
}

Testing Automations

Test Entity Lifecycle Triggers

# Test afterCreate
curl -X POST http://localhost:3000/api/customer \
-H "Content-Type: application/json" \
-d '{
"first_name": "Test",
"last_name": "User",
"email": "test@example.com"
}'

# Test afterUpdate
curl -X PUT http://localhost:3000/api/customer/1 \
-H "Content-Type: application/json" \
-d '{
"status": "active"
}'

Test Webhooks

curl -X POST http://localhost:3000/webhooks/resend-receipt \
-H "Content-Type: application/json" \
-d '{
"salesOrderId": 123,
"email": "customer@example.com"
}'

Test Queue Messages

// Programmatically add message to queue
await messageQueue.add('index_customer', {
customerId: 123
});

Monitor Logs

npm run dev

# Watch for automation execution logs:
# [INFO] Automation 'when_customer_created' triggered
# [INFO] Action 'send_welcome' completed successfully

Best Practices

1. Use Descriptive Names

// Good
{
"key": "when_customer_verified",
"name": "When Customer Email Verified",
"actions": [
{
"name": "Send welcome email to verified customer",
"key": "send_welcome_email"
}
]
}

// Bad
{
"key": "auto1",
"name": "automation",
"actions": [
{
"name": "action",
"key": "a1"
}
]
}

2. Keep Actions Atomic

Each action should do one thing:

// Good - separate concerns
{
"actions": [
{
"name": "Validate order",
"key": "validate",
"actionTypeKey": "validate_order"
},
{
"name": "Process payment",
"key": "payment",
"actionTypeKey": "process_payment"
},
{
"name": "Send confirmation",
"key": "confirm",
"actionTypeKey": "send_confirmation"
}
]
}

// Bad - action does too much
{
"actions": [
{
"name": "Process everything",
"key": "process_all",
"actionTypeKey": "process_order_payment_and_send_email"
}
]
}

3. Use Queues for Heavy Operations

{
"triggerType": "afterCreate",
"actions": [
{
"name": "Queue heavy processing",
"key": "queue_processing",
"actionTypeKey": "add_message_queue",
"params": {
"queueName": "heavy_processing",
"message": "{{{entityId: trigger.entity.id}}}"
}
}
]
}

4. Log Extensively

export class MyAction extends S9AutomationActionType {
async exec(params: any) {
const { logger } = this.context;

logger.info(`Starting process for ID: ${params.id}`);

try {
const result = await this.process(params);
logger.info(`Process completed: ${result.status}`);
return result;
} catch (error) {
logger.error(`Process failed: ${error.message}`, { params, error });
throw error;
}
}
}

5. Validate Trigger Data

export class SafeAction extends S9AutomationActionType {
async exec(params: any) {
// Validate required parameters
if (!params.customerId) {
throw new Error('customerId is required');
}

// Check entity exists
const customer = await this.context.db('customer')
.where({ id: params.customerId })
.first();

if (!customer) {
throw new Error(`Customer ${params.customerId} not found`);
}

// Process...
}
}

Troubleshooting

Automation Not Triggering

Problem: Automation doesn't run when expected

Solutions:

  1. Check automation file is in src/automations/
  2. Verify triggerType matches event
  3. Check entityKey matches entity
  4. Restart Stack9: npm run dev
  5. Look for errors in logs

Template Expression Not Working

Problem: {{trigger.entity.field}} not replaced

Solutions:

  1. Verify field name is correct
  2. Check entity has that field
  3. Use correct trigger context
  4. Test with simpler expression first

Action Not Executing

Problem: Action in sequence doesn't run

Solutions:

  1. Check actionTypeKey is registered
  2. Verify previous actions succeeded
  3. Check conditional logic
  4. Review action logs for errors

Conditional Actions Not Running

Problem: Conditions not evaluating correctly

Solutions:

  1. Verify field values match exactly
  2. Check operator is correct
  3. Use combinator: "or" vs "and" correctly
  4. Log values for debugging

Performance Considerations

1. Avoid N+1 Queries

// Bad - N queries
for (const item of items) {
const related = await db('related').where({ id: item.related_id });
}

// Good - 1 query
const relatedIds = items.map(i => i.related_id);
const related = await db('related').whereIn('id', relatedIds);

2. Use Batch Processing

const BATCH_SIZE = 100;
const records = await db('entity').select();

for (let i = 0; i < records.length; i += BATCH_SIZE) {
const batch = records.slice(i, i + BATCH_SIZE);
await this.processBatch(batch);
}

3. Set Appropriate Timeouts

{
"triggerParams": {
"timeoutMs": 30000 // 30 seconds for long operations
}
}

Next Steps

Congratulations! You've mastered Stack9 automations. Continue learning:

Summary

You now understand:

  • Automation structure and configuration
  • All trigger types (afterCreate, afterUpdate, webhook, cronJob, mqHandler, etc.)
  • Action sequencing and chaining
  • Conditional actions and logic
  • Template expressions for dynamic parameters
  • Error handling strategies
  • Testing and debugging approaches
  • Real production patterns

Automations are the backbone of event-driven applications in Stack9. Master them to build reactive, scalable systems!