The ztick protocol is a simple line-based text protocol for communicating with the scheduler over TCP.

Protocol Overview

  • Transport: TCP (plaintext or TLS-encrypted)
  • Format: Newline-terminated lines
  • Parser: Space-separated arguments with quoted string support
  • Max line size: 4096 bytes (fixed per-connection buffer)

TLS Support: When ztick is configured with TLS certificates, the protocol is transparently encrypted over the same TCP connection. The protocol itself is unchanged — clients using TLS need only change their connection mechanism (e.g., use openssl s_client instead of socat). See Configuration Reference for TLS setup.

TCP Client Tools

Any tool that can open a TCP connection and send text works with ztick:

ToolPlaintextTLSExample
socat (recommended)socat - TCP:host:portsocat - OPENSSL:host:portecho 'r1 GET job.1' | socat - TCP:localhost:5678
openssl s_clientN/Aopenssl s_client -connect host:port -quietFor TLS debugging and verification
curlcurl telnet://host:portN/AQuick interactive sessions
bash built-incat > /dev/tcp/host/portN/ANo external dependencies

All documentation examples use socat as the default tool.

Connection

1. Connect to the TCP server (default: 127.0.0.1:5678)
2. If TLS is configured, complete the TLS handshake
3. If authentication is enabled, send AUTH command (see AUTH below)
4. Send commands as lines (terminated with \n)
5. Receive responses (also newline-terminated)
6. Connection stays open for multiple commands
7. Close when done

When TLS is enabled, plaintext clients that connect will have their connection closed after a failed handshake. The server remains available to new connections.

Authentication and Namespace Enforcement: When auth_file is configured, every new connection must authenticate with the AUTH command before any other commands are accepted. After successful authentication, all subsequent commands are subject to namespace restrictions — commands targeting job or rule identifiers outside the authenticated token’s namespace are rejected with ERROR auth_denied. This applies to SET, GET, QUERY, REMOVE, REMOVERULE, and RULE SET commands.

Command Format

Every command follows this structure:

<request_id> <instruction> <args...>\n
  • request_id — A client-chosen identifier echoed back in the response (e.g., req-1, cmd-42)
  • instruction — The operation to perform (AUTH, SET, GET, QUERY, LISTRULES, STAT, REMOVE, REMOVERULE, or RULE SET)
  • args — Instruction-specific arguments

Response Format

<request_id> <status>\n
<request_id> <status> <body>\n
StatusMeaning
OKCommand succeeded
ERRORCommand failed; optionally followed by error code and message

The request_id matches the one sent in the command, allowing clients to correlate responses.

Error responses can include a machine-readable error code and human-readable message:

<request_id> ERROR <code> <message>\n

For example:

req-1 ERROR not_found job "backup.daily" does not exist
req-2 ERROR invalid_args missing required argument: timestamp
req-3 ERROR auth_denied insufficient namespace scope

Pre-authentication errors (when auth is enabled) omit the request ID and message to prevent token enumeration:

ERROR <code>\n

For example:

ERROR auth_required
ERROR auth_failed

Backward compatibility: Clients that match the ERROR prefix (with or without parsing the code and message) continue to work without modification.

Write commands (SET, RULE SET) and delete commands (REMOVE, REMOVERULE) return a status-only response. Read commands (GET) return a response with additional data in the body after the status. List commands (QUERY, LISTRULES, STAT) return multiple lines followed by a terminal OK line.

Error Handling

Error Codes

The server returns one of six error codes in ERROR responses:

CodeTriggered ByExampleNotes
not_foundGET/REMOVE/REMOVERULE for non-existent IDreq-1 ERROR not_found job "backup.daily" does not existSingle-entity lookups only; QUERY with no matches returns OK with empty body
invalid_argsMissing or malformed required argumentsreq-2 ERROR invalid_args missing required argument: timestampApplies to SET, GET, RULE SET, QUERY, etc. when args are incomplete
auth_requiredNon-AUTH command sent before authentication (when auth enabled)ERROR auth_requiredConnection closed after response; no request ID, no message
auth_failedAUTH sent with unrecognized tokenERROR auth_failedConnection closed after response; no request ID, no message to prevent token enumeration
auth_deniedCommand targets identifier outside token’s namespace scopereq-3 ERROR auth_denied insufficient namespace scopeReturned for commands after successful auth when targeting out-of-scope namespace
internalUnexpected server failure (rare)req-4 ERROR internalNo implementation details, file paths, or stack traces in message

