Files
attune/docs/action-development-guide.md

27 KiB

Action Development Guide

Complete guide for developing actions in Attune

Table of Contents

  1. Introduction
  2. Action Anatomy
  3. Parameter Configuration
  4. Output Configuration
  5. Standard Environment Variables
  6. Runtime Configuration
  7. Complete Examples
  8. Best Practices
  9. Troubleshooting

Introduction

Actions are the fundamental building blocks of automation in Attune. Each action is a script or program that performs a specific task, receives parameters, and returns results. This guide covers everything you need to know to write effective actions.

What You'll Learn

  • How to configure parameter delivery methods (stdin, file)
  • How to format parameters (JSON, YAML, dotenv)
  • How to specify output formats for structured data
  • What environment variables are available to your actions
  • How to write actions for different runtimes (Shell, Python, Node.js)
  • Best practices for security and reliability

Action Anatomy

Every action consists of two files:

  1. Metadata file (actions/<action_name>.yaml) - Describes the action
  2. Implementation file (actions/<action_name>.<ext>) - Executes the logic

Metadata File Structure

ref: mypack.my_action
label: "My Action"
description: "Action description"
enabled: true

# Runtime configuration
runner_type: shell              # Runtime to use (shell, python, nodejs, etc.)
entry_point: my_action.sh       # Script to execute

# Parameter configuration (how parameters are delivered)
parameter_delivery: stdin       # Options: stdin (default), file
parameter_format: json          # Options: json (default), yaml, dotenv

# Output configuration (how output is parsed)
output_format: json             # Options: text (default), json, yaml, jsonl

# Parameter schema (JSON Schema format)
# Note: 'required' is an array of required property names
# If no properties are required, omit the 'required' field entirely
parameters:
  type: object
  properties:
    message:
      type: string
      description: "Message to process"
    count:
      type: integer
      description: "Number of times to repeat"
      default: 1
  required:
    - message

# Output schema (documents expected JSON structure)
output_schema:
  type: object
  properties:
    result:
      type: string
      description: "Processed result"
    success:
      type: boolean
      description: "Whether operation succeeded"

tags:
  - utility

Parameter Configuration

Parameters are the inputs to your action. Attune provides flexible configuration for how parameters are delivered and formatted.

Parameter Delivery Methods

Parameters are passed via standard input. This is the most secure method as parameters don't appear in process listings.

parameter_delivery: stdin
parameter_format: json

Reading stdin parameters:

The worker writes parameters to stdin with a delimiter:

<formatted_parameters>
---ATTUNE_PARAMS_END---
<secrets_json>
  • Parameters come first in your chosen format
  • Delimiter ---ATTUNE_PARAMS_END--- separates parameters from secrets
  • Secrets follow as JSON (if any)

2. File Delivery

Parameters are written to a temporary file with restrictive permissions (owner read-only, 0400).

parameter_delivery: file
parameter_format: yaml

Reading file parameters:

The file path is provided in the ATTUNE_PARAMETER_FILE environment variable:

# Shell example
PARAM_FILE="$ATTUNE_PARAMETER_FILE"
params=$(cat "$PARAM_FILE")

Parameter Formats

1. JSON Format (Default)

Standard JSON object format.

parameter_format: json

Example output:

{
  "message": "Hello, World!",
  "count": 42,
  "enabled": true
}

Reading JSON (Shell with jq):

#!/bin/bash
set -e

# Read JSON from stdin
read -r -d '' PARAMS_JSON || true
MESSAGE=$(echo "$PARAMS_JSON" | jq -r '.message')
COUNT=$(echo "$PARAMS_JSON" | jq -r '.count // 1')

Reading JSON (Python):

#!/usr/bin/env python3
import json
import sys

# Read until delimiter
content = sys.stdin.read()
parts = content.split('---ATTUNE_PARAMS_END---')
params = json.loads(parts[0].strip()) if parts[0].strip() else {}

message = params.get('message', '')
count = params.get('count', 1)

2. YAML Format

YAML format, useful for complex nested structures.

parameter_format: yaml

Example output:

message: Hello, World!
count: 42
enabled: true
nested:
  key: value

Reading YAML (Python):

