# Protocol Reference<no value>

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](configuration.md) for TLS setup.

## TCP Client Tools

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

| Tool | Plaintext | TLS | Example |
|------|-----------|-----|---------|
| **socat** (recommended) | `socat - TCP:host:port` | `socat - OPENSSL:host:port` | `echo 'r1 GET job.1' \| socat - TCP:localhost:5678` |
| **openssl s_client** | N/A | `openssl s_client -connect host:port -quiet` | For TLS debugging and verification |
| **curl** | `curl telnet://host:port` | N/A | Quick interactive sessions |
| **bash built-in** | `cat > /dev/tcp/host/port` | N/A | No 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
```

| Status | Meaning |
|--------|---------|
| `OK` | Command succeeded |
| `ERROR` | Command 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:

| Code | Triggered By | Example | Notes |
|------|--------------|---------|-------|
| `not_found` | GET/REMOVE/REMOVERULE for non-existent ID | `req-1 ERROR not_found job "backup.daily" does not exist` | Single-entity lookups only; QUERY with no matches returns OK with empty body |
| `invalid_args` | Missing or malformed required arguments | `req-2 ERROR invalid_args missing required argument: timestamp` | Applies to SET, GET, RULE SET, QUERY, etc. when args are incomplete |
| `auth_required` | Non-AUTH command sent before authentication (when auth enabled) | `ERROR auth_required` | Connection closed after response; no request ID, no message |
| `auth_failed` | AUTH sent with unrecognized token | `ERROR auth_failed` | Connection closed after response; no request ID, no message to prevent token enumeration |
| `auth_denied` | Command targets identifier outside token's namespace scope | `req-3 ERROR auth_denied insufficient namespace scope` | Returned for commands after successful auth when targeting out-of-scope namespace |
| `internal` | Unexpected server failure (rare) | `req-4 ERROR internal` | No implementation details, file paths, or stack traces in message |

### Connection and Protocol Errors

| Condition | Behavior |
|-----------|----------|
| Malformed line (invalid syntax) | Silently skipped, no response sent, connection stays open |
| Incomplete line (no newline yet) | Server waits for more data |
| Unrecognized command | Silently ignored, no response sent (see below) |
| Out of memory | Connection closed |
| AUTH timeout (5s) | Connection closed when auth is enabled |
| Non-AUTH command before authentication | `ERROR 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**:
```bash
# 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**:
```bash
# 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`

| Field | Description |
|-------|-------------|
| `status` | Job status: `planned`, `triggered`, `executed`, or `failed` |
| `execution_ns` | Execution timestamp in nanoseconds since Unix epoch |

**Examples**:
```bash
# 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`

| Field | Description |
|-------|-------------|
| `job_id` | The matching job's identifier |
| `status` | Job status: `planned`, `triggered`, `executed`, or `failed` |
| `execution_ns` | Execution timestamp in nanoseconds since Unix epoch |

**Examples**:
```bash
# 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`

| Field | Description |
|-------|-------------|
| `rule_id` | The rule's identifier |
| `pattern` | Prefix pattern the rule matches against |
| `runner_type` | Runner type: `shell`, `direct`, `http`, `awf`, `amqp`, or `redis` |
| `runner_args` | Shell: `<command>`. Direct: `<executable> [args...]`. HTTP: `<method> <url>`. AWF: `<workflow> [--input key=value ...]`. AMQP: `<dsn> <exchange> <routing_key>`. Redis: `<url> <command> <key>` |

**Examples**:
```bash
# 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`

| Metric | Description |
|--------|-------------|
| `uptime_ns` | Server uptime in nanoseconds since startup |
| `connections` | Number of active TCP connections |
| `jobs_total` | Total number of jobs in storage |
| `jobs_planned` | Count of jobs with status `planned` |
| `jobs_triggered` | Count of jobs with status `triggered` |
| `jobs_executed` | Count of jobs with status `executed` |
| `jobs_failed` | Count of jobs with status `failed` |
| `rules_total` | Total number of rules in storage |
| `executions_pending` | Number of jobs queued for execution |
| `executions_inflight` | Number of jobs currently being executed |
| `persistence` | Backend type: `logfile` or `memory` |
| `compression` | Compression process status: `idle`, `running`, `success`, or `failure` |
| `auth_enabled` | `1` if authentication is configured, `0` otherwise |
| `tls_enabled` | `1` if TLS is configured, `0` otherwise |
| `framerate` | Configured scheduler tick rate |

**Examples**:
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
# 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

```bash
# 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

```bash
{
  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:

```bash
# 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

```python
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()
```