Connection and Protocol Errors

ConditionBehavior
Malformed line (invalid syntax)Silently skipped, no response sent, connection stays open
Incomplete line (no newline yet)Server waits for more data
Unrecognized commandSilently ignored, no response sent (see below)
Out of memoryConnection closed
AUTH timeout (5s)Connection closed when auth is enabled
Non-AUTH command before authenticationERROR auth_required response and connection closed

Important: Only AUTH, SET, GET, QUERY, LISTRULES, STAT, REMOVE, REMOVERULE, and RULE SET produce responses. If you send an unrecognized command, the server will not send any response — the client must not block waiting for one.

Commands

AUTH

Authenticate with a secret token (required as the first command when authentication is enabled).

Syntax:

AUTH <secret>\n

Parameters:

  • secret (string): Token secret from the auth file (e.g., sk_deploy_a1b2c3d4e5f6)

Response:

  • Success: OK\n
  • Failure: ERROR <code>\n (connection is immediately closed, no request ID)

Error Codes:

  • auth_required: AUTH command sent without a token argument, or server missing required configuration
  • auth_failed: Token not recognized in the auth file

Behavior:

  • When auth_file is configured, AUTH must be the first command sent after connecting
  • Sending any other command before AUTH returns ERROR auth_required and closes the connection
  • Connections that do not complete AUTH within 5 seconds are automatically closed
  • After successful authentication, all subsequent commands are restricted to the token’s assigned namespace

Examples:

# Authenticate with a valid token
echo 'AUTH sk_deploy_a1b2c3d4e5f6' | socat - TCP:localhost:5678
# Response: OK

# Attempt to authenticate with an invalid token
echo 'AUTH invalid_secret' | socat - TCP:localhost:5678
# Response: ERROR auth_failed
# Connection is closed

# Send a command before authenticating
echo 'r1 SET job.1 12345' | socat - TCP:localhost:5678
# Response: ERROR auth_required
# Connection is closed

Notes:

  • AUTH is only required when auth_file is configured in the server config
  • When auth is disabled (no auth_file), clients can skip AUTH and issue commands directly
  • Secrets are transmitted in cleartext over plaintext TCP connections — TLS is strongly recommended in production
  • After successful AUTH, the secret is not retained in memory; only the token name and namespace are kept

SET

Create or update a job.

Syntax:

<request_id> SET <job_identifier> <timestamp>

Parameters:

  • job_identifier (string): Unique job identifier (e.g., backup.daily)
  • timestamp: Either an integer in nanoseconds or a datetime string YYYY-MM-DD HH:MM:SS

Response:

  • Success: <request_id> OK\n
  • Invalid arguments: <request_id> ERROR invalid_args <missing_field>\n

Examples:

# With nanosecond timestamp
echo 'req-1 SET backup.daily 1711612800000000000' | socat - TCP:localhost:5678
# Response: req-1 OK

# With datetime string
echo 'req-2 SET app.task.1 2026-03-30 14:00:00' | socat - TCP:localhost:5678
# Response: req-2 OK

# Missing timestamp
echo 'req-3 SET backup.daily' | socat - TCP:localhost:5678
# Response: req-3 ERROR invalid_args missing required argument: timestamp

GET

Retrieve a job’s current state.

Syntax:

<request_id> GET <job_identifier>

Parameters:

  • job_identifier (string): The job identifier to look up (e.g., backup.daily)

Response:

  • Success: <request_id> OK <status> <execution_ns>\n
  • Not found: <request_id> ERROR not_found job "<job_id>" does not exist\n
  • Invalid arguments: <request_id> ERROR invalid_args missing required argument: job_identifier\n
FieldDescription
statusJob status: planned, triggered, executed, or failed
execution_nsExecution timestamp in nanoseconds since Unix epoch

Examples:

# Get a job's state
echo 'req-5 GET backup.daily' | socat - TCP:localhost:5678
# Response: req-5 OK planned 1711612800000000000

