---
name: delayedi
description: Schedule and trigger WhatsApp messages by cron, interval, once, or /run <slug> webhook. Scripts written in Bash/Sh/Python/Node/Bun/Deno/Rust produce JSON that feeds message templates. This skill teaches you how to install the CLI, configure it, and author scripts + crons through it.
---

# delayedi — WhatsApp scheduler & script runner

delayedi lets you run scripts on a schedule and pipe their JSON output into
templated WhatsApp messages. This skill teaches an agent how to drive it via
the `delayedi` CLI.

- **Production URL**: `https://delayedi.oino.dev`
- **API base**: `https://delayedi.oino.dev/api`
- **CLI binary**: `delayedi` (single Rust binary, no runtime deps)
- **Auth**: single API key, sent as `Authorization: Bearer <key>` header
  (CLI reads it from `~/.config/delayedi/config.json`)

## 1. Install

One-liner, auto-detects OS/arch (Linux/macOS x86_64 + arm64, Windows amd64):

```bash
curl -fsSL https://delayedi.oino.dev/install.sh | sh
```

Installs to `/usr/local/bin` when writable/root, else `~/.local/bin`. If the
target dir isn't on `PATH`, the script prints the exact `export` line.

Environment overrides:

```bash
# pin a version
curl -fsSL https://delayedi.oino.dev/install.sh | DELAYEDI_VERSION=v0.1.0 sh
# custom dir
curl -fsSL https://delayedi.oino.dev/install.sh | DELAYEDI_INSTALL_DIR=~/bin sh
```

Verify: `delayedi --version` prints `delayedi 0.1.0` (or the pinned tag).

### Update to a newer version

Same one-liner — the installer just overwrites the existing binary in place:

```bash
curl -fsSL https://delayedi.oino.dev/install.sh | sh
```

By default the installer resolves `releases/latest` from Gitea, which
ignores pre-releases. To grab a pre-release (or downgrade), pin the tag:

```bash
curl -fsSL https://delayedi.oino.dev/install.sh | DELAYEDI_VERSION=v0.3.0 sh
```

Your config (`~/.config/delayedi/config.toml`) is left untouched — you
keep your server_url + api_key across upgrades.

## 2. Configure

Ask the human operator for the API key (do not guess). Then:

```bash
delayedi config set server_url https://delayedi.oino.dev
delayedi config set api_key    <the-key-they-give-you>
```

Config file lives at the path printed by `delayedi config path`.

You can also pass credentials per-invocation:

```bash
delayedi --server-url https://delayedi.oino.dev --api-key <key> health
# or via env:
DELAYEDI_SERVER_URL=... DELAYEDI_API_KEY=... delayedi health
```

Quick sanity checks:

```bash
delayedi health                    # should print {"status":"ok"} — public
delayedi whatsapp sessions list    # requires auth; lists sessions
```

## 3. Error handling (important for agents)

Every failure prints exactly:

```
error: <kind> (HTTP <status>): <server-message-or-detail>
hint:  <what to do next>
```

Stable exit codes for programmatic decisions:

| Code | Kind          | Meaning |
|------|---------------|---------|
| 0    | ok            | success |
| 2    | bad_arg       | invalid CLI argument value |
| 3    | config        | server_url / api_key missing or unreadable |
| 4    | auth          | api key rejected (HTTP 401) |
| 5    | not_found     | resource id doesn't exist (HTTP 404) |
| 6    | validation    | server rejected the body (HTTP 400/422) |
| 7    | server        | server 5xx |
| 8    | unreachable   | DNS / TCP / TLS failed |
| 9    | decode        | server returned unexpected JSON |
| 1    | other         | catch-all |

Set `DELAYEDI_ERROR_JSON=1` to get machine-readable output:
`{"ok":false,"kind":"not_found","exit_code":5,"message":"..."}`.

If you see `hint:` lines, **read them** — they tell you the exact next action.

## 4. Concepts

- **Session** — a WhatsApp connection identified by a UUID
  (`session_id`) and given a human alias. Managed at the openwa layer,
  registered with delayedi via `whatsapp sessions`.
