Writing
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:
- Purpose -- What security operation does this action perform? (e.g., block an IP, enrich an alert, send a notification)
- Inputs -- What data does the action need? Define each parameter, its type, and whether it is required.
- Outputs -- What data does the action produce? Define the result structure so downstream playbook steps can consume it.
- Executor -- Where does the logic run? Choose between a local script, a remote HTTP endpoint, or an Opservant agent.
- 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.
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.
- Create
block-ip-action.jsonwith the definition shown in Step 1. - Write
block_ip.pywith the executor script from Step 2. - Register via
POST /api/actions/definitions. - Execute in sandbox with
sandbox: true. - Review logs at
GET /api/actions/{id}/runs. - Enable for production with
PATCH /api/actions/definitions/{id}. - 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.