# Get a nonexistent job
echo 'req-6 GET no.such.job' | socat - TCP:localhost:5678
# Response: req-6 ERROR not_found job "no.such.job" does not exist

# Get without job identifier
echo 'req-7 GET' | socat - TCP:localhost:5678
# Response: req-7 ERROR invalid_args missing required argument: job_identifier

Notes: GET is a read-only command — it does not generate any persistence log entry.

QUERY

List jobs matching a prefix pattern, or all jobs when no pattern is given.

Syntax:

<request_id> QUERY [<pattern>]

Parameters:

  • pattern (string, optional): Prefix to match against job identifiers. When omitted, returns all jobs.

Response:

  • One line per matching job: <request_id> <job_id> <status> <execution_ns>\n
  • Terminal line: <request_id> OK\n
FieldDescription
job_idThe matching job’s identifier
statusJob status: planned, triggered, executed, or failed
execution_nsExecution timestamp in nanoseconds since Unix epoch

Examples:

# Query all jobs with "backup." prefix
echo 'req-1 QUERY backup.' | socat - TCP:localhost:5678
# Response:
# req-1 backup.daily planned 1711612800000000000
# req-1 backup.weekly planned 1711872000000000000
# req-1 OK

# Query all jobs (no pattern)
echo 'req-2 QUERY' | socat - TCP:localhost:5678
# Response: one line per job, followed by req-2 OK

# Query with no matches
echo 'req-3 QUERY nonexistent.' | socat - TCP:localhost:5678
# Response:
# req-3 OK

Notes: QUERY is a read-only command — it does not generate any persistence log entry. Results are returned in unspecified order (hashmap iteration order).

When authentication is enabled, QUERY results are filtered to only include jobs matching the authenticated token’s namespace prefix. For example, a token with namespace deploy. will only see jobs starting with deploy. even if the pattern would normally match other jobs. A token with namespace * sees all jobs.

LISTRULES

List all configured rules.

Syntax:

<request_id> LISTRULES

Parameters: None. Extra trailing arguments are silently ignored.

Response:

  • One line per rule: <request_id> <rule_id> <pattern> <runner_type> <runner_args...>\n
  • Terminal line: <request_id> OK\n
FieldDescription
rule_idThe rule’s identifier
patternPrefix pattern the rule matches against
runner_typeRunner type: shell, direct, http, awf, amqp, or redis
runner_argsShell: <command>. Direct: <executable> [args...]. HTTP: <method> <url>. AWF: <workflow> [--input key=value ...]. AMQP: <dsn> <exchange> <routing_key>. Redis: <url> <command> <key>

Examples:

# List all rules (with shell, direct, http, awf, and amqp rules loaded)
echo 'req-1 LISTRULES' | socat - TCP:localhost:5678
# Response:
# req-1 rule.backup backup. shell /usr/bin/backup.sh
# req-1 rule.curl curl. direct /usr/bin/curl -s http://example.com
# req-1 rule.webhook deploy. http POST https://hooks.example.com/webhook
# req-1 rule.health health. http GET https://api.internal/trigger
# req-1 rule.review code-review. awf code-review
# req-1 rule.report report. awf generate-report --input format=pdf --input target=main
# req-1 rule.notify notify. amqp amqp://broker:5672 jobs notifications
# req-1 rule.publish deploy. redis redis://127.0.0.1:6379/0 PUBLISH deploy:events
# req-1 OK

# List rules when none are loaded
echo 'req-2 LISTRULES' | socat - TCP:localhost:5678
# Response:
# req-2 OK

# Extra arguments are ignored
echo 'req-3 LISTRULES foo' | socat - TCP:localhost:5678
# Response: same as LISTRULES without arguments

Notes: LISTRULES is a read-only command — it does not generate any persistence log entry. Results are returned in unspecified order (hashmap iteration order).

STAT

Return server health metrics as key-value pairs.

Syntax:

<request_id> STAT

Parameters: None. Extra trailing arguments are silently ignored.

Response:

  • One line per metric: <request_id> <key> <value>\n
  • Terminal line: <request_id> OK\n