#!/usr/bin/env python3
import sys
import yaml

content = sys.stdin.read()
parts = content.split('---ATTUNE_PARAMS_END---')
params = yaml.safe_load(parts[0].strip()) if parts[0].strip() else {}

message = params.get('message', '')

3. Dotenv Format

Simple key-value pairs, one per line. Best for shell scripts with simple parameters.

parameter_format: dotenv

Example output:

message='Hello, World!'
count='42'
enabled='true'

Reading dotenv (Shell):

#!/bin/sh
set -e

# Initialize variables
message=""
count=""

# Read until delimiter
while IFS= read -r line; do
    case "$line" in
        *"---ATTUNE_PARAMS_END---"*) break ;;
        message=*) 
            message="${line#message=}"
            # Remove quotes
            message="${message#[\"']}"
            message="${message%[\"']}"
            ;;
        count=*)
            count="${line#count=}"
            count="${count#[\"']}"
            count="${count%[\"']}"
            ;;
    esac
done

echo "Message: $message"
echo "Count: $count"

Example with no required parameters:

# All parameters are optional
parameters:
  type: object
  properties:
    message:
      type: string
      description: "Optional message to log"
      default: "Hello"
    verbose:
      type: boolean
      description: "Enable verbose logging"
      default: false
  # Note: No 'required' field - all parameters are optional

Security Considerations

  • Never use environment variables for secrets - Environment variables are visible in process listings (ps aux)
  • Always use stdin or file delivery for sensitive data - These methods are not visible to other processes
  • Secrets are always passed via stdin - Even if parameters use file delivery, secrets come through stdin after the delimiter
  • Parameter files have restrictive permissions - 0400 (owner read-only)

Output Configuration

Configure how your action's output is parsed and stored in the execution result.

Output Formats

1. Text Format (Default)

No parsing - output is captured as plain text in execution.stdout.

output_format: text

Use case: Simple actions that output messages, logs, or unstructured text.

Example:

#!/bin/bash
echo "Task completed successfully"
echo "Processed 42 items"
exit 0

Result:

  • execution.stdout: Full text output
  • execution.result: null (no parsing)

2. JSON Format

Parses the last line of stdout as JSON and stores it in execution.result.

output_format: json

Use case: Actions that return structured data, API responses, or computed results.

Example:

#!/bin/bash
# Your action logic here
curl -s https://api.example.com/data

# Output JSON as last line (curl already outputs JSON)
# The worker will parse this into execution.result
exit 0

Example (manual JSON):

#!/usr/bin/env python3
import json

result = {
    "status": "success",
    "items_processed": 42,
    "duration_ms": 1234
}

# Output JSON - will be parsed into execution.result
print(json.dumps(result, indent=2))

Result:

  • execution.stdout: Full output including JSON
  • execution.result: Parsed JSON object from last line

3. YAML Format

Parses the entire stdout as YAML.

output_format: yaml

Use case: Actions that generate YAML configuration or reports.

Example:

#!/usr/bin/env python3
import yaml

result = {
    "status": "success",
    "config": {
        "enabled": True,
        "timeout": 30
    }
}

print(yaml.dump(result))

4. JSONL Format (JSON Lines)

Parses each line of stdout as a separate JSON object and collects them into an array.

output_format: jsonl

Use case: Streaming results, processing multiple items, progress updates.

Example:

#!/usr/bin/env python3
import json

# Process items and output each as JSON
for i in range(5):
    item = {"id": i, "status": "processed"}
    print(json.dumps(item))  # Each line is valid JSON

Result:

  • execution.result: Array of parsed JSON objects

Standard Environment Variables

The worker provides these environment variables to all action executions:

Core Variables (Always Present)

Variable Description Example
ATTUNE_EXEC_ID Execution database ID 12345
ATTUNE_ACTION Action reference (pack.action) core.echo
ATTUNE_API_URL Attune API base URL http://api:8080
ATTUNE_API_TOKEN Execution-scoped API token eyJ0eXAi...

Contextual Variables (When Applicable)

Variable Description Present When
ATTUNE_RULE Rule reference that triggered execution Execution triggered by rule
ATTUNE_TRIGGER Trigger reference that fired Execution triggered by event
ATTUNE_CONTEXT_* Custom context data Context provided in execution config