- **Script** — code that produces **a single line of JSON on stdout**. Runs
  under `mise exec` (Python/Node/Bun/Deno/Rust) or directly (Bash/Sh).
  Optional `slug` makes it triggerable via `/run <slug>` webhook.
  Optional `reply_template` renders the JSON into the WhatsApp reply.
  Optional `tag` for grouping in the UI.
- **Cron** — links (`whatsapp_session`, optional `script_id`, targets,
  message templates) with a schedule (`cron | interval | once`).
  Optional `tag` for grouping.
- **Named recipient** — friendly `@name` alias for a chat/group JID.
  Users claim by sending a one-time code from the chat itself. Useful
  because groups have no phone number.

## 5. Sandbox / security note

**Scripts run in a sandboxed environment with no ambient env, no filesystem
persistence, no bind-mounted secrets.** You must therefore:

- **Bake all secrets** — API tokens, SSH private keys, DB URLs, HMAC keys,
  master keys, etc. — **directly as constants inside the script source**.
  This looks insecure but is fine: the script body is stored server-side
  behind API-key auth and executed in an isolated per-run tempdir.
- Never rely on process env unless it was set via the cron or webhook
  invocation itself (`DELAYEDI_TRIGGER`, `DELAYEDI_SENDER_JID`,
  `DELAYEDI_ARGS`, `DELAYEDI_ARGS_JSON`, `DELAYEDI_SESSION_ID` for
  webhook `/run`).
- Don't `read` from `~/.aws`, `~/.ssh`, or expect any external mount to
  exist. Write files you need into `std::env::temp_dir()` /
  `mktemp -d` and clean them up before exit.
- Multi-line strings (PEM keys) can be embedded verbatim — see the Rust
  example below.

## 6. Script authoring — required output contract

**stdout MUST be a single well-formed JSON value.** Newlines inside strings
are fine, but nothing else may leak to stdout (log to stderr instead). The
result is parsed by the scheduler and referenced from message templates via
`{$.path.to.value}` (dot-notation; array indexing works too).

Bad output → cron history is marked `failed` with the parse error.
Non-zero exit → `failed` with stderr in the error message.
Empty stdout on exit 0 → `failed` ("expected JSON, got nothing").

## 7. Runtime specs

