Iris transports things. Rite reacts to things.
Rite is a self-hostable, minimal event-to-action runtime. It receives authenticated events, normalizes them, matches TOML-configured handlers, and forwards matching events to actions.
GitHub webhook ──> Rite ──> configured HTTP action
Uptime Kuma webhook ──> Rite ──> configured HTTP action
Iris event ──> Rite ──> configured HTTP action
Create rite.toml:
[[rites]]
name = "github-pr-opened"
source = "github"
match = { event_type = "pull_request", action = "opened" }
action = { type = "http_post", url = "https://example.test/hooks/pr" }
[sources.iris]
enabled = true
base_url = "http://127.0.0.1:3000"
# Required when this ingress source is enabled.
[sources.uptime_kuma]
enabled = true
secret = "change-me"Run it:
RITE_GITHUB_WEBHOOK_SECRET=change-me cargo run -p rite-cli -- --config rite.tomlEndpoints:
GET /health— returnsokGET /status— live process-local event/action counters (HTTP-only)GET /sources— configured source adaptersPOST /event/github— authenticated GitHub webhook ingress usingX-Hub-Signature-256POST /event/uptime_kuma— authenticated Uptime Kuma webhook ingress using base64SignatureHMAC-SHA256
GET /status returns a JSON snapshot from the running Rite process with exactly
these fields: events_received, events_matched, actions_succeeded,
actions_failed, uptime_seconds, and handlers_loaded. The counters and
uptime are process-local and reset when Rite restarts; they are not persisted,
aggregated, or a view into another running server.
Status is intentionally HTTP-only. rite-cli reads local configuration rather
than connecting to a running server, so it does not expose potentially
misleading live metrics; the MCP manifest likewise has no status tool.
Rite's agent-facing read operations are generated from api/operations.yaml and run through the
same execute_operation dispatch as HTTP. They load the local configuration and print JSON, so
no HTTP server or webhook secret is required:
cargo run -p rite-cli -- list-sources --config rite.example.toml
cargo run -p rite-cli -- list-handlers --config rite.example.toml
cargo run -p rite-cli -- get-handler github-pr-opened --config rite.example.tomlrite serve --config rite.toml --github-webhook-secret "$RITE_GITHUB_WEBHOOK_SECRET" explicitly
starts the server; omitting the subcommand remains equivalent for compatibility. The generated
generated/mcp.json declares the same read operations for MCP hosts over the existing HTTP
endpoints: GET /sources, GET /handlers, and GET /handlers/{handler_id}. No separate server
surface is needed.
When enabled, the Iris source subscribes to GET /v1/events in the background. Set
sources.iris.api_token (or RITE_IRIS_API_TOKEN in Docker) when Iris requires bearer
authentication; Rite sends it only as the subscription's Authorization: Bearer header, including
on reconnects. Without a token Rite warns at startup so legacy unauthenticated Iris deployments
remain supported. It reconnects with exponential backoff (1–60 seconds), so Rite remains healthy
while Iris is unavailable. Iris messages become source = "iris" events. event_type is the Iris message kind and metadata
contains provider, source_id, sender, kind, iris_metadata, and the complete original
Iris message under message for future matching needs.
Published images are available from GitHub Container Registry after a release tag:
ghcr.io/techgodhq/rite:<version> (or :latest). Supply a webhook secret and,
when Rite should subscribe to Iris events, its private Iris URL:
docker run --rm \
--name rite \
--publish 127.0.0.1:8080:8080 \
--env RITE_GITHUB_WEBHOOK_SECRET="${RITE_GITHUB_WEBHOOK_SECRET}" \
--env RITE_IRIS_BASE_URL="http://iris.internal:9876" \
--env RITE_IRIS_API_TOKEN="${RITE_IRIS_API_TOKEN}" \
ghcr.io/techgodhq/rite:latestRITE_GITHUB_WEBHOOK_SECRET is required for authenticated GitHub webhook
ingress. Omit RITE_IRIS_BASE_URL when you do not want an Iris subscription. Set
RITE_IRIS_API_TOKEN whenever that Iris instance has IRIS_API_TOKEN configured; it is not
written to logs.
For a complete private-network example with both services, use the Iris
repository's deploy/docker-compose.yml.
http_post forwards the normalized event as JSON by default. A handler may
instead set body_template to render a deterministic text body using
{{event_type}}, {{action}}, {{title}}, {{body}}, or nested event
metadata such as {{metadata.provider}}; missing fields render empty. Optional
headers are forwarded verbatim. Templated bodies default to
Content-Type: text/plain unless that header is explicitly set.
For a JSON API that needs a custom object, use body_json instead of
body_template (the two modes are mutually exclusive). It accepts nested TOML
tables and arrays, strings, booleans, integers, finite floats, and scalar roots.
TOML datetimes and non-finite floats are rejected; TOML has no null value, and
Rite does not invent a null sentinel. Only configured string leaves are
interpolated. The supported placeholders are the same event fields above;
malformed or unknown placeholders are rejected while loading configuration.
Each inserted value is treated as opaque string data and is never rescanned or
parsed as JSON. A missing or null referenced value fails that action without
sending it; non-string metadata is represented as JSON text within the string
leaf. Static object keys, URLs, headers, and credentials remain configuration
owned.
Structured mode sets Content-Type: application/json by default. An explicit
content type may be application/json or application/json; charset=utf-8
(case-insensitive; quoted UTF-8 is also accepted). Other media types, parameters,
charsets, or duplicate case-insensitive content-type headers are rejected during
configuration loading. These rules apply only to body_json; legacy text and
no-template request behavior is unchanged.
[[rites]]
name = "deploy-alert"
source = "iris"
match = { body_contains = "URGENT" }
action = { type = "http_post", url = "https://hooks.example.test/alerts", body_template = "{{title}}: {{body}}" }
[[rites]]
name = "structured-iris-message"
source = "iris"
match = { event_type = "text" }
action = { type = "http_post", url = "https://api.example.test/messages/synthetic-thread", body_json = { body = "{{body}}", provider = "telegram.synthetic" } }The second example keeps the provider instance fixed in configuration while preserving arbitrary message text as a JSON string. Rite remains a generic HTTP action runner and does not add provider-specific action types.
Flat match tables AND every entry. For OR or negation, compose conditions
with all_of, any_of, and not — arbitrarily nestable, with leaf keys
keeping exactly the semantics above:
[[rites]]
name = "page-on-critical"
source = "iris"
match = { any_of = [{ severity = "critical" }, { body_contains = "URGENT" }] }
action = { type = "http_post", url = "https://hooks.example.test/pages" }
[[rites]]
name = "real-prs-only"
source = "github"
match = { all_of = [{ event_type = "pull_request" }, { not = { draft = true } }] }
action = { type = "http_post", url = "https://hooks.example.test/reviews" }
[[rites]]
name = "inbox-attention"
source = "iris"
match = { all_of = [{ path_equals = { path = ["metadata", "message", "source"], value = "agent_hook" } }, { path_equals = { path = ["metadata", "message", "metadata", "schema_version"], value = 1 } }, { path_equals = { path = ["metadata", "message", "metadata", "installation_id"], value = "synthetic-alpha" } }, { path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "attention_required" } }, { path_equals = { path = ["metadata", "message", "metadata", "direction"], value = "inbound" } }, { path_equals = { path = ["metadata", "message", "metadata", "content_opt_in"], value = true } }] }
action = { type = "http_post", url = "https://hooks.example.test/inbox" }path_equals takes a literal array of object keys rooted at the normalized
RiteEvent JSON shape and a scalar string, integer, or boolean value. It never
splits keys on dots, traverses arrays, or infers a source/provider. A missing
path, null, or wrong JSON type is a non-match; malformed paths and
non-scalar expected values are rejected while loading configuration.
all_of and any_of take nonempty arrays of condition tables; not takes
one condition table. A table mixing operators with other keys — or using two
operators as siblings — is rejected at config load with an actionable error.
Structured values must use their declared grammar: an inline table under
all_of/any_of, an array under not, or any table/array under an ordinary
leaf key is rejected rather than silently becoming a non-matching leaf. A
scalar under an operator-named key (for example, not = "draft") remains an
ordinary metadata lookup exactly as before, so existing scalar configurations
never change meaning.
cargo build --all-targets
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --checkRite normalizes GitHub push and pull_request payloads plus Uptime Kuma heartbeats, matches configured handlers, and executes http_post actions with the normalized event as JSON. Uptime Kuma heartbeats require a configured secret; status maps deterministically to up (info), down (critical), pending (warning), or maintenance (info). Matching-safe metadata includes monitor_name, monitor_id, status, plus present URL/timing fields; Kuma's msg becomes the optional event body.