Writing Rules
A Rule matches job identifiers by prefix and specifies what happens when a matching job triggers. This guide covers rule creation, pattern matching, and runner configuration.
Basic Rule Creation
Use the RULE SET instruction:
<request_id> RULE SET <rule_identifier> <pattern> <runner_type> <runner_args...>Example: Shell Runner
echo 'r1 RULE SET rule.backup backup. shell /usr/local/bin/backup.sh' | socat - TCP:localhost:5678Creates a rule that:
- Is identified as
rule.backup - Matches any job starting with
backup.(e.g.,backup.daily,backup.hourly) - Executes
/usr/local/bin/backup.shwhen a matching job triggers
Response:
r1 OKPattern Matching
Rules use prefix matching: the pattern is compared against the start of each job identifier.
Prefix Match
echo 'r1 RULE SET rule.app app. shell /bin/process' | socat - TCP:localhost:5678Matches: app.task.1, app.job.2, app.anything
Does not match: application.task, my.app.task
Exact Match
Use the full identifier as pattern:
echo 'r1 RULE SET rule.reboot system.reboot shell /usr/bin/reboot' | socat - TCP:localhost:5678Matches only: system.reboot (and anything starting with system.reboot)
Multiple Rules
Create separate rules for different job families:
echo 'r1 RULE SET rule.backup backup. shell /usr/bin/backup.sh' | socat - TCP:localhost:5678
echo 'r2 RULE SET rule.db database. shell /usr/bin/db-sync.sh' | socat - TCP:localhost:5678
echo 'r3 RULE SET rule.mail mail. shell /usr/bin/mail-send.sh' | socat - TCP:localhost:5678Priority by Specificity
When multiple rules match a job, the rule with the longest matching pattern wins:
# Broad rule: matches all jobs starting with "app."
echo 'r1 RULE SET rule.app.general app. shell "/bin/echo general"' | socat - TCP:localhost:5678
# Specific rule: matches only "app.critical." jobs
echo 'r2 RULE SET rule.app.critical app.critical. shell /bin/alert' | socat - TCP:localhost:5678Job app.critical.task1 matches both patterns, but app.critical. is longer (14 chars vs 4 chars), so the alert rule wins.
Runner Types
Shell Runner
Execute a command in a child process:
echo 'r1 RULE SET rule.logs logs. shell "/usr/sbin/logrotate -f /etc/logrotate.conf"' | socat - TCP:localhost:5678The command is passed to the configured shell (default: /bin/sh -c), so shell features work. The shell binary and arguments can be changed via the [shell] config section — see
Configuration Reference. Quote commands that contain spaces:
echo 'r1 RULE SET rule.complex complex. shell /usr/local/bin/my-script.sh' | socat - TCP:localhost:5678Direct Runner
Execute a command directly without shell interpretation. Useful for simple commands where you want to avoid shell injection vulnerabilities.
echo 'r1 RULE SET rule.fetch fetch. direct /usr/bin/curl -s http://example.com' | socat - TCP:localhost:5678Characteristics:
- No shell interpreter involved — execve is used directly
- First argument is the executable path; remaining arguments are passed literally
- Shell metacharacters (
$(),;,|, etc.) are passed as literal strings, not interpreted - Eliminates shell injection risks
Example: Safe from Injection
# Shell runner: vulnerable to injection via job ID manipulation
echo 'r1 RULE SET rule.app1 app. shell "curl http://api/result?id=$1"' | socat - TCP:localhost:5678
# Direct runner: same command, immune to injection
echo 'r1 RULE SET rule.app2 app. direct /usr/bin/curl http://api/result?id=$1' | socat - TCP:localhost:5678In the direct runner, $1 is passed literally to curl as a query parameter value, not substituted by the shell.
HTTP Runner
Trigger an external webhook via HTTP/HTTPS request. Useful for integrating ztick with third-party services like Slack, cloud functions, or custom webhooks.
echo 'r1 RULE SET rule.webhook deploy. http POST https://hooks.example.com/webhook' | socat - TCP:localhost:5678Characteristics:
- Methods:
GET,POST,PUT,DELETE - POST and PUT requests include a JSON body:
{"job_id":"<identifier>","execution":<timestamp_ns>} - GET and DELETE requests send no body
- HTTP 2xx status codes are treated as success; all others as failure
- TLS is automatically used for
https://URLs - Connection and read timeouts: 30 seconds
Example: Slack Webhook (POST)
echo 'r1 RULE SET rule.slack-notify deploy. http POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL' | socat - TCP:localhost:5678When a job matching deploy.* triggers, ztick sends:
POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL HTTP/1.1
Host: hooks.slack.com
Content-Type: application/json
Content-Length: 42
{"job_id":"deploy.v1.2.3","execution":1711612800000000000}Example: Health Check (GET)
echo 'r1 RULE SET rule.health health. http GET https://api.internal/health' | socat - TCP:localhost:5678GET requests send no body, useful for pinging endpoints.
Example: PUT Request
echo 'r1 RULE SET rule.update config. http PUT https://config.internal/update' | socat - TCP:localhost:5678PUT requests also include the JSON body like POST.
AWF Runner
Execute an AWF (AI Workflow) using the AWF CLI. Useful for automating AI agent pipelines (code review, report generation, data analysis) on a schedule.
echo 'r1 RULE SET rule.review code-review. awf code-review' | socat - TCP:localhost:5678Characteristics:
- Workflow name is passed to
awf run <workflow> - Optional key=value input parameters via repeated
--inputflag - Exit code 0 is success; non-zero is failure
- Requires
awfCLI binary to be in$PATH
Example: Without Inputs
echo 'r1 RULE SET rule.review code-review. awf code-review' | socat - TCP:localhost:5678
# Response: r1 OKSpawns: awf run code-review
Example: With Inputs
echo 'r1 RULE SET rule.report report. awf generate-report --input format=pdf --input target=main' | socat - TCP:localhost:5678
# Response: r1 OKSpawns: awf run generate-report --input format=pdf --input target=main
Each --input flag passes one key=value pair. Repeat the flag for multiple parameters.
AMQP Runner
Publish a message to an AMQP 0-9-1 broker (e.g. RabbitMQ) when a matching job triggers. Useful for fanning out scheduled events to downstream consumers without coupling them to ztick.
echo 'r1 RULE SET rule.notify notify. amqp amqp://guest:guest@localhost:5672/ jobs notifications' | socat - TCP:localhost:5678Parameters: amqp <dsn> <exchange> <routing_key>
Characteristics:
- AMQP 0-9-1 protocol over plaintext TCP (default port 5672)
- DSN format:
amqp://[user[:password]@]host[:port][/vhost]— credentials are redacted from logs - Each execution opens a new TCP connection, performs the handshake, publishes one
basic.publishframe, then closes cleanly - Message body is the job identifier (u128 hex string); richer payloads are a future addition
- Connect/send/receive timeout: 30 seconds — broker latency cannot starve the processor thread
- Fire-and-forget: success means the publish frame was accepted by the broker at TCP level (no publisher confirms in v1)
- Connection refused, authentication failure, or malformed DSN return
success = falsewithout crashing the processor
Limitations:
- TLS (
amqps://) is not supported — use a stunnel sidecar or wait for a future revision if you need encryption in transit - Exchange existence is not validated; if the exchange is missing the broker silently drops the message (consult RabbitMQ logs)
- No connection pooling — each execution pays a fresh handshake (~10 ms on localhost)
Example: Notify on Deploy
echo 'r1 RULE SET rule.deploy deploy. amqp amqp://guest:guest@rabbitmq:5672/ jobs deploy.events' | socat - TCP:localhost:5678When a job matching deploy.* triggers, ztick publishes the job identifier to exchange jobs with routing key deploy.events.
Local Development Stack
A compose.yaml at the repository root boots a RabbitMQ broker with the management UI on http://localhost:15672 (default credentials guest / guest):
docker compose up -dPrepare the broker (declare exchange / queue / binding)
ztick publishes to whatever exchange + routing-key the rule names. RabbitMQ accepts the publish at TCP level even when no queue is bound (without publisher confirms — see the Limitations above), so messages disappear silently if the topology is missing. Declare it once before sending real traffic.
Using the bundled CLI (RabbitMQ 4.x — note the --name flag syntax, not the legacy name=value):
docker compose exec rabbitmq rabbitmqadmin declare exchange --name jobs --type direct --durable true
docker compose exec rabbitmq rabbitmqadmin declare queue --name notifications --durable true
docker compose exec rabbitmq rabbitmqadmin declare binding --source jobs --destination notifications --destination-type queue --routing-key notificationsThe shorthand alternative is to publish to the default exchange ("") with the routing key set to the queue name — RabbitMQ then routes directly to that queue, no binding needed:
docker compose exec rabbitmq rabbitmqadmin declare queue --name ztick.events --durable true
# Then in the rule: amqp amqp://guest:guest@localhost:5672/ "" ztick.eventsThe management UI on http://localhost:15672 exposes the same actions under the Exchanges and Queues tabs.
Verifying messages arrive
Without publisher confirms, ztick reports success = true as soon as the publish frame leaves the socket — the broker may still drop the message if no queue is bound. Two cheap ways to confirm receipt:
# Drain one message (acknowledges and removes it from the queue)
docker compose exec rabbitmq rabbitmqadmin get messages --queue notifications --count 1
# Or watch the queue depth without consuming
watch -n 1 'docker compose exec rabbitmq rabbitmqadmin list queues name messages'The management UI’s Queues → notifications → Get messages (with Ack mode = Reject requeue true) inspects without draining.
A successful publish from ztick produces a body equal to the job identifier formatted as a u128 hex string, for example:
Body: 0x1a2b3c4d5e6f70809a0b1c2d3e4f5a6bConsumers should treat the body as opaque text and look up the job’s actual data via ztick’s TCP GET <job_id> if richer context is needed.
Troubleshooting
The runner never propagates errors to the processor — every failure path returns success = false and emits one warning to stderr (with the DSN credentials stripped). Map the warning text to the likely cause:
| Warning line | Likely cause | Fix |
|---|---|---|
amqp runner: dsn parse failed: ... err=error.InvalidScheme | DSN does not start with amqp://, or uses amqps:// (TLS not supported) | Correct the scheme; remove s suffix |
amqp runner: dsn parse failed: ... err=error.MissingUserInfo | DSN omits the @ separator (e.g. amqp://host/) | Add credentials, even guest:guest@ |
amqp runner: dsn parse failed: ... err=error.MissingHost | Empty host (e.g. amqp://user:pass@:5672/) | Provide a hostname or IP |
amqp runner: dsn parse failed: ... err=error.InvalidPort | Port is non-numeric or > 65535 | Fix the port |
amqp runner: tcp connect failed: ... err=error.ConnectionRefused | Broker is not listening on that host:port | docker compose ps; check [http] is on the right port; firewall |
amqp runner: tcp connect failed: ... err=error.ConnectionTimedOut | Broker reachable but slow to accept (overload, network); 30 s timeout exhausted | Investigate broker health; consider raising broker resources |
amqp runner: handshake failed: ... err=error.PeerClose | Broker closed the connection during handshake — almost always authentication failure (AMQP reply-code 403) | Verify credentials in the DSN; check broker user permissions |
amqp runner: handshake failed: ... err=error.EndOfStream | Broker closed mid-handshake without sending Connection.Close (rare) | Check broker logs for protocol errors |
If the publish appears to succeed (no warning) but the queue stays empty, the topology is the suspect — see Prepare the broker and Verifying messages arrive above.
Redis Runner
Send a single Redis command to a Redis server when a matching job triggers. Useful for fanning out events on a pub/sub channel or pushing job identifiers onto a Redis-backed worker queue without deploying a heavyweight broker.
echo 'r1 RULE SET rule.publish deploy. redis redis://127.0.0.1:6379/0 PUBLISH deploy:events' | socat - TCP:localhost:5678Parameters: redis <url> <command> <key>
Characteristics:
- Plaintext RESP2 over TCP (default port 6379)
- URL format:
redis://[user[:password]@]host[:port][/db]— credentials are redacted from logs - Allowed commands (case-sensitive, validated at
RULE SETparse time):PUBLISH,RPUSH,LPUSH,SET - Each execution opens a new TCP connection, optionally sends
AUTH(single-arg or ACL two-arg form), optionally sendsSELECT <db>whendb != 0, sends the configured command with the job identifier as the value/payload, then closes - Connect/send/receive timeout: 30 seconds — Redis latency cannot starve the processor thread
- Fire-and-forget:
PUBLISHwith zero subscribers is treated as success (matchesredis-clidefaults); a RESP error reply (-ERR ...) is treated as failure - Connection refused, auth rejection, malformed URL, or unsupported commands return
success = falsewithout crashing the processor
Limitations:
- TLS (
rediss://) is not supported — rejected at parse time. Wait for the general TLS-support track if you need encryption in transit - Only
PUBLISH,RPUSH,LPUSH,SETare supported in v1 (noHSET,SADD,XADD, etc.) - No connection pooling — each execution pays a fresh handshake (~1 ms on localhost)
- Payload is the raw job identifier; structured JSON envelopes are deferred
Example: Publish on Deploy (PUBLISH)
echo 'r1 RULE SET rule.publish deploy. redis redis://127.0.0.1:6379/0 PUBLISH deploy:events' | socat - TCP:localhost:5678When a job matching deploy.* triggers, ztick connects to Redis and runs PUBLISH deploy:events <job_id>. Subscribers on the deploy:events channel receive the job identifier as the message payload.
Verify the channel locally:
# In one terminal: subscribe
docker compose exec redis redis-cli SUBSCRIBE deploy:events
# In another terminal: schedule a matching job
echo 'r2 SET deploy.release.v1 2026-04-27 12:00:00' | socat - TCP:localhost:5678Example: Worker Queue (RPUSH)
echo 'r1 RULE SET rule.queue backup. redis redis://127.0.0.1:6379/0 RPUSH backup:tasks' | socat - TCP:localhost:5678When a job matching backup.* triggers, ztick runs RPUSH backup:tasks <job_id>, appending the job identifier to the tail of the backup:tasks list. Workers can drain the list with BLPOP backup:tasks 0.
Verify the queue:
# Inspect the list contents (does not consume)
docker compose exec redis redis-cli LRANGE backup:tasks 0 -1
# Pop the next task (consumes)
docker compose exec redis redis-cli LPOP backup:tasksExample: Authenticated Redis with Non-Zero Database
echo 'r1 RULE SET rule.notify notify. redis redis://app:s3cr3t@redis.internal:6379/3 PUBLISH notify:events' | socat - TCP:localhost:5678Triggers send AUTH app s3cr3t, then SELECT 3, then PUBLISH notify:events <job_id>. When the URL contains only a password (redis://:s3cr3t@host/0), ztick falls back to the legacy single-arg AUTH s3cr3t.
Local Development Stack
The bundled compose.yaml boots a Redis service on 127.0.0.1:6379 with no auth and database 0:
docker compose up -d redis
docker compose exec redis redis-cli ping
# PONGUpdating a Rule
Overwrite a rule by sending RULE SET with the same identifier:
echo 'r1 RULE SET rule.backup backup. shell /usr/bin/backup-v2.sh' | socat - TCP:localhost:5678Complete Example
# 1. Create rules
echo 'r1 RULE SET rule.backup backup. shell /usr/bin/backup.sh' | socat - TCP:localhost:5678
echo 'r2 RULE SET rule.report report. shell /usr/bin/generate-report.sh' | socat - TCP:localhost:5678
# 2. Verify rules are loaded
echo 'r3 LISTRULES' | socat - TCP:localhost:5678
# 3. Schedule jobs
echo 'r4 SET backup.daily 2026-04-01 02:00:00' | socat - TCP:localhost:5678
echo 'r5 SET backup.weekly 2026-04-07 03:00:00' | socat - TCP:localhost:5678
echo 'r6 SET report.monthly 2026-04-01 06:00:00' | socat - TCP:localhost:5678When each job’s execution time arrives:
backup.dailyandbackup.weeklytrigger/usr/bin/backup.shreport.monthlytriggers/usr/bin/generate-report.sh
Removing a Rule
Use the REMOVERULE command to delete a rule:
echo 'r1 REMOVERULE rule.backup' | socat - TCP:localhost:5678Response:
r1 OKIf the rule doesn’t exist:
r1 ERRORREMOVERULE persists the deletion to the logfile — the rule stays removed across server restarts and background compression. Removing a rule does not cancel pending jobs that were previously matched by it.
Listing Rules
Use LISTRULES to see all configured rules:
echo 'r1 LISTRULES' | socat - TCP:localhost:5678Response (one line per rule, then OK):
r1 rule.backup backup. shell /usr/bin/backup.sh
r1 rule.report report. shell /usr/bin/generate-report.sh
r1 OKIf no rules are loaded, the response is just r1 OK. Rules are returned in unspecified order.
See Protocol Reference for full details.
Best Practices
1. Use Descriptive Identifiers and Patterns
# Clear
echo 'r1 RULE SET rule.backup.daily backup.daily. shell /usr/bin/daily-backup' | socat - TCP:localhost:5678
# Confusing
echo 'r1 RULE SET r1 bak shell /usr/bin/backup' | socat - TCP:localhost:56782. Create Rules Before Jobs
A job that triggers without a matching rule transitions to failed. Always set up rules first.
3. Make Scripts Idempotent
Rules may be retried if execution fails. Ensure commands are safe to run multiple times:
#!/bin/bash
set -euo pipefail
# Idempotent: safe to run multiple times
mkdir -p /var/backups
cp /data/important.db /var/backups/important.db.$(date +%s)4. Test Commands Independently
Before creating a rule, verify the command works:
/usr/local/bin/backup.sh
echo $? # Should be 0 for success