MetricDescription
uptime_nsServer uptime in nanoseconds since startup
connectionsNumber of active TCP connections
jobs_totalTotal number of jobs in storage
jobs_plannedCount of jobs with status planned
jobs_triggeredCount of jobs with status triggered
jobs_executedCount of jobs with status executed
jobs_failedCount of jobs with status failed
rules_totalTotal number of rules in storage
executions_pendingNumber of jobs queued for execution
executions_inflightNumber of jobs currently being executed
persistenceBackend type: logfile or memory
compressionCompression process status: idle, running, success, or failure
auth_enabled1 if authentication is configured, 0 otherwise
tls_enabled1 if TLS is configured, 0 otherwise
framerateConfigured scheduler tick rate

Examples:

# Get server health metrics
echo 'req-1 STAT' | socat - TCP:localhost:5678
# Response:
# req-1 uptime_ns 60000000000
# req-1 connections 1
# req-1 jobs_total 42
# req-1 jobs_planned 30
# req-1 jobs_triggered 2
# req-1 jobs_executed 8
# req-1 jobs_failed 2
# req-1 rules_total 5
# req-1 executions_pending 0
# req-1 executions_inflight 0
# req-1 persistence logfile
# req-1 compression idle
# req-1 auth_enabled 0
# req-1 tls_enabled 0
# req-1 framerate 512
# req-1 OK

# Extra arguments are ignored
echo 'req-2 STAT verbose' | socat - TCP:localhost:5678
# Response: same as STAT without arguments

Notes: STAT is a read-only command — it does not generate any persistence log entry. STAT is namespace-independent: any authenticated client can call it regardless of namespace. When authentication is enabled, STAT requires authentication like any other command.

REMOVE

Delete a scheduled job.

Syntax:

<request_id> REMOVE <job_identifier>

Parameters:

  • job_identifier (string): The job identifier to delete (e.g., backup.daily)

Response:

  • Success: <request_id> OK\n
  • Not found: <request_id> ERROR not_found job "<job_id>" does not exist\n

Examples:

# Remove an existing job
echo 'req-7 REMOVE backup.daily' | socat - TCP:localhost:5678
# Response: req-7 OK

# Remove a nonexistent job
echo 'req-8 REMOVE no.such.job' | socat - TCP:localhost:5678
# Response: req-8 ERROR not_found job "no.such.job" does not exist

Notes: REMOVE persists the deletion to the append-only logfile. The removal survives server restarts and background log compression.

REMOVERULE

Delete an execution rule.

Syntax:

<request_id> REMOVERULE <rule_identifier>

Parameters:

  • rule_identifier (string): The rule identifier to delete (e.g., rule.backup)

Response:

  • Success: <request_id> OK\n
  • Not found: <request_id> ERROR not_found rule "<rule_id>" does not exist\n

Examples:

# Remove an existing rule
echo 'req-9 REMOVERULE rule.backup' | socat - TCP:localhost:5678
# Response: req-9 OK

# Remove a nonexistent rule
echo 'req-10 REMOVERULE no.such.rule' | socat - TCP:localhost:5678
# Response: req-10 ERROR not_found rule "no.such.rule" does not exist

Notes: REMOVERULE persists the deletion to the append-only logfile. The removal survives server restarts and background log compression. Removing a rule does not cancel pending jobs that were previously matched by the rule.

RULE SET

Create or update a rule that matches jobs by prefix and assigns a runner.

Syntax (shell runner):

<request_id> RULE SET <rule_identifier> <pattern> shell <command>

Syntax (direct runner):

<request_id> RULE SET <rule_identifier> <pattern> direct <executable> [args...]

Syntax (awf runner):

<request_id> RULE SET <rule_identifier> <pattern> awf <workflow> [--input <key=value> ...]

Syntax (http runner):

<request_id> RULE SET <rule_identifier> <pattern> http <method> <url>

Syntax (amqp runner):

<request_id> RULE SET <rule_identifier> <pattern> amqp <dsn> <exchange> <routing_key>

Syntax (redis runner):

<request_id> RULE SET <rule_identifier> <pattern> redis <url> <command> <key>

