Webhook receiver for Sylvode. It receives webhook deliveries from a Sylvode deployment and dispatches them to AI agents, chat platforms or external services.
Formerly OpenPR Webhook. The project was renamed together with the main product. Every old name (executable, container image, signature header, environment variable, service unit) keeps working for the whole Sylvode 1.x line and is not removed before Sylvode v2.0; see Migrating from OpenPR Webhook.
Built with Rust (Axum).
Sylvode ──webhook POST──▶ sylvode-webhook ──dispatch──▶ OpenClaw (Signal/Telegram)
──dispatch──▶ HTTP endpoint
──dispatch──▶ Custom command
──dispatch──▶ CLI agent (codex/claude-code)
│
▼
Sylvode MCP server
(read issue → fix → write back)
- Sylvode sends a signed webhook delivery for one of the events a webhook subscribes to, or its worker dispatches a queued AI task to the webhook of the bot it is assigned to
- sylvode-webhook verifies the HMAC-SHA256 signature
- It acts only on bot tasks: a delivery whose
bot_context.is_bot_taskistrue, or a worker AI task envelope (task_idandai_participant_id). Anything else is answered{"status":"ignored","reason":"not_bot_task"} - It selects exactly one agent (see Agent selection) and dispatches to it
A webhook can subscribe to these 14 events, the full list the Sylvode API accepts:
| Group | Events |
|---|---|
| Issues | issue.created, issue.updated, issue.assigned, issue.deleted, issue.state_changed |
| Comments | comment.created, comment.updated, comment.deleted |
| Labels | label.added, label.removed |
| Sprints | sprint.started, sprint.completed |
| AI tasks | ai.task_completed, ai.task_failed |
A delivery carries bot_context only when the webhook has a bot user and that bot is an assignee
of the issue, or, for comment.created, is mentioned in the comment.
- HMAC-SHA256 signature verification — Validates webhook authenticity
- Agent routing — Each bot task goes to exactly one agent, chosen by bot identity, agent type and optional route constraints (project type, trigger kind, event, form key)
- Agent types:
openclaw— Send the rendered message via the OpenClaw CLI (openclaw message send)openprx— Send the rendered message via an OpenPRX Signal API or a commandwebhook— Forward the delivery JSON unchanged to an HTTP endpointcustom— Run a command with the rendered message as an argumentcli— Run codex, claude-code or opencode with a rendered prompt (fixed executor whitelist)
- MCP closed-loop automation — AI agents read full issue context (description, comments, labels) via Sylvode MCP tools and write results back directly
- CLI callbacks — Report a CLI run back to Sylvode via MCP or the REST API (see Callbacks)
- Per-agent environment variables — Inject extra environment variables into each CLI executor
- WSS tunnel client (Phase B MVP) — Active ws/wss connection with Bearer auth, heartbeat, auto-reconnect. No Sylvode server endpoint exists yet, so it has nothing to connect to (see the tunnel section)
- Tunnel envelope + HMAC — Minimal envelope (
id/type/ts/agent_id/payload/sig) with optional HMAC-SHA256 - Task bridge — Handles
task.dispatch-> immediatetask.ack-> asynctask.result - Message templates — Customizable message and prompt format with placeholders
- Configurable — TOML-based configuration
# Build
cargo build --release
# Configure
cp config.example.toml config.toml
# Set webhook_secrets to the secret of your Sylvode webhook; start-up is refused while the
# example placeholder is still there
# Run
./target/release/sylvode-webhook config.toml
# sylvode-webhook listening on 127.0.0.1:9090The Rust toolchain is pinned in rust-toolchain.toml (1.97.1), which rustup installs on the first
build. The crate's [lints] turn every compiler and clippy warning into an error, so building
with a different compiler version (cargo +stable build, or cargo install outside this
directory) can fail on a lint that version added.
The example configuration is secure by default: it listens on loopback only, verifies every
signature (allow_unsigned = false) and contains no agent that runs a command without
[features].cli_enabled. Change listen once the secret is set, or put a reverse proxy in front.
sylvode-webhook [CONFIG] reads config.toml in the working directory when no path is given.
--help and --version print usage and the version.
In Sylvode, create a webhook pointing to this receiver:
- URL:
http://your-server:9090/webhook - Secret: Must match one of
webhook_secretsinconfig.toml - Events: Select which events to receive (see the list above)
- Bot user: The bot whose tasks this receiver handles. Without one, deliveries carry no
bot_contextand are ignored, and the worker has no webhook to dispatch that bot's AI tasks to
[server]
listen = "0.0.0.0:9090"
[security]
webhook_secrets = ["a-long-random-secret"] # blank entries and example placeholders refuse start-up
allow_unsigned = false # true only for local development; see below
max_delivery_age_secs = 300 # refuse signed deliveries whose timestamp is further from now; 0 = off
# Feature gates (safe defaults). A cli agent does nothing unless cli_enabled is
# true, and no callback is sent unless callback_enabled is true.
[features]
tunnel_enabled = false
cli_enabled = false
callback_enabled = false
[runtime]
cli_max_concurrency = 1 # cli executors running at once, webhook and tunnel together; others wait
http_timeout_secs = 15 # outbound HTTP; commands get max(this, 60) seconds
tunnel_reconnect_backoff_max_secs = 60
# Agent: OpenClaw (AI assistant via Signal/Telegram)
[[agents]]
id = "david"
name = "David"
agent_type = "openclaw"
message_template = "🔔 [{project}] {event}: {key} {title}\n👤 {actor} | Trigger: {reason}"
[agents.openclaw]
command = "openclaw"
args = ["message", "send"] # runs: openclaw message send --channel ... --target ... --message ...
channel = "signal"
target = "uuid:your-user-uuid"
# Agent: OpenPRX (AI assistant via Signal)
[[agents]]
id = "vano"
name = "Vano"
agent_type = "openprx"
message_template = "[{project}] {event}: {key} {title}\n{actor} | {reason}"
[agents.openprx]
signal_api = "http://127.0.0.1:8686"
account = "+1234567890"
target = "uuid:your-user-uuid"
# Or send through a command instead (used only when signal_api is not set):
# command = "openprx"
# args = ["message", "send"]
# channel = "signal" # default
# Agent: forward the delivery JSON, unchanged, to an HTTP endpoint.
# No message is rendered, so message_template has no effect here.
[[agents]]
id = "review-bot"
name = "Document review connection"
agent_type = "webhook"
[agents.webhook]
url = "https://automation.example.com/hooks/sylvode"
secret = "optional-shared-secret" # if set, the X-Webhook-Signature header is added
# Optional: route constraints (see "Agent selection"). Every non-empty list
# must contain the task's value; empty or omitted lists mean "no constraint".
[agents.route]
bot_names = ["Document review connection"]
bot_agent_types = ["webhook"]
project_types = ["contract_review"]
trigger_kinds = ["assigned", "mentioned"]
events = ["issue.assigned", "comment.created"]
form_keys = ["order", "print_job"]
# Agent: Custom command
[[agents]]
id = "logger"
name = "Logger"
agent_type = "custom"
message_template = "{event} {key}"
[agents.custom]
command = "logger" # one program, no shell: put arguments in args
args = ["-t", "sylvode", "--", "{message}"]
# Agent: CLI executor with MCP closed-loop
[[agents]]
id = "ai-fixer"
name = "AI Issue Fixer"
agent_type = "cli"
[agents.cli]
executor = "claude-code" # codex | claude-code | opencode
workdir = "/path/to/your/repository"
timeout_secs = 900
max_output_chars = 12000
prompt_template = "Fix issue {issue_id}: {title}\nContext: {reason}"
callback = "mcp" # mcp (default) | api
callback_url = "http://127.0.0.1:8090/mcp/rpc" # no callback is sent without it
callback_token = "opr_xxx" # sent as Authorization: Bearer <token>
# Issue state to set through the callback (ignored when skip_callback_state = true):
# update_state_on_start = "in_progress"
# update_state_on_success = "done"
# update_state_on_fail = "todo"
# MCP closed-loop: AI reads full issue context and updates state via MCP tools,
# so skip_callback_state prevents duplicate state updates from the callback.
skip_callback_state = true
# Optional: custom MCP instructions (overrides built-in default).
# mcp_instructions = "Use work_items.get to read issue {issue_id}, then fix it."
# Path to an MCP config for claude-code (--mcp-config flag). Setting it (or
# mcp_instructions, or env_vars) also appends the default MCP instructions.
mcp_config_path = "/path/to/mcp-config.json"
# Extra environment variables injected into the executor subprocess.
# [agents.cli.env_vars]
# HTTPS_PROXY = "http://proxy.internal:3128"allow_unsigned = true turns signature verification off and logs a warning at start-up. On a
listen address other than loopback (exactly 127.0.0.0/8, ::1 or localhost; not
localhost.example, an IPv4-mapped address or any other name) it is refused unless
allow_unsigned_non_loopback = true acknowledges that anyone who can reach the port can trigger
agents. With allow_unsigned = false and no webhook_secrets, every request is answered 401,
and start-up logs a warning saying so.
Start-up is refused, with a message naming the agent, when a route.trigger_kinds value is not one
Sylvode sends, or when an openclaw, openprx or custom command contains whitespace, names
neither an existing file nor a program on PATH, and cannot be split unambiguously (see below).
Commands run without a shell, so command is one program and its arguments go in args. The
one-string form earlier documentation showed, command = "openclaw message send", is still
accepted with a deprecation warning at start-up when it is unambiguous: args is empty, the string
contains no quotes or backslashes, and its first word is a program on PATH or a file. It is then
split on whitespace once, at start-up, and runs as command = "openclaw" with
args = ["message", "send"]; text inserted into arguments later is never split. Because the first
word must be found when the service starts, this form refuses start-up where the program is not on
the service's PATH (a container image without openclaw, say), whereas command = "openclaw"
written separately only fails when a task is dispatched.
A bot task is dispatched to exactly one agent, or to none. The bot key is bot_context.bot_name
(or bot_context.bot_id when the name is empty) for a webhook delivery, and ai_participant_id
(the bot user's id) for a worker AI task. The first match in this order wins, in configuration
order within each step:
- an agent whose
idequals the bot key, or whosenameequals it ignoring case, and whose route matches; - otherwise an agent whose
agent_typeequals the bot's agent type (bot_context.bot_agent_type, which Sylvode sets tocustomfor a bot without one, orai_participant_agent_typefor a worker task) and whose route matches; - otherwise an agent that has an
[agents.route]section and whose route matches.
For a worker AI task, the bot's agent type is ai_participant_agent_type only; the task's inner
payload never chooses it. The issue that the prompt names and every callback writes to depends on
the task's reference_type:
reference_type |
Queued by Sylvode when | Issue |
|---|---|---|
work_item |
an issue is created with, or reassigned to, the bot | reference_id; payload.issue_id is ignored |
comment |
a comment mentions the bot | payload.issue_id (reference_id is the comment) |
proposal, none, other |
a proposal opens for voting, or a task created by hand | none (unknown) |
For comment tasks the issue comes from the payload because the task carries it nowhere else;
Sylvode's comment route sets it to the issue the comment was posted on. Residual trust: a project
admin can create a task by hand (POST /api/v1/projects/{project_id}/ai/tasks) with any reference
and any payload, so for such a task the issue is whatever that admin wrote, in payload.issue_id
for a comment reference and in reference_id for a work_item reference. Only the Sylvode API
can check, when it receives the callback, that the bot may write to that issue.
An agent without a route matches every task. A route matches when every non-empty list contains
the task's value, compared ignoring case and surrounding whitespace; a value missing from the task
fails a non-empty list. bot_names and bot_ids together form one constraint that either list can
satisfy. When nothing matches, the answer is {"status":"no_agent", ...} and nothing runs.
trigger_kinds is compared with bot_context.trigger_reason (or bot_context.trigger_kind) on a
webhook delivery and with the top-level trigger_kind on a worker AI task, and with nothing else: a
task without that field fails a non-empty trigger_kinds. These are the values Sylvode sends; any other value refuses
start-up:
| Source | Values |
|---|---|
Webhook delivery (bot_context.trigger_reason) |
assigned (issue.assigned, issue.updated and every event not listed here), created (issue.created), status_changed (issue.state_changed), mentioned (comment.created), completed (ai.task_completed), failed (ai.task_failed) |
Worker AI task (trigger_kind) |
assigned (issue_assigned), mention (review_requested, comment_requested), proposal_vote (vote_requested), manual (anything else) |
events is compared with the delivery's event (a worker AI task has none, so a route with
events never matches one). project_types is compared with a project_type field, which
Sylvode currently sends only in the payload of a worker AI task for a bot mentioned in a comment;
form_keys with a form_key field, which no current Sylvode bot task carries. A non-empty list
for either of them therefore matches only those tasks, or none.
openclawrunscommand [args...] --channel <channel> --target <target> --message <message>.openprxposts{"recipients": [target], "message": message}to<signal_api>/api/v1/send/<account>; withoutsignal_apiit runscommandlikeopenclaw.customrunscommandwithargs. Every argument containing{message}has it replaced by the rendered message and stays one argument, whatever the message contains; without{message}in any argument the message is appended as the last argument. If text from Sylvode would make an argument start with-(so it could act as an option), the command is not run, unless an earlier argument is--. A leading-you write yourself, in an argument such as--text={message}or at the start ofmessage_templatesuch as- {title}, is left alone.webhookposts the delivery JSON unchanged tourl, signed withX-Webhook-Signature: sha256=<hex>whensecretis set.cliruns the whitelisted executor with the rendered prompt in the background and answers at once:codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <prompt>,claude -p <prompt> --permission-mode bypassPermissions [--mcp-config <path>]oropencode run <prompt>, inworkdir, stopped aftertimeout_secs(see below). A rendered prompt that starts with-would be read as an option of the executor, so such a run is not started and reportsfailed; beginprompt_templatewith fixed text (the default does).
Commands are run directly, never through a shell, so command names one program and everything
else goes in args. Their stdin is /dev/null; they inherit the service's environment (a cli
agent adds its env_vars).
On Linux and macOS every command (cli executors and the commands of openclaw, openprx and
custom agents) is started as the leader of its own process group. When it times out
(timeout_secs for cli, max(http_timeout_secs, 60) seconds for the others), when the service
shuts down, and also when it exits by itself, the whole group is sent SIGTERM and, if anything
in it is still alive 5 seconds later, SIGKILL. This stops the processes an executor started
(an npm-installed codex launcher's native binary, an agent's tool calls), not only the executor
itself. A process that deliberately leaves its process group (setsid, daemon) is not
reached. The timeout or cancellation callback is sent only after the group is gone.
The group is signalled only while its leader (the command itself) has not been reaped, because
until then the kernel cannot give the group id to another process. On Linux the service notices
the command's exit without reaping it and reads the remaining members from /proc. On macOS a
command's exit can only be noticed by reaping it, so a command that exits by itself is not
followed by signals to its group (anything it left running there keeps running); on a timeout or
shutdown the group gets SIGTERM, the full 5 seconds, then SIGKILL. A group the service is not
permitted to signal (EPERM, such as a set-user-ID program) is reported and left alone instead of
being waited for.
In a container, run the image with an init process (docker run --init, or init: true in
compose): without one, processes orphaned by an executor are never reaped and pile up as
zombies.
On SIGTERM or SIGINT the service stops accepting connections and lets the requests in flight
finish. Every running cli executor (webhook-triggered or tunnel task) is stopped as above and
then sends its terminal callback with status = "cancelled", which sets update_state_on_fail
like a failure, so no issue is left in_progress by a run that no longer exists. On the webhook
path this callback follows the same rules as a failure (it is not sent with
skip_callback_state = true, which also suppresses the start callback). The whole shutdown, the
requests in flight and these callbacks together, takes at most 5 + 2 × http_timeout_secs + 5
seconds (40 seconds by default) from the signal: a request still open then (a client that stalled
half-way, say) is closed, a callback not yet sent is dropped, and the service exits 0. Give the service manager at least that long: systemd's default TimeoutStopSec (90 s)
is enough, docker stop needs --time 45 (its default is 10 s).
| Variable | Effect |
|---|---|
SYLVODE_WEBHOOK_SAFE_MODE |
1/true/yes/on forces the tunnel, CLI and callback paths off at runtime — a one-switch rollback to webhook-only behavior. openclaw, openprx and custom agents still run their commands |
RUST_LOG |
Log filter. This service's events use the sylvode_webhook target, e.g. RUST_LOG=sylvode_webhook=debug; the default is sylvode_webhook=info |
Logs are written to stdout. Deprecation notices for legacy names are written to stderr, once per process.
When a CLI agent has Sylvode MCP tools available (via global config or mcp_config_path), it can autonomously:
- Read full issue context — title, description, comments, labels, state, priority via
work_items.get/comments.list - Fix the problem — analyze context, write code, run tests
- Write results back — post a summary comment via
comments.create, update state viawork_items.update
This eliminates the need for the webhook callback to update issue state (use skip_callback_state = true).
Default MCP instructions are appended to the prompt when the agent has MCP-related config
(mcp_instructions, mcp_config_path, or env_vars); a form event gets form-specific
instructions instead. You can replace them with mcp_instructions. With MCP-related config the
prompt also asks the agent to read the project context (context.get_project) when the task
carries a project id, and describes the form record for a form event.
The Sylvode MCP server reads its API URL, bot token and workspace from its own configuration
file, not from environment variables. Point the agent's MCP client at it with an absolute path; see
the Sylvode README for the [mcp] section.
For Codex, add to ~/.codex/config.toml:
[mcp_servers.sylvode]
type = "stdio"
command = "/path/to/mcp-server"
args = ["serve", "--config", "/absolute/path/to/config/sylvode.toml"]For Claude Code, add to ~/.claude.json:
"sylvode": {
"type": "stdio",
"command": "/path/to/mcp-server",
"args": ["serve", "--config", "/absolute/path/to/config/sylvode.toml"]
}A callback is sent only when [features].callback_enabled = true and the agent has a
callback_url.
- When. For a
cliagent selected from a webhook delivery: before the run, a start callback carryingupdate_state_on_start, if that is set. It is sent in the background once the run has acli_max_concurrencyslot, so the/webhookanswer never waits for it (Sylvode's worker gives up after 10 seconds and retries the task, which would run it again); shutdown abandons a start callback still in flight and the executor is not started. After the run, a final callback only when the run failed, timed out or was cancelled by shutdown (a successful agent reports through MCP itself), and only whenskip_callback_stateisfalse. For a tunnel task: a final callback after every run; withskip_callback_state = trueit carries no state. skip_callback_state = truemeans the agent reports through MCP itself: no callback carries a state, so no start callback is sent, and on the webhook path no callback is sent at all, even for a failure.callback_urlandcallback_tokenare then only used by tunnel tasks.callback = "mcp"(the default) sends JSON-RPCtools/callrequests:comments.createwith an execution report, thenwork_items.updatewhen there is a state to set (update_state_on_success, orupdate_state_on_failfor a failure or timeout).callback = "api"posts the result as JSON (issue_id,run_id,executor,status,summary,exit_code,duration_ms,stdout_tail,stderr_tail,state).
callback_token is sent as Authorization: Bearer <token>.
A plain API callback (callback = "api") reports its surface as cli in both
X-Sylvode-MCP-Surface and the legacy X-OpenPR-MCP-Surface, with the same value, so the API
records it correctly whether it predates the rename or not. Both headers are sent for the whole
Sylvode 1.x line.
When forwarding via agent_type = "webhook" and agents.webhook.secret is configured,
sylvode-webhook signs the outbound JSON body and sends:
- Header:
X-Webhook-Signature - Value format:
sha256=<hex_hmac>
Status: client only, nothing to connect to yet. The tunnel client is implemented and tested
only against an in-process test peer (tests/e2e/tunnel.rs). No Sylvode server endpoint exists
yet: the Sylvode API has no /api/v1/agent-tunnel route (the URL below is a placeholder), and no
server implements the task.dispatch / task.ack / task.result / heartbeat / error
envelopes described here, which are this repository's draft protocol. Enabling the tunnel today
connects to nothing: the client retries with backoff and logs tunnel connect failed. Use the
webhook path for Sylvode.
Enable both [features].tunnel_enabled = true and [tunnel].enabled = true in config.toml to
let sylvode-webhook actively connect to a control plane. The tunnel needs url (wss://, or
ws://), agent_id and auth_token, and its tasks also need [features].cli_enabled = true.
ws:// sends the bearer token and every task unencrypted, so a ws:// URL whose host is not
loopback refuses start-up unless allow_insecure_transport = true acknowledges it.
[tunnel]
enabled = true
url = "wss://sylvode.example.com/api/v1/agent-tunnel" # placeholder: no Sylvode endpoint exists yet
agent_id = "vano-qa" # required; there is no default
auth_token = "opr_xxx" # Authorization: Bearer <token>
reconnect_secs = 3
heartbeat_secs = 20
hmac_secret = "a-long-random-shared-secret" # optional; see "Signature behavior"
require_inbound_sig = false # only changes the error reason; see below
# allow_insecure_transport = true # acknowledge ws:// to a host other than loopbackReconnects back off from reconnect_secs, doubling up to
[runtime].tunnel_reconnect_backoff_max_secs.
Envelope schema (minimal):
{
"id": "uuid",
"type": "task.dispatch|task.ack|task.result|heartbeat|error",
"ts": 1710000000,
"agent_id": "vano-qa",
"payload": {},
"sig": "sha256=<hex>"
}Current task bridge behavior:
- Receive
task.dispatch; its payload may carryrun_id,issue_id,agentandbody - Send
task.ackimmediately (run_id,issue_id,status=accepted) - Run the
cliagent whoseidequalsagent, or the firstcliagent whenagentis absent, onbody(or the whole payload); at most[runtime].cli_max_concurrencyexecutors run at once, counting webhook-triggered runs too - Send
task.resultwhen done (run_id,issue_id,status,summary)
Other envelope types received are ignored.
Signature behavior:
- With
tunnel.hmac_secretset, every outbound envelope carriessig(HMAC-SHA256 over the envelope withoutsig), and every inbound envelope must carry a validsig: one that is missing or wrong is answered with anerrorenvelope (reason: bad_signature, ormissing_signaturewhenrequire_inbound_sigis also set) and dropped. - With
hmac_secretset, a validly signed envelope is also refused (and answered with anerrorenvelope) when itstsis more than 300 seconds from the receiver's clock (stale_envelope) or itsidrepeats one accepted on this connection (duplicate_envelope, the last 4,096 ids are remembered). - Every inbound envelope whose
agent_idis not this agent's is refused (wrong_agent). - A refused envelope of type
erroris dropped without an answer, so two ends that both answer refusals cannot keep answering each other. - Without
hmac_secret, inboundsigis not verified and inbound envelopes are not authenticated: anyone who can impersonate the control plane can dispatch tasks. Usewss://and sethmac_secret. A blank or examplehmac_secretrefuses start-up while the tunnel is enabled, and is a warning otherwise. require_inbound_sigadds nothing to security: withhmac_secretevery envelope must already carry a validsig, and without it start-up is refused. It only makes a missingsigreported asmissing_signatureinstead ofbad_signature.
Safety toggles:
SYLVODE_WEBHOOK_SAFE_MODE=1forcestunnel/cli/callbackoff at runtime.- This provides one-command rollback to legacy webhook-only behavior. It does not stop
openclaw,openprx(command) orcustomagents, which are part of that behaviour and run their configured command for every matching bot task; remove or comment out such agents to stop them.
message_template sets the message of openclaw, openprx and custom agents; webhook agents
send no message and cli agents use prompt_template. Without a template these defaults are used:
| Template | Default |
|---|---|
message_template |
[{project}] {event}: {key} {title} and, on a second line, {actor} | Trigger: {reason} |
prompt_template |
Fix issue {issue_id}: {title} and, on a second line, Context: {reason} |
A template is rendered in one pass: a placeholder below is replaced by its value, any other text is
kept as written, and a placeholder that appears inside a value (such as an issue title containing
{actor}) stays literal.
| Placeholder | Value | In message_template |
In prompt_template |
|---|---|---|---|
{event} |
Event (issue.assigned), or a worker task's task_type |
yes | yes |
{title} |
Issue title (untitled when absent) |
yes | yes |
{reason} |
Trigger reason (assigned, mentioned, ...; see Agent selection) |
yes | yes |
{issue_id} |
Issue id | yes | yes |
{project_id} |
Project id | yes | yes |
{form_id}, {form_key}, {record_id} |
Form identifiers of a form event | yes | yes |
{key} |
Issue key (BIL-4C3B2A19) |
yes | |
{actor} |
Name of the user who caused the event | yes | |
{project} |
Project name | yes | |
{workspace} |
Workspace name | yes | |
{state}, {priority} |
Issue state and priority | yes | |
{url} |
issue/<issue_id> |
yes |
A value that is missing renders as an empty string in message_template (unknown for {event},
{reason} and {actor}, untitled for {title}) and as <project_id>, <form_id>, and so on in
prompt_template (unknown for {issue_id}, {event} and {reason}). In a custom agent's
args, {message} stands for the rendered message.
| Endpoint | Method | Description |
|---|---|---|
/webhook |
POST | Receive a webhook delivery or worker AI task |
/health |
GET | Health check; answers ok |
/webhook answers 401 for a missing, invalid or conflicting signature or a stale delivery, 409
for a replayed one, and 400 for a body that is not JSON. Otherwise it answers 200 with a JSON status: ignored (not a bot task), no_agent
(no agent matched), or dispatched with the agent id and a short result: ok, error: the command exited with status N, webhook: 200 OK, the reason a dispatch was refused, or for a cli
agent the run id (the run continues in the background). The output of a command and the body of a
remote error are never part of the answer, because the sender stores it in its delivery log; they
are written to this service's log (Dispatch result with detail=...).
The request signature is sha256=<hex> (the prefix is optional), an HMAC-SHA256 of the raw body
keyed with one of webhook_secrets. It is accepted in any of these headers:
| Header | Status |
|---|---|
X-Sylvode-Signature |
Canonical |
X-Webhook-Signature |
Canonical; the header the Sylvode API sends today |
X-OpenPR-Signature |
Legacy; accepted with a deprecation notice, not removed before Sylvode v2.0 |
Every signature header present is read. When they carry different signatures (or one header is
repeated with different values) the request is refused with 401, even if one of them is valid: an
invalid signature cannot ride along a valid one. A non-ASCII signature value is refused the same
way. With allow_unsigned = true signature headers are not read at all.
A signature proves who wrote a body, not when it was sent. For a signed request, two fields inside
the signed body are therefore checked as well: a webhook delivery whose timestamp is more than
max_delivery_age_secs (default 300) away from the receiver's clock, in either direction, is
refused with 401, and a delivery whose id was already received (for a worker AI task: the same
task_id and attempts) is refused with 409. Keep the clocks of Sylvode and this receiver in
sync.
What this covers, and what it does not:
- It covers signed HTTP deliveries only (with
allow_unsigned = truenothing is checked), and only through the fields a body carries at its top level. - A webhook delivery from the Sylvode API carries both
idandtimestamp, so it is checked for freshness and uniqueness. - A worker AI task carries
task_idandattemptsbut no timestamp, so it is only checked for uniqueness, and only while its key is remembered: for twicemax_delivery_age_secs(with the default 300, about 10 minutes; never less than 10 minutes), up to 10,000 keys. A captured worker task can be replayed after that. - The memory is held in the process and cleared on restart: a delivery received before a restart is accepted once more afterwards (a webhook delivery only while its timestamp is fresh).
- A body without these top-level fields is not checked. Sylvode does send such bodies: a flow
event delivery (
{"delivery": {...}, "event": {...}}) has neither, and is ignored anyway because it is not a bot task.
./scripts/install.sh --binary-path /usr/local/bin/sylvode-webhook --config-dir /etc/sylvode-webhook
./scripts/uninstall.shThe installer creates the user service sylvode-webhook.service on Linux or the launch agent
dev.sylvode.webhook on macOS. An existing openpr-webhook.service or dev.openpr.webhook is
updated in place under its old name, so an enabled unit keeps working; run the uninstaller and
install again to move to the new name. The uninstaller removes both.
[Unit]
Description=Sylvode Webhook Receiver
After=network.target
[Service]
# A dedicated unprivileged user: cli executors run with bypassed permissions as this user.
User=sylvode-webhook
Group=sylvode-webhook
ExecStart=/usr/local/bin/sylvode-webhook /etc/sylvode-webhook/config.toml
WorkingDirectory=/etc/sylvode-webhook
Restart=always
# Running executors are stopped and report on SIGTERM; see Shutdown.
TimeoutStopSec=90
[Install]
WantedBy=multi-user.targetdocker run --init --stop-timeout 45 -p 9090:9090 \
-v ./config.toml:/etc/sylvode-webhook/config.toml:ro \
ghcr.io/openprx/sylvode-webhook:latestRun it with --init (see Stopping commands) and --stop-timeout 45 (see
Shutdown). The image runs /app/sylvode-webhook /etc/sylvode-webhook/config.toml as
the non-root user sylvode (uid 10001), so the mounted config.toml must be readable by uid
10001, and listen must not be loopback inside the container (use 0.0.0.0:9090, with a secret).
The image contains only the webhook executables and CA certificates: no codex, claude,
opencode or openclaw, so cli, openclaw and command-based agents need an image derived from
this one that adds them. It contains the legacy /app/openpr-webhook as well, so a compose file whose command names
/app/openpr-webhook keeps working. It is also published as ghcr.io/openprx/openpr-webhook with
the same digest. See Dockerfile.
Each release ships sylvode-webhook-<platform>.tar.gz with a .sha256, for linux-amd64,
linux-arm64, macos-amd64 and macos-arm64. Every archive contains both the sylvode-webhook
and the legacy openpr-webhook executable. The same archive is also published as
openpr-webhook-<platform>.tar.gz with its own .sha256.
Every legacy name below keeps working for the whole Sylvode 1.x line and is not removed before Sylvode v2.0. Using one writes a single deprecation line to stderr per process, naming the replacement. stdout and exit codes are the same for the legacy and the canonical name.
| Legacy | Canonical | When both are supplied |
|---|---|---|
Executable openpr-webhook |
sylvode-webhook |
— |
Image ghcr.io/openprx/openpr-webhook |
ghcr.io/openprx/sylvode-webhook (same digest) |
— |
Archive openpr-webhook-<platform>.tar.gz |
sylvode-webhook-<platform>.tar.gz (same bytes) |
— |
Header X-OpenPR-Signature |
X-Sylvode-Signature (or X-Webhook-Signature) |
Different values are refused with 401 |
Env OPENPR_WEBHOOK_SAFE_MODE |
SYLVODE_WEBHOOK_SAFE_MODE |
Disagreeing values refuse to start |
RUST_LOG target openpr_webhook |
sylvode_webhook |
A directive for the same target with a different level refuses to start |
Service openpr-webhook.service / dev.openpr.webhook |
sylvode-webhook.service / dev.sylvode.webhook |
The installer refuses to proceed |
Outbound header X-OpenPR-MCP-Surface |
X-Sylvode-MCP-Surface |
Both are always sent with the same value |
Upgrading from OpenPR Webhook 0.3.3 is not a drop-in replacement for every configuration. Start-up
now refuses configurations that cannot work or are unsafe, and some behaviour changed. Before
upgrading, run the new binary once against your configuration; it exits 1 with one of the errors
below if something must change (sylvode-webhook config.toml, then stop it with Ctrl-C).
Each of these started with 0.3.3 and refuses to start now. The error is logged on stdout as
failed to load config from <path>: <error>, except where noted.
| # | Configuration | Error (excerpt) | Fix |
|---|---|---|---|
| 1 | route.trigger_kinds contains a value Sylvode never sends, such as assignment from the old examples |
agent `X`: route.trigger_kinds contains `assignment`, which Sylvode never sends; valid values: assigned, created, ... |
Use the values in Agent selection: assigned, and mentioned (not mention) for a comment mention on a webhook delivery |
| 2 | An openclaw, openprx or custom command with whitespace that names no file and cannot be split unambiguously (args also set, quotes or backslashes in it, or its first word is not a program on PATH) |
agent `X`: openclaw.command `...` contains whitespace and is not an executable, and cannot be split unambiguously (<reason>); ... write ... command = "openclaw" and args = ["message", "send"] |
Write command and args as the error shows. The plain documented form command = "openclaw message send" with openclaw on PATH still starts, with a deprecation warning |
| 3 | An enabled tunnel with require_inbound_sig = true and no hmac_secret |
tunnel.require_inbound_sig = true requires a non-empty tunnel.hmac_secret: ... |
Set hmac_secret to the secret shared with the control plane, or remove require_inbound_sig |
| 4 | An empty or blank entry in security.webhook_secrets, while signatures are verified (allow_unsigned = false) |
security.webhook_secrets[N] is blank; an empty HMAC key lets anyone sign a request. ... |
Set the real secret or remove the entry |
| 5 | A blank tunnel.hmac_secret while the tunnel is enabled ([features].tunnel_enabled and [tunnel].enabled) |
tunnel.hmac_secret is blank; an empty HMAC key lets anyone sign an envelope. ... |
Set the real secret or remove the setting |
| 6 | An example placeholder secret in webhook_secrets or tunnel.hmac_secret: one of your-secret-here, shared-hmac-secret, replace_with_webhook_secret (the Sylvode repository's compose example) or the new example's placeholders, or any value containing replace_with, replace-with, your_, your-, changeme or example, ignoring case and surrounding whitespace. Only a secret in use refuses: webhook_secrets unless allow_unsigned = true, hmac_secret while the tunnel is enabled; an unused one (such as the hmac_secret = "shared-hmac-secret" beside enabled = false in the old examples) is logged as a warning |
security.webhook_secrets[N] is still the example placeholder; ... / tunnel.hmac_secret is still an example placeholder; ... |
Set a long random secret, the same as on the Sylvode webhook or the control plane |
| 7 | allow_unsigned = true with a listen address other than loopback, as in the old config.example.toml (0.0.0.0:9090) |
security.allow_unsigned = true with server.listen = "0.0.0.0:9090", which is not a loopback address: ... |
Set allow_unsigned = false and a secret, or listen on 127.0.0.1; only if unsigned access from the network is really intended, set allow_unsigned_non_loopback = true |
| 8 | An enabled tunnel whose url is ws:// to a host other than loopback |
tunnel.url = "ws://..." uses unencrypted ws:// to a host other than loopback, ... |
Use wss://, or set tunnel.allow_insecure_transport = true if the network path is trusted |
| 9 | Both SYLVODE_WEBHOOK_SAFE_MODE and OPENPR_WEBHOOK_SAFE_MODE set, disagreeing |
SYLVODE_WEBHOOK_SAFE_MODE="1" and the legacy OPENPR_WEBHOOK_SAFE_MODE="0" disagree; set only SYLVODE_WEBHOOK_SAFE_MODE |
Set only the canonical variable |
| 10 | RUST_LOG with an openpr_webhook directive and a sylvode_webhook directive for the same target at different levels |
On stderr: error: RUST_LOG directive "sylvode_webhook=error" and the legacy directive "openpr_webhook=debug" disagree; ... |
Use only sylvode_webhook directives |
| 11 | A config path that starts with - (including exactly -h, --help, -V, --version) |
On stderr: error: unknown option `-x`; see --help (write a config path that starts with `-` as ./-x), or help or version output |
Pass the path as ./-x |
These configurations still start, but the service behaves differently:
- The tunnel client can complete a WebSocket handshake. It never could before (its handshake request was rejected locally). It has been tested only against an in-process test peer: no Sylvode server endpoint exists yet, so an enabled tunnel still connects to nothing.
- The one-string
command = "openclaw message send"runs asopenclawwithargs = ["message", "send"]. With 0.3.3 every dispatch to such an agent failed to spawn, so it starts sending messages. {message}in acustomagent'sargsis replaced by the message. With 0.3.3 the literal{message}was passed and the message appended after it.- Agents without a template get the rendered default message or prompt instead of literal
placeholder text (
[TPL_PROJECT] TPL_EVENT: ...). - Text from Sylvode that would start an argument with
-is not passed: acustomagent is not run (custom_error: ...) and aclirun fails before starting the executor. A leading-you wrote inmessage_templateorargsis allowed. - Webhook-triggered
cliruns wait forcli_max_concurrency(default 1) slots shared with the tunnel, instead of all running at once. - Replays are refused: a signed delivery whose
idwas already received is answered409, and one whosetimestampis more thanmax_delivery_age_secs(300) from the receiver's clock401. Keep the clocks in sync, or setmax_delivery_age_secs = 0. - Signature headers that disagree are answered
401, even if one of them is valid, and a non-ASCII signature value is refused. - The
/webhookanswer'sresultis a short status; command output and remote error bodies are logged instead (Dispatch result detail=...). - Worker AI tasks: the callback's issue is the task's
reference_idfor awork_itemreference andpayload.issue_idfor acommentreference (a comment mentioning the bot), and none otherwise; the agent type isai_participant_agent_type. For awork_itemtask the free-formpayloadno longer overrides the issue, and for no task the agent type.route.trigger_kindsis matched only againstbot_context.trigger_reasonand a worker task'strigger_kind, not againsttask_typeor the payload. - Timeouts and shutdown stop the executor's whole process group (see Stopping
commands). On
SIGTERMorSIGINTthe service sends each runningcliexecutor's terminal callback (cancelled, which setsupdate_state_on_fail), takes at most 40 seconds by default, open requests included, and exits 0; 0.3.3 had no signal handling and was killed, leaving executors running and issuesin_progress. Give the service manager enough stop time (see Shutdown). - Tunnel envelopes for another
agent_idare refused, and withhmac_secretso are envelopes more than 300 seconds old and repeated envelope ids. - Logs: the target is
sylvode_webhook, the listening line is written after binding and names the bound address, and values from deliveries or commands are logged as escaped fields (event="..."). - Commands get
/dev/nullas stdin.
Unchanged on purpose: the configuration file format and its default path config.toml, the
/webhook and /health routes, the outbound X-Webhook-Signature, the openprx agent type
(it names the OpenPRX assistant, not this product), the tunnel agent_id (always taken from your
configuration) and the repository URL https://github.com/openprx/openpr-webhook.
- Documentation — Full documentation (10 languages)
- Community — OpenPRX community forum
Licensed under either of Apache License, Version 2.0 or MIT license at your option.