Parameter Delivery Variables

Variable Description Present When
ATTUNE_PARAMETER_DELIVERY Delivery method used Always (stdin or file)
ATTUNE_PARAMETER_FORMAT Format used Always (json, yaml, or dotenv)
ATTUNE_PARAMETER_FILE Path to parameter file parameter_delivery: file

Using Environment Variables

Shell:

#!/bin/bash
set -e

echo "Execution ID: $ATTUNE_EXEC_ID"
echo "Action: $ATTUNE_ACTION"
echo "API URL: $ATTUNE_API_URL"

# Check if triggered by rule
if [ -n "$ATTUNE_RULE" ]; then
    echo "Triggered by rule: $ATTUNE_RULE"
    echo "Trigger: $ATTUNE_TRIGGER"
fi

# Use API token for authenticated requests
curl -H "Authorization: Bearer $ATTUNE_API_TOKEN" \
     "$ATTUNE_API_URL/api/executions/$ATTUNE_EXEC_ID"

Python:

#!/usr/bin/env python3
import os
import requests

exec_id = os.environ['ATTUNE_EXEC_ID']
action_ref = os.environ['ATTUNE_ACTION']
api_url = os.environ['ATTUNE_API_URL']
api_token = os.environ['ATTUNE_API_TOKEN']

print(f"Execution {exec_id} running action {action_ref}")

# Make authenticated API request
headers = {'Authorization': f'Bearer {api_token}'}
response = requests.get(f"{api_url}/api/executions/{exec_id}", headers=headers)
print(response.json())

Custom Environment Variables

You can also set custom environment variables per execution via the env_vars field:

{
  "action_ref": "core.my_action",
  "parameters": {
    "message": "Hello"
  },
  "env_vars": {
    "DEBUG": "true",
    "LOG_LEVEL": "verbose"
  }
}

Note: These are separate from parameters and passed as actual environment variables.


Runtime Configuration

Configure which runtime executes your action.

Available Runtimes

Runtime runner_type Description Entry Point
Shell shell POSIX shell scripts Script file (.sh)
Python python Python 3.x scripts Script file (.py)
Node.js nodejs JavaScript/Node.js Script file (.js)
Native native Compiled binaries Binary file
Local local Local system commands Command name

Shell Runtime

Execute shell scripts using /bin/sh (or configurable shell).

runner_type: shell
entry_point: my_script.sh

Script requirements:

  • Must be executable or have a shebang (#!/bin/sh)
  • Exit code 0 indicates success
  • Output to stdout for results
  • Errors to stderr

Example:

#!/bin/sh
set -e  # Exit on error

# Read parameters from stdin
content=$(cat)
params=$(echo "$content" | head -n 1)

# Process
echo "Processing: $params"

# Output result
echo '{"status": "success"}'
exit 0

Python Runtime

Execute Python scripts with automatic virtual environment management.

runner_type: python
entry_point: my_script.py

Features:

  • Automatic virtual environment creation
  • Dependency installation from requirements.txt
  • Python 3.x support
  • Access to all standard libraries

Example:

#!/usr/bin/env python3
import json
import sys

def main():
    # Read parameters
    content = sys.stdin.read()
    parts = content.split('---ATTUNE_PARAMS_END---')
    params = json.loads(parts[0].strip()) if parts[0].strip() else {}
    
    # Process
    message = params.get('message', '')
    result = message.upper()
    
    # Output
    print(json.dumps({
        'result': result,
        'success': True
    }))
    return 0

if __name__ == '__main__':
    sys.exit(main())

Dependencies (optional requirements.txt in action directory):

requests>=2.28.0
pyyaml>=6.0

Node.js Runtime

Execute JavaScript with Node.js.

runner_type: nodejs
entry_point: my_script.js

Example:

#!/usr/bin/env node
const readline = require('readline');

async function main() {
    // Read stdin
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
        terminal: false
    });

    let input = '';
    for await (const line of rl) {
        if (line.includes('---ATTUNE_PARAMS_END---')) break;
        input += line;
    }

    const params = JSON.parse(input || '{}');
    
    // Process
    const result = {
        message: params.message.toUpperCase(),
        success: true
    };

    // Output
    console.log(JSON.stringify(result, null, 2));
}