Set via `runtime` field, JSON-encoded (that's what clap sees on the CLI):

| Runtime label | JSON value                 | Interpreter (under mise) |
|---------------|----------------------------|--------------------------|
| Bash          | `"Bash"`                   | `bash script.sh`         |
| Sh            | `"Sh"`                     | `sh script.sh`           |
| Python        | `{"Python":"latest"}` or `{"Python":"3.12"}` | `python3 script.py` |
| Node          | `{"Node":"lts"}` or `{"Node":"20"}` | `node script.js` |
| Bun           | `{"Bun":"latest"}`         | `bun run script.js`      |
| Deno          | `{"Deno":"latest"}`        | `deno run --allow-all script.ts` |
| Rust          | `{"Rust":"latest"}`        | `rust-script script.rs` — needs the special preamble below |

### Bash / Sh

```bash
#!/usr/bin/env bash
set -euo pipefail
echo -n '{"score":42,"user":"white"}'
```

Note `echo -n` — you don't want a trailing newline confusing the JSON
parser (it works either way but is cleaner).

### Python

```python
import json, urllib.request, datetime
with urllib.request.urlopen("https://api.example.com/thing", timeout=10) as r:
    data = json.loads(r.read())
print(json.dumps({"key": data["field"], "at": datetime.datetime.now().isoformat()}))
```

### Node / Bun / Deno

Same idea; use `fetch` (native in all three) or `node:https`. Emit one
`console.log(JSON.stringify({...}))` at the very end.

### Rust (rust-script) — this one has ceremony

`rust-script` compiles a single-file Rust program on-the-fly. **The
preamble is mandatory** — it declares the crate manifest inside a comment
block. Compilation is cached on the server, so subsequent runs are fast.

Minimal template:

```rust
#!/usr/bin/env rust-script
//! ```cargo
//! [dependencies]
//! ureq = { version = "2", features = ["json"] }
//! serde_json = "1"
//! ```

use serde_json::json;

fn main() {
    // do work…
    let payload = json!({ "user": "white", "score": 42 });
    println!("{}", payload);
}
```

For a package-style script (edition, features, custom name):

```rust
#!/usr/bin/env rust-script
//! ```cargo
//! [package]
//! edition = "2021"
//! [dependencies]
//! hmac = "0.12"
//! sha2 = "0.10"
//! hex = "0.4"
//! ureq = { version = "2", features = ["json"] }
//! chrono = { version = "0.4", features = ["clock"] }
//! ```
```

**Baked secrets** (per the sandbox note) can be verbatim `r#"..."#` raw
strings — including multiline PEM keys:

```rust
const PRIVATE_KEY: &str = r#"-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAy...
...
-----END RSA PRIVATE KEY-----
"#;

const API_TOKEN: &str = "sk-live-abc123...";
```

Never emit prose to stdout even on error — always emit a JSON with an
`error` field and `exit(1)` so the scheduler can log it cleanly:

```rust
fn main() -> std::process::ExitCode {
    match run() {
        Ok(v)  => { println!("{v}"); std::process::ExitCode::SUCCESS }
        Err(e) => { println!("{}", serde_json::json!({"error": e.to_string()})); std::process::ExitCode::from(1) }
    }
}
```

Default `timeout_sec` is 30. Rust scripts should set `timeout_sec` to
`120` or higher on the first run of a new dep set (compilation).

## 8. Script CRUD via CLI

Runtime values must be **JSON-quoted** (clap parses them raw):

```bash
# Bash
delayedi script create \
  --name 'daily-uptime' \
  --runtime '"Bash"' \
  --source 'echo -n {\"up\":true}' \
  --timeout-sec 5

# Rust with a slug (webhook-triggerable via `/run tencent-report`)
delayedi script create \
  --name 'tencent-report' \
  --runtime '{"Rust":"latest"}' \
  --timeout-sec 120 \
  --source "$(cat report.rs)"

# List / get / update / delete
delayedi script list
delayedi script get 6
delayedi script update 6 --name 'tencent-report-v2'
delayedi script delete 6
```

Attach **slug** (webhook trigger), **reply_template** (renders on
`/run`), or **tag** (UI grouping) as flags:

```bash
delayedi script create \
  --name 'tencent-report' \
  --runtime '{"Rust":"latest"}' \
  --timeout-sec 120 \
  --source "$(cat report.rs)" \
  --slug 'tencent-report' \
  --tag  'finance' \
  --reply-template 'Tencent {$.date}: LLM {$.llm} · TKE {$.tke} · total {$.total}'

# Ad-hoc dry-run WITHOUT saving — handy for template iteration.
delayedi script run --name x --runtime '"Bash"' --source 'echo -n {\"ok\":true}'
```

Slug rules: `^[a-z0-9]+(-[a-z0-9]+)*$`, 1–40 chars, reserved
(`help list status cancel ping run`) rejected.
Tag rules: `^[a-z]+(-[a-z]+)*$`, 1–30 chars.

## 9. Cron CRUD via CLI

Three schedule types with a shared `cron_string` field:

- `cron` — 5-field or 6-field cron expression (server normalizes 5→6),
  e.g. `"0 9 * * *"` (daily 9am) or `"0 0 9 * * *"` (with seconds).
  Fires in the server timezone (Asia/Kuala_Lumpur).
- `interval` — an integer number of minutes (e.g. `"15"`).
- `once` — ISO datetime `YYYY-MM-DDTHH:MM:SS` in server local time,
  e.g. `"2027-01-01T09:00:00"`. Auto-disables itself after firing.

Message templates use `{$.path}` — e.g. `{$.date}`, `{$.user.name}`,
`{$.items.0.title}`. Missing keys render as empty string, so guard your
scripts to always emit all referenced keys.

Targets are WhatsApp JIDs:

- `<digits>@c.us` — user (recommended); `+60...`, `60...`, spaces all
  normalized by the frontend but the CLI expects raw JID.
- `<digits>-<ts>@g.us` — group.
- `<digits>@lid` — anonymous/privacy contact.

```bash
delayedi cron create \
  --cron-type cron \
  --cron-string '0 9 * * *' \
  --cron-description 'Daily report' \
  --whatsapp-session 72e6d217-281c-4bee-a930-d40ba73d8923 \
  --primary-target 60183143153@c.us \
  --targets '60183143153@c.us,60999999999@c.us,120@g.us|<other-session-id>' \
  --primary-target-message 'Daily: {$.date} — score {$.score}' \
  --target-message 'FYI: {$.date} score {$.score}' \
  --script-id 6 \
  --is-enabled true

delayedi cron list
delayedi cron get 42
delayedi cron history 42
delayedi cron messages 42
delayedi cron delete 42
```

**Per-target sender override**

Each entry in `--targets` is either `chat_id` or `chat_id|sender_session_id`.
When a `sender` is set, that specific target is sent using that WhatsApp
session instead of the cron's primary `--whatsapp-session`. This is
useful for cross-session groups (a group where only one of your sessions
is a member). Leave the `|...` suffix off to fall back to the primary
sender. The primary target always uses the primary session.

Wire format (server + REST):

```json
{
  "whatsapp_session": "<primary-session-id>",
  "primary_target": "60123@c.us",
  "targets": [
    "60111@c.us",
    { "chat_id": "120-1@g.us", "sender": "<other-session-id>" }
  ]
}
```

Both shapes (bare string → default sender; object with `sender` → override)
are accepted on input; the API returns objects.

Tag flag is available directly:

```bash
delayedi cron create ... --tag finance
delayedi cron update 42 ... --tag finance
```

**Dry-run the full pipeline** (script + template + actual sends) against
an unsaved cron body — the frontend's ▶ Test Full Pipeline button:

```bash
delayedi --pretty cron test-run \
  --cron-type once --cron-string '2027-01-01T00:00:00' \
  --cron-description 'test' \
  --whatsapp-session <session_id> \
  --primary-target 60xxxxxxxxx@c.us \
  --targets 60xxxxxxxxx@c.us \
  --primary-target-message 'Hi {$.who}' \
  --script-id N
```

## 10. Webhook `/run <slug>`

Users can trigger scripts by messaging a session with `/run <slug>`.
The bot reply is:

- If the script defines `reply_template` AND stdout is valid JSON AND
  exit 0 → rendered template.
- Otherwise → raw `[stdout]\n...\n[stderr]\n...` chunked to WhatsApp's
  4096-char message limit with `(1/N)` prefixes. Errors always fall
  through to raw so operators can debug.

### Arged scripts (`accepts_args`)

By default scripts receive **no positional args**. Any tokens after the
slug are silently dropped — so it is always safe to schedule a script
with cron.

Opt-in with `accepts_args: true` (CLI: `--accepts-args`, UI: checkbox in
the script form). When set:

- `/run <slug> a b c` → args `["a","b","c"]` reach the script.
- The script is **webhook-only**. `POST /api/cron` (create / update /
  test-run) with a `script_id` referencing an arged script returns
  `400 script '<name>' accepts args and cannot be scheduled`.
- The frontend's `/cron/new` script dropdown hides arged scripts and
  shows a hint line (`“N scripts hidden…”`).

Validation of arg count / shape is the **script's** job. Write to
`stderr` and `exit 1` on bad input — the webhook chunks stderr straight
back to the WhatsApp chat so the user sees the usage message.

#### How each runtime receives args

All runtimes get positional args after the script path, PLUS the env
vars `DELAYEDI_ARGS` (space-joined) and `DELAYEDI_ARGS_JSON` (JSON
array). Plus `DELAYEDI_TRIGGER=webhook`, `DELAYEDI_SENDER_JID`,
`DELAYEDI_SESSION_ID` on every invocation.

| Runtime | Read args as |
|---|---|
| Bash / Sh | `$1 $2 …`, `$@`, `$#` |
| Python | `sys.argv[1:]` (argv[0] is the script path) |
| Node | `process.argv.slice(2)` |
| Bun | `Bun.argv.slice(2)` |
| Deno | `Deno.args` (already sliced) |
| Rust (rust-script) | `std::env::args().skip(1).collect::<Vec<_>>()` |

Universal fallback (any language): read `DELAYEDI_ARGS_JSON` from env.

Example — required-arg script with a usage hint:

```bash
#!/usr/bin/env bash
symbol="${1:-}"
if [ -z "$symbol" ]; then
  echo "usage: /run report <symbol> [days=7]" >&2
  exit 1
fi
days="${2:-7}"
curl -s "https://api.example.com/$symbol?days=$days"
```

Manage the openwa → delayedi webhook registration from the CLI
(requires `OPENWA_URL` + `OPENWA_API_KEY` env for admin endpoints):

```bash
export OPENWA_URL=https://openwa.io7.my
export OPENWA_API_KEY=owa_k1_...

delayedi webhook list     --session-id 72e6d217-...        # existing hooks
delayedi webhook register --session-id 72e6d217-...        # binds delayedi
delayedi webhook test     --session-id 72e6d217-... --webhook-id <uuid>
delayedi webhook delete   --session-id 72e6d217-... --webhook-id <uuid>

# Rotate the secret (invalidates the current URL immediately):
delayedi whatsapp sessions rotate-webhook-secret --session-id 72e6d217-...

# One-off send that does NOT touch cron/message history:
delayedi whatsapp sessions send-test \
  --session-id 72e6d217-... --target 60123456789@c.us --message 'ping'
```

## 11. Named recipients

Create/manage via CLI:

```bash
delayedi --pretty named-recipient create --name board-updates
# → shows a 6-char code + instructions. Human sends the code from the
#   chat you want to name; delayedi binds it automatically.

delayedi named-recipient list                     # all rows
delayedi named-recipient list --session-id <UUID> # bound-to-session only
delayedi named-recipient reclaim <id>             # new code, clear binding
delayedi named-recipient delete  <id>
```

Response contains a 6-char code the human sends from the target chat.
On successful claim, the row gets bound to `session_id` + `chat_id` and
the chat receives `✓ registered as "<name>"`. Bad/expired codes are
silently ignored (they look like noise to onlookers).

In the cron form, targets accept `@<name>` and resolve to the stored JID
at submit — great for groups.

## 12. Practical patterns

### Iterating on a script safely

```bash
# 1. Save the script.
delayedi script create --name test --runtime '"Bash"' \
  --source 'echo -n {\"ok\":true}' --timeout-sec 5
# → returns {"id": N, ...}

# 2. Attach it to a *test* cron pointed at YOUR OWN number, type=once,
#    scheduled 30 seconds from now.
FUTURE=$(TZ=Asia/Kuala_Lumpur date -d '+40 seconds' +%Y-%m-%dT%H:%M:%S)
delayedi cron create --cron-type once --cron-string "$FUTURE" \
  --cron-description 'test' \
  --whatsapp-session <session_id> \
  --primary-target 60xxxxxxxxx@c.us \
  --primary-target-message 'test {$.ok}' \
  --script-id N

# 3. Wait, then check.
sleep 60
delayedi cron history <cron_id>
delayedi cron messages <cron_id>
```

The `/api/cron/test-run` endpoint runs the *full* pipeline (script + all
templates + all sends) against an unsaved body — the frontend uses this
for the ▶ Test Full Pipeline button. It's not exposed on the CLI yet;
call it with `curl` if you need dry-run behaviour from a script.

### JSON output shape hygiene

Templates fail-open on missing keys (empty string). Prefer flat shapes,
put derived string fields at the top level rather than deep in nested
objects, and never emit `null` for a field a template references — use
an empty string or a default so previews look right.

## 13. Where things live

- Repo: `https://tea.neuron.my/white/delayedi`
- Server: `172.237.80.247` (Linode, systemd unit `delayedi.service`,
  binary at `/opt/delayedi/server`, SQLite at
  `/opt/delayedi/delayedi.db`)
- Frontend: served by Caddy at `delayedi.oino.dev` from
  `/var/www/delayedi/`. Same host proxies `/api/*` to the Rust server on
  `127.0.0.1:3000`.
- openwa: `https://openwa.io7.my` (upstream WhatsApp gateway). Sends via
  `POST /api/sessions/{sid}/messages/send-text`, webhook received via
  `POST /api/sessions/{sid}/webhooks` with `{"url": "..."}`.
