This guide walks through the full lifecycle of creating a custom action for the S4E platform, from initial planning to production deployment.

Planning the Action

Before writing any code, answer these questions:

  1. Purpose -- What security operation does this action perform? (e.g., block an IP, enrich an alert, send a notification)
  2. Inputs -- What data does the action need? Define each parameter, its type, and whether it is required.
  3. Outputs -- What data does the action produce? Define the result structure so downstream playbook steps can consume it.
  4. Executor -- Where does the logic run? Choose between a local script, a remote HTTP endpoint, or an Opservant agent.
  5. Error modes -- What can go wrong? Plan for network timeouts, authentication failures, and invalid input.

Tip

Keep actions small and single-purpose. An action that does one thing well is easier to test, debug, and reuse across multiple playbooks.

Step 1: Create the Action Definition

The action definition is a JSON document conforming to the action schema. See the Schema Reference for the full specification.

{
  "name": "block-ip",
  "version": "1.0.0",
  "type": "containment",
  "description": "Blocks a malicious IP on the perimeter firewall.",
  "parameters": [
    {
      "name": "ip_address",
      "type": "string",
      "required": true,
      "validation": { "pattern": "^\\d{1,3}(\\.\\d{1,3}){3}$" }
    },
    {
      "name": "duration_hours",
      "type": "integer",
      "required": false,
      "default": 24,
      "validation": { "min": 1, "max": 720 }
    },
    {
      "name": "fw_api_key",
      "type": "secret",
      "required": true
    }
  ],
  "executor": {
    "type": "remote",
    "target": "https://firewall.internal/api/rules/block",
    "method": "POST"
  },
  "timeout_seconds": 30,
  "retry": { "max_attempts": 2, "backoff_seconds": 5 }
}

Step 2: Implement the Executor Logic

The executor is the code that actually performs the operation. The implementation depends on the executor type.

Python Script (Local Executor)

For a local executor, write a Python script that reads parameters from stdin as JSON and writes results to stdout.

import json
import sys
import requests

def main():
    params = json.load(sys.stdin)

    ip_address = params["ip_address"]
    duration = params.get("duration_hours", 24)
    api_key = params["fw_api_key"]

    response = requests.post(
        "https://firewall.internal/api/rules/block",
        json={"ip": ip_address, "duration_h": duration},
        headers={"Authorization": f"Bearer {api_key}"},
        timeout=20,
    )
    response.raise_for_status()

    result = response.json()
    json.dump({
        "status": "success",
        "data": {"rule_id": result["id"], "expires_at": result["expires_at"]},
    }, sys.stdout)

if __name__ == "__main__":
    main()

Shell Script (Local Executor)

#!/usr/bin/env bash
set -euo pipefail

IP_ADDRESS="$1"
DURATION="${2:-24}"

curl -sf -X POST "https://firewall.internal/api/rules/block" \
  -H "Content-Type: application/json" \
  -d "{\"ip\": \"${IP_ADDRESS}\", \"duration_h\": ${DURATION}}"

HTTP Call (Remote Executor)

When using the remote executor type, no separate script is needed. The platform constructs the HTTP request from the action parameters and sends it to the configured target URL. The response body becomes the action result.

Note

For remote executors the platform automatically injects parameters into the request body as JSON. Use input_mapping in your action definition to control which parameters are sent and how they are named.

Step 3: Register the Action

Submit the action definition to the S4E API:

curl -X POST "https://api.s4e.io/api/actions/definitions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d @block-ip-action.json

A successful response returns 201 Created with the generated id:

{
  "id": "a3f8e1b2-7c44-4d2a-9f10-6b8e5d3c1a72",
  "name": "block-ip",
  "version": "1.0.0",
  "status": "registered"
}

Warning

Action names must be unique within your tenant. Attempting to register a duplicate name returns HTTP 409.

Step 4: Test in Sandbox Mode

Before using the action in production playbooks, test it in the sandbox:

curl -X POST "https://api.s4e.io/api/actions/a3f8e1b2/execute" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sandbox": true,
    "parameters": {
      "ip_address": "192.168.1.100",
      "duration_hours": 1,
      "fw_api_key": "test-key-xxx"
    }
  }'

Sandbox mode runs the action in an isolated environment with no access to production resources. See Testing Actions for detailed guidance.

Step 5: Deploy to Production

Once testing passes, enable the action for production use:

curl -X PATCH "https://api.s4e.io/api/actions/definitions/a3f8e1b2" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled": true}'

The action is now available for use in playbooks and can be invoked manually from the S4E console.

Best Practices

Idempotency

Design actions so that running them multiple times with the same input produces the same result. For the "Block IP" example, the executor should check whether a rule already exists before creating a duplicate.

existing = requests.get(
    f"https://firewall.internal/api/rules?ip={ip_address}",
    headers=headers,
    timeout=10,
).json()

if existing.get("count", 0) > 0:
    json.dump({"status": "already_blocked", "data": existing["rules"][0]}, sys.stdout)
    return

Error Handling

Always return structured error information so playbooks can branch on failure:

try:
    response.raise_for_status()
except requests.HTTPError as exc:
    json.dump({
        "status": "error",
        "error": {"code": response.status_code, "message": str(exc)},
    }, sys.stdout)
    sys.exit(1)

Logging

Write diagnostic messages to stderr so they appear in the platform's action logs without interfering with the structured result on stdout.

import sys
print(f"Blocking IP {ip_address} for {duration}h", file=sys.stderr)

Timeouts

Set timeout_seconds in the action definition to a value that reflects realistic network latency plus processing time. The platform terminates the executor if it exceeds this limit and marks the run as timed_out.

Warning

The maximum allowed timeout_seconds is 3600 (one hour). Actions that require longer execution should be designed as asynchronous workflows with a polling step.

Secrets Management

Never hard-code credentials. Use the secret parameter type so the platform injects values from the tenant's credential store at runtime. Secrets are encrypted at rest and never appear in logs or API responses.

Full Walkthrough: "Block IP" Action

Below is a consolidated summary of the complete workflow covered above.

  1. Create block-ip-action.json with the definition shown in Step 1.
  2. Write block_ip.py with the executor script from Step 2.
  3. Register via POST /api/actions/definitions.
  4. Execute in sandbox with sandbox: true.
  5. Review logs at GET /api/actions/{id}/runs.
  6. Enable for production with PATCH /api/actions/definitions/{id}.
  7. Reference in a playbook step with "action_ref": "[email protected]".

Tip

Use GET /api/actions/definitions/{id}/versions to list all registered versions of an action and roll back if a new version introduces issues.