main().catch(err => {
    console.error(err);
    process.exit(1);
});

Native Runtime

Execute compiled binaries (sensors, performance-critical actions).

runner_type: native
entry_point: my_binary

Use case: Compiled sensors, performance-critical operations.

Requirements:

  • Binary must be executable
  • Built for target architecture (see scripts/build-pack-binaries.sh)
  • Follow same stdin/stdout conventions

Complete Examples

Example 1: Simple Text Action

Metadata (actions/greet.yaml):

ref: mypack.greet
label: "Greet User"
description: "Greet a user by name"
runner_type: shell
entry_point: greet.sh
parameter_delivery: stdin
parameter_format: json
output_format: text

parameters:
  type: object
  properties:
    name:
      type: string
      description: "Name to greet"
    formal:
      type: boolean
      description: "Use formal greeting"
      default: false
  required:
    - name

Implementation (actions/greet.sh):

#!/bin/bash
set -e

# Read JSON parameters
read -r -d '' PARAMS_JSON || true
NAME=$(echo "$PARAMS_JSON" | jq -r '.name')
FORMAL=$(echo "$PARAMS_JSON" | jq -r '.formal // false')

# Generate greeting
if [ "$FORMAL" = "true" ]; then
    echo "Good day, $NAME. It is a pleasure to meet you."
else
    echo "Hey $NAME! What's up?"
fi

exit 0

Example 2: HTTP API Action with JSON Output

Metadata (actions/fetch_user.yaml):

ref: mypack.fetch_user
label: "Fetch User"
description: "Fetch user data from API"
runner_type: shell
entry_point: fetch_user.sh
parameter_delivery: stdin
parameter_format: json
output_format: json

parameters:
  type: object
  properties:
    user_id:
      type: integer
      description: "User ID to fetch"
    include_posts:
      type: boolean
      description: "Include user posts"
      default: false
  required:
    - user_id

output_schema:
  type: object
  properties:
    user:
      type: object
      description: "User data"
    posts:
      type: array
      description: "User posts (if requested)"
    success:
      type: boolean

Implementation (actions/fetch_user.sh):

#!/bin/bash
set -e

# Read parameters
read -r -d '' PARAMS_JSON || true
USER_ID=$(echo "$PARAMS_JSON" | jq -r '.user_id')
INCLUDE_POSTS=$(echo "$PARAMS_JSON" | jq -r '.include_posts // false')

# Fetch user
USER_DATA=$(curl -s "https://jsonplaceholder.typicode.com/users/$USER_ID")

# Build result
RESULT="{\"user\": $USER_DATA"

# Optionally fetch posts
if [ "$INCLUDE_POSTS" = "true" ]; then
    POSTS=$(curl -s "https://jsonplaceholder.typicode.com/users/$USER_ID/posts")
    RESULT="$RESULT, \"posts\": $POSTS"
fi

RESULT="$RESULT, \"success\": true}"

# Output JSON (will be parsed into execution.result)
echo "$RESULT"
exit 0

Example 3: Python Action with Secrets

Metadata (actions/send_email.yaml):

ref: mypack.send_email
label: "Send Email"
description: "Send email via SMTP"
runner_type: python
entry_point: send_email.py
parameter_delivery: stdin
parameter_format: json
output_format: json

parameters:
  type: object
  properties:
    to:
      type: string
      description: "Recipient email"
    subject:
      type: string
      description: "Email subject"
    body:
      type: string
      description: "Email body"
    smtp_password:
      type: string
      description: "SMTP password"
      secret: true
  required:
    - to
    - subject
    - body

Implementation (actions/send_email.py):

#!/usr/bin/env python3
import json
import sys
import smtplib
from email.mime.text import MIMEText

def read_stdin_params():
    """Read parameters and secrets from stdin."""
    content = sys.stdin.read()
    parts = content.split('---ATTUNE_PARAMS_END---')
    
    params = json.loads(parts[0].strip()) if parts[0].strip() else {}
    secrets = json.loads(parts[1].strip()) if len(parts) > 1 and parts[1].strip() else {}
    
    # Merge secrets into params
    return {**params, **secrets}