Parameters:

  • rule_identifier (string): Unique rule identifier (e.g., rule.backup)
  • pattern (string): Prefix to match job identifiers (e.g., backup. matches backup.daily)
  • shell <command>: Execute a shell command when the job triggers. Quote the command if it contains spaces (e.g., shell "/bin/echo hello")
  • direct <executable> [args...]: Execute a binary directly via execve without shell interpretation. The first token after direct is the executable path; remaining tokens are passed as literal argv elements. Shell metacharacters are not interpreted, eliminating shell injection risks.
  • http <method> <url>: Trigger an external webhook via HTTP/HTTPS request. Methods: GET, POST, PUT, DELETE. The URL must include a scheme (http:// or https://). POST and PUT requests include a JSON body {"job_id":"<identifier>","execution":<timestamp_ns>}; GET and DELETE send no body. HTTP 2xx status codes indicate success; all others indicate failure. Connection and read timeouts are 30 seconds.
  • awf <workflow> [--input <key=value> ...]: Execute an AWF workflow. The workflow name is required. The optional --input flag can be repeated to pass key=value parameters to the workflow (e.g., awf generate-report --input format=pdf --input target=main)
  • amqp <dsn> <exchange> <routing_key>: Publish a message to an AMQP 0-9-1 broker. The DSN follows the amqp://[user[:password]@]host[:port][/vhost] format (plaintext only — TLS is not yet supported). The message body is the job identifier. Each execution opens a fresh connection, publishes one basic.publish frame, and closes the connection. Connect/send/receive timeout: 30 seconds. Connection refused, auth rejection, or DSN parse errors return failure without crashing the processor.
  • redis <url> <command> <key>: Send a single Redis command per matching job. Requires exactly four runner tokens (redis + url + command + key). The URL follows the redis://[user[:password]@]host[:port][/db] format (plaintext only — rediss:// is rejected at parse time). The <command> token must be one of PUBLISH, RPUSH, LPUSH, SET (case-sensitive); any other value rejects the rule with ERROR invalid_args before persistence. The job identifier is sent as the value/payload. Each execution opens a fresh TCP connection, optionally sends AUTH then SELECT <db> when applicable, sends the configured command, then closes. Connect/send/receive timeout: 30 seconds. Credentials in the URL are redacted from logs.

Response:

  • Success: <request_id> OK\n
  • Invalid arguments: <request_id> ERROR invalid_args <details>\n

Examples:

# Shell runner
echo 'req-3 RULE SET rule.backup backup. shell /usr/bin/backup.sh' | socat - TCP:localhost:5678
# Response: req-3 OK

# Direct runner (no shell interpretation)
echo 'req-4 RULE SET rule.curl curl. direct /usr/bin/curl -s http://example.com' | socat - TCP:localhost:5678
# Response: req-4 OK

# AWF runner without inputs
echo 'req-5 RULE SET rule.review code-review. awf code-review' | socat - TCP:localhost:5678
# Response: req-5 OK

# AWF runner with inputs
echo 'req-6 RULE SET rule.report report. awf generate-report --input format=pdf --input target=main' | socat - TCP:localhost:5678
# Response: req-6 OK

# HTTP runner (POST)
echo 'req-7 RULE SET rule.webhook deploy. http POST https://hooks.example.com/webhook' | socat - TCP:localhost:5678
# Response: req-7 OK

# HTTP runner (GET)
echo 'req-8 RULE SET rule.health health. http GET https://api.internal/trigger' | socat - TCP:localhost:5678
# Response: req-8 OK

# AMQP runner
echo 'req-9 RULE SET rule.notify notify. amqp amqp://guest:guest@localhost:5672/ jobs notifications' | socat - TCP:localhost:5678
# Response: req-9 OK

# Redis runner (PUBLISH)
echo 'req-10 RULE SET rule.publish deploy. redis redis://127.0.0.1:6379/0 PUBLISH deploy:events' | socat - TCP:localhost:5678
# Response: req-10 OK

# Redis runner (RPUSH)
echo 'req-11 RULE SET rule.queue backup. redis redis://127.0.0.1:6379/0 RPUSH backup:tasks' | socat - TCP:localhost:5678
# Response: req-11 OK

Pattern Matching

Rules match jobs by prefix: a rule with pattern backup. matches any job whose identifier starts with backup. (e.g., backup.daily, backup.weekly).

When multiple rules match a job, the rule with the longest matching pattern wins.

String Parsing

Arguments are space-separated. Quoted strings preserve spaces:

req-1 RULE SET rule.1 app. shell "/usr/bin/command --arg 'value'"
                            ├────────────────────────────────────┘
                            └─ Entire quoted string is one argument

Escaping inside quoted strings:

  • \""
  • \\\

Examples

Full Session

# Create a rule for backup jobs
echo 'r1 RULE SET rule.backup backup. shell /usr/bin/backup.sh' | socat - TCP:localhost:5678
# r1 OK

# Schedule a backup job for a specific datetime
echo 'r2 SET backup.daily 2026-03-30 02:00:00' | socat - TCP:localhost:5678
# r2 OK

# Schedule another job with nanosecond timestamp
echo 'r3 SET backup.weekly 1711872000000000000' | socat - TCP:localhost:5678
# r3 OK

# Retrieve the job's state
echo 'r4 GET backup.daily' | socat - TCP:localhost:5678
# r4 OK planned 1743303600000000000

# Query all backup jobs
echo 'r5 QUERY backup.' | socat - TCP:localhost:5678
# r5 backup.daily planned 1743303600000000000
# r5 backup.weekly planned 1711872000000000000
# r5 OK

# List all configured rules
echo 'r6 LISTRULES' | socat - TCP:localhost:5678
# r6 rule.backup backup. shell /usr/bin/backup.sh
# r6 OK

# Check server health
echo 'r7 STAT' | socat - TCP:localhost:5678
# r7 uptime_ns 120000000000
# r7 connections 1
# r7 jobs_total 1
# r7 jobs_planned 1
# ... (15 metrics total)
# r7 OK

# Remove the weekly backup job
echo 'r8 REMOVE backup.weekly' | socat - TCP:localhost:5678
# r8 OK

# Remove the backup rule
echo 'r9 REMOVERULE rule.backup' | socat - TCP:localhost:5678
# r9 OK

Batch Operations

{
  echo 'r1 RULE SET rule.jobs job. shell "/bin/echo done"'
  echo 'r2 SET job.1 2026-03-30 12:00:00'
  echo 'r3 SET job.2 2026-03-30 13:00:00'
  echo 'r4 SET job.3 2026-03-30 14:00:00'
} | socat - TCP:localhost:5678

Each command returns its own response:

r1 OK
r2 OK
r3 OK
r4 OK

Authenticated Session

When auth_file is configured, authenticate before sending commands:

# Authenticate, then create and query jobs within namespace
{
  echo 'AUTH sk_deploy_a1b2c3d4e5f6'
  echo 'r1 SET deploy.release.v1 2026-04-01 12:00:00'
  echo 'r2 QUERY deploy.'
} | socat - TCP:localhost:5678
# OK
# r1 OK
# r2 deploy.release.v1 planned 1743508800000000000
# r2 OK

Python Client

import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('localhost', 5678))

# Authenticate (only when auth_file is configured)
# sock.send(b'AUTH sk_deploy_a1b2c3d4e5f6\n')
# print(sock.recv(1024).decode())  # OK

# Create a rule
sock.send(b'r1 RULE SET rule.app app. shell "/bin/echo hello"\n')
print(sock.recv(1024).decode())  # r1 OK

# Schedule a job
sock.send(b'r2 SET app.task.1 2026-03-30 14:00:00\n')
print(sock.recv(1024).decode())  # r2 OK

# Query jobs by prefix
sock.send(b'r3 QUERY app.\n')
print(sock.recv(4096).decode())
# r3 app.task.1 planned 1743350400000000000
# r3 OK

# List all rules
sock.send(b'r4 LISTRULES\n')
print(sock.recv(4096).decode())
# r4 rule.app app. shell /bin/echo hello
# r4 OK

# Check server health
sock.send(b'r5 STAT\n')
print(sock.recv(4096).decode())
# r5 uptime_ns 5000000000
# r5 connections 1
# ... (15 metrics total)
# r5 OK

sock.close()