def main():
    try:
        params = read_stdin_params()
        
        # Extract parameters
        to = params['to']
        subject = params['subject']
        body = params['body']
        smtp_password = params.get('smtp_password', '')
        
        # Create message
        msg = MIMEText(body)
        msg['Subject'] = subject
        msg['From'] = 'noreply@example.com'
        msg['To'] = to
        
        # Send (example - configure for your SMTP server)
        # with smtplib.SMTP('smtp.example.com', 587) as server:
        #     server.starttls()
        #     server.login('user', smtp_password)
        #     server.send_message(msg)
        
        # Simulate success
        result = {
            'success': True,
            'to': to,
            'subject': subject,
            'message': 'Email sent successfully'
        }
        
        print(json.dumps(result, indent=2))
        return 0
        
    except Exception as e:
        result = {
            'success': False,
            'error': str(e)
        }
        print(json.dumps(result, indent=2))
        return 1

if __name__ == '__main__':
    sys.exit(main())

Example 4: Multi-Item Processing with JSONL

Metadata (actions/process_items.yaml):

ref: mypack.process_items
label: "Process Items"
description: "Process multiple items and stream results"
runner_type: python
entry_point: process_items.py
parameter_delivery: stdin
parameter_format: json
output_format: jsonl

parameters:
  type: object
  properties:
    items:
      type: array
      items:
        type: string
      description: "Items to process"
  required:
    - items

Implementation (actions/process_items.py):

#!/usr/bin/env python3
import json
import sys
import time

def main():
    # Read parameters
    content = sys.stdin.read()
    parts = content.split('---ATTUNE_PARAMS_END---')
    params = json.loads(parts[0].strip()) if parts[0].strip() else {}
    
    items = params.get('items', [])
    
    # Process each item and output immediately (streaming)
    for idx, item in enumerate(items):
        # Simulate processing
        time.sleep(0.1)
        
        # Output one JSON object per line
        result = {
            'index': idx,
            'item': item,
            'processed': item.upper(),
            'timestamp': time.time()
        }
        print(json.dumps(result))  # Each line is valid JSON
        sys.stdout.flush()  # Ensure immediate output
    
    return 0

if __name__ == '__main__':
    sys.exit(main())

Example 5: Shell Action with Dotenv Parameters

Metadata (actions/backup.yaml):

ref: mypack.backup
label: "Backup Files"
description: "Backup files to destination"
runner_type: shell
entry_point: backup.sh
parameter_delivery: stdin
parameter_format: dotenv
output_format: text

parameters:
  type: object
  properties:
    source:
      type: string
      description: "Source directory"
    destination:
      type: string
      description: "Backup destination"
    compress:
      type: boolean
      description: "Compress backup"
      default: true
  required:
    - source
    - destination

Implementation (actions/backup.sh):

#!/bin/sh
set -e

# Initialize variables
source=""
destination=""
compress="true"

# Read dotenv format from stdin
while IFS= read -r line; do
    case "$line" in
        *"---ATTUNE_PARAMS_END---"*) break ;;
        source=*)
            source="${line#source=}"
            source="${source#[\"\']}"
            source="${source%[\"\']}"
            ;;
        destination=*)
            destination="${line#destination=}"
            destination="${destination#[\"\']}"
            destination="${destination%[\"\']}"
            ;;
        compress=*)
            compress="${line#compress=}"
            compress="${compress#[\"\']}"
            compress="${compress%[\"\']}"
            ;;
    esac
done

echo "Backing up $source to $destination"

# Perform backup
if [ "$compress" = "true" ]; then
    tar -czf "$destination/backup.tar.gz" -C "$source" .
    echo "Compressed backup created at $destination/backup.tar.gz"
else
    cp -r "$source" "$destination"
    echo "Backup copied to $destination"
fi

exit 0

Best Practices

Security

  1. Never log secrets - Don't echo parameters that might contain secrets
  2. Use stdin for sensitive data - Avoid file delivery for credentials
  3. Validate inputs - Check parameters before using them
  4. Use API token - Authenticate API requests with $ATTUNE_API_TOKEN
  5. Restrict file permissions - Keep temporary files secure

Reliability

  1. Exit codes matter - Return 0 for success, non-zero for failure
  2. Handle errors gracefully - Use set -e in shell scripts
  3. Provide meaningful errors - Write error details to stderr
  4. Set timeouts - Avoid infinite loops or hangs
  5. Clean up resources - Remove temporary files

Output

  1. Structure output properly - Match your output_format setting
  2. Validate JSON - Ensure JSON output is valid
  3. Use stderr for logs - Reserve stdout for results
  4. Keep output concise - Large outputs are truncated (10MB default)
  5. Document output schema - Define output_schema in metadata

Performance

  1. Minimize dependencies - Fewer dependencies = faster execution
  2. Use appropriate runtime - Shell for simple tasks, Python for complex logic
  3. Stream large results - Use JSONL for incremental output
  4. Avoid unnecessary work - Check prerequisites early

Maintainability

  1. Document parameters - Provide clear descriptions
  2. Provide examples - Include usage examples in metadata
  3. Version your actions - Use pack versioning
  4. Test thoroughly - Test with various parameter combinations
  5. Handle edge cases - Empty inputs, missing optional parameters

Troubleshooting

My action isn't receiving parameters

Check:

  • Is parameter_delivery set correctly?
  • Are you reading from stdin or checking $ATTUNE_PARAMETER_FILE?
  • Are you reading until the delimiter ---ATTUNE_PARAMS_END---?

Debug:

# Dump stdin to stderr for debugging
cat > /tmp/debug_stdin.txt
cat /tmp/debug_stdin.txt >&2

My JSON output isn't being parsed

Check:

  • Is output_format: json set in metadata?
  • Is the last line of stdout valid JSON?
  • Are you outputting anything else to stdout?

Debug:

import json
result = {"test": "value"}
print(json.dumps(result))  # Ensure this is last line

My action times out

Check:

  • Default timeout is 5 minutes (300 seconds)
  • Is your action hanging or waiting for input?
  • Are you flushing output buffers?

Fix:

print(result)
sys.stdout.flush()  # Ensure output is written immediately

Secrets aren't available

Check:

  • Are secrets configured for the action?
  • Are you reading past the delimiter in stdin?
  • Secrets come as JSON after ---ATTUNE_PARAMS_END---

Example:

content = sys.stdin.read()
parts = content.split('---ATTUNE_PARAMS_END---')
params = json.loads(parts[0].strip()) if parts[0].strip() else {}
secrets = json.loads(parts[1].strip()) if len(parts) > 1 else {}

Environment variables are missing

Check:

  • Standard variables (ATTUNE_EXEC_ID, etc.) are always present
  • Context variables (ATTUNE_RULE, etc.) are conditional
  • Custom env_vars must be set in execution config

Debug:

env | grep ATTUNE >&2  # Print all ATTUNE_* variables to stderr

Output is truncated

Cause: Default limit is 10MB for stdout/stderr

Solution:

  • Reduce output size
  • Use artifacts for large data
  • Stream results using JSONL format

Additional Resources


Quick Reference Card

Configuration Options Default Description
runner_type shell, python, nodejs, native, local Required Runtime to execute action
parameter_delivery stdin, file stdin How parameters are delivered
parameter_format json, yaml, dotenv json Format for parameter serialization
output_format text, json, yaml, jsonl text How to parse stdout output

Standard Environment Variables

  • ATTUNE_EXEC_ID - Execution database ID
  • ATTUNE_ACTION - Action reference (pack.action)
  • ATTUNE_API_URL - API base URL
  • ATTUNE_API_TOKEN - Execution-scoped token
  • ATTUNE_RULE - Rule ref (if triggered by rule)
  • ATTUNE_TRIGGER - Trigger ref (if triggered by event)

Exit Codes

  • 0 - Success
  • Non-zero - Failure (error message from stderr)

Output Parsing

  • Text: No parsing, captured in execution.stdout
  • JSON: Last line parsed into execution.result
  • YAML: Full output parsed into execution.result
  • JSONL: Each line parsed, collected into array in execution.result