Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions skills/reply-router/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
name: reply-router
description: Classify an inbound reply against a sealed original send receipt and either append a recipient-keyed suppression event to a hosted data-store or emit a bounded governed routing decision. The skill never sends mail.
runx:
category: ops
---

# Reply Router

`reply-router` reads one inbound reply together with the sealed original send
receipt that produced it, classifies the reply against a suppression policy, and
branches. The skill is a read-and-route judgment: it never sends mail, never
mints a new send, and never edits the original send receipt. The actual send is
a separate governed `send-as` run a downstream driver issues by name.

## What This Skill Does

The skill reads three pieces of evidence:

- `inbound_reply{content, received_from, received_at}`
- `original_send_receipt{send_plan, principal, receipt_id, checksum}`
- `suppression_policy{unsubscribe_signals, confidence_threshold}`

It validates that the original send receipt is sealed (the receipt envelope is
present and the checksum matches the named `send_plan`) and that the policy
names at least one unsubscribe signal. It then classifies the reply by
searching the content for any of the policy's `unsubscribe_signals` strings or
patterns. A high-confidence unsubscribe match appends a suppression event to
the hosted data-store `registry:runx/data-store@0.1.2` via an `append_event`
with an idempotency key and an expected_version CAS. The durable record is the
compliance block the next `send-as` preflight reads as a fail-closed gate.

For other classifications (`interested`, `objection`, `out-of-office`,
`wrong-person`), the skill emits a typed `runx.reply.routing.v1` decision
naming a bounded `send_target` and a `principal`. A downstream `send-as` run
honors that decision later; the skill itself does not dispatch.

For an unsealed or missing original send receipt, or for an inbound reply
whose content does not match any policy signal and where the agent cannot
ground a typed classification, the skill escalates to a human approval lane
and emits no suppression event and no routing decision.

## When To Use It

- An operator has a sealed original send receipt and needs a typed decision
on how to handle the inbound reply before any subsequent send.
- A workflow needs to prove a recipient was unsubscribed via a durable
data-store record that the next `send-as` preflight can verify.
- A run should keep suppression and routing out of the actual mail path.

## When Not To Use It

- To actually send, queue, schedule, or otherwise move a message. Use a
separate governed `send-as` run for that effect.
- To append a suppression event without a sealed original send receipt.
- To emit a routing decision when the inbound content is ambiguous or the
reply cannot be grounded in a policy signal.
- To invent a classification, an unsubscribe match, or a routing target that
the input evidence does not support.

## Procedure

1. Read `inbound_reply`, `original_send_receipt`, and `suppression_policy`.
Reject any missing or unclear top-level object.
2. Verify the `original_send_receipt` is sealed: `receipt_id` and `checksum`
are present, `send_plan` and `principal` are non-empty strings, and
`checksum` matches the named `send_plan`. If any of these is missing or
mismatched, escalate to the human approval lane and emit no outputs.
3. Verify `suppression_policy.unsubscribe_signals` is a non-empty list of
strings. The `confidence_threshold` must be a number in (0, 1].
4. Search `inbound_reply.content` for each policy `unsubscribe_signals`
string using a case-insensitive match. If at least one signal is found
and the match is exact (not negated, not quoted as not-an-unsubscribe),
classify the reply as `unsubscribe` with confidence 1.0 and evidence
naming the matched signal and the offset in the reply content.
5. For an `unsubscribe` classification, append a suppression event to the
hosted data-store via `append_event` with:
- `aggregate_id` = the reply's `received_from` (the recipient key)
- `idempotency_key` = sha256 of `{receipt_id}:{received_from}:{signal}`
- `expected_version` = the value from a prior `read_projection` against
`aggregate_id` (0 if no prior projection exists)
- The event payload names the matched signal, the original receipt id,
and the inbound reply received_at timestamp.
The skill emits a `suppression_result{aggregate_id, idempotency_key,
before_version, after_version}`. The skill does NOT emit a routing
decision on this path.
6. If no unsubscribe signal matches, attempt to ground a typed
classification from a bounded set (`interested`, `objection`,
`out-of-office`, `wrong-person`) using the reply content and the
original send plan. If grounding succeeds with confidence above
`confidence_threshold`, emit `runx.reply.routing.v1{classification,
send_target, principal}` naming a bounded send target the downstream
`send-as` run honors. The skill consumes nothing and does not dispatch.
7. If no typed classification can be grounded above
`confidence_threshold`, escalate to the human approval lane and emit
no outputs.
8. Never invent a match, classification, or aggregate version. Never append
a suppression event without a sealed original send receipt. Never
classify a reply whose `received_from` does not match the principal of
the original send receipt.

## Outputs

- `classification{type, confidence, evidence}` for every sealed run.
- `suppression_result{aggregate_id, idempotency_key, before_version,
after_version}` when the reply is an unsubscribe.
- `runx.reply.routing.v1{classification, send_target, principal}` when the
reply is routed.
- `escalation_reason` when the run stops for a human approval lane.
164 changes: 164 additions & 0 deletions skills/reply-router/X.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
skill: reply-router
version: "0.1.0"

catalog:
kind: graph
audience: public
visibility: public
role: context

harness:
cases:
- name: sealed_unsubscribe_suppression
runner: reply_route
inputs:
inbound_reply:
content: "Please unsubscribe me from this list. No more emails please."
received_from: "ops@example.com"
received_at: "2026-07-02T10:00:00Z"
original_send_receipt:
send_plan: '{"to":"ops@example.com","subject":"weekly digest","from":"ops@example.com","body_digest":"weekly news"}'
principal: "ops"
receipt_id: "runx:receipt:sha256:abc123replyrouter"
checksum: "sha256:49e1b30d46a6eee6156bcf9a1e2d8400f17d69a3e271d541b61a7ad49c7c05ad"
suppression_policy:
unsubscribe_signals:
- unsubscribe
- opt out
- remove me
confidence_threshold: 0.8
operator_context: Append a recipient-keyed suppression event to the data-store via ungated CAS. No routing decision is emitted on this path; the next send-as preflight must read the durable record as a fail-closed block.
caller:
answers:
agent_task.reply-classify.output:
classification:
type: unsubscribe
confidence: 1.0
evidence: matched policy signal "unsubscribe" at offset 7
suppression_result:
aggregate_id: ops@example.com
idempotency_key: bbdc596dd2d71413bc2bce898a98a2a52d32ed5d86abd73c185a2ab3a3eed733
before_version: 0
after_version: 1
suppression_event:
aggregate_id: ops@example.com
idempotency_key: bbdc596dd2d71413bc2bce898a98a2a52d32ed5d86abd73c185a2ab3a3eed733
event:
type: reply.unsubscribe_recorded
payload:
matched_signal: unsubscribe
match_offset: 7
original_receipt_id: runx:receipt:sha256:abc123replyrouter
received_at: "2026-07-02T10:00:00Z"
data_store_ref: registry:runx/data-store@0.1.2
routing_decision: null
escalation_reason: null
expect:
status: sealed
receipt:
schema: runx.receipt.v1
state: sealed
- name: stop_ambiguous_or_unsealed
runner: reply_route
inputs:
inbound_reply:
content: "ok thanks"
received_from: "ops@example.com"
received_at: "2026-07-02T10:00:00Z"
original_send_receipt:
send_plan: '{"to":"ops@example.com","subject":"weekly digest","from":"ops@example.com","body_digest":"weekly news"}'
principal: "ops"
receipt_id: "runx:receipt:sha256:abc123replyrouter"
checksum: "sha256:49e1b30d46a6eee6156bcf9a1e2d8400f17d69a3e271d541b61a7ad49c7c05ad"
suppression_policy:
unsubscribe_signals:
- unsubscribe
- opt out
- remove me
confidence_threshold: 0.8
operator_context: Ambiguous reply with no policy match and no grounded typed classification; the classify sub-step must block to needs_human with no suppression write and no routing decision.
expect:
status: needs_agent

runners:
reply_route:
default: true
type: graph
inputs:
inbound_reply:
type: json
required: true
description: Inbound reply content, recipient, and timestamp.
original_send_receipt:
type: json
required: true
description: Sealed original send receipt with send_plan, principal, receipt_id, and checksum.
suppression_policy:
type: json
required: true
description: Unsubscribe signal list and confidence threshold.
graph:
name: reply-router-read-classify-and-persist
steps:
- id: read_suppression
label: read projection using the registry:runx/data-store@0.1.2 data-source contract
tool: data.source
scopes:
- runx:data:read
inputs:
operation: read_projection
data_source_ref: local://reply-router/suppressions
store_id: reply-router-suppressions-v1
resource: suppression_events
aggregate_id: "$input.inbound_reply.received_from"
- id: classify
run:
type: agent-task
agent: analyst
task: reply-classify
outputs:
classification: object
suppression_result: object
suppression_event: object
routing_decision: object
escalation_reason: string
inputs:
inbound_reply: "$input.inbound_reply"
original_send_receipt: "$input.original_send_receipt"
suppression_policy: "$input.suppression_policy"
context:
current_projection: read_suppression.data_operation_result.data.projection
instructions: Classify only from the supplied reply, sealed receipt, policy, and current projection. Never invent intent. An unsubscribe result must emit a suppression event and no routing decision; ambiguous or unsealed input must require human review.
- id: append_suppression
when:
field: classify.classification.type
equals: unsubscribe
label: append through the registry:runx/data-store@0.1.2 data-source contract
tool: data.source
scopes:
- runx:data:append
inputs:
operation: append_event
data_source_ref: local://reply-router/suppressions
store_id: reply-router-suppressions-v1
resource: suppression_events
context:
aggregate_id: classify.suppression_event.aggregate_id
expected_version: read_suppression.data_operation_result.data.projection.version
idempotency_key: classify.suppression_event.idempotency_key
event: classify.suppression_event.event
- id: readback_suppression
when:
field: classify.classification.type
equals: unsubscribe
label: verify through the registry:runx/data-store@0.1.2 data-source contract
tool: data.source
scopes:
- runx:data:read
inputs:
operation: read_projection
data_source_ref: local://reply-router/suppressions
store_id: reply-router-suppressions-v1
resource: suppression_events
context:
aggregate_id: classify.suppression_event.aggregate_id
14 changes: 14 additions & 0 deletions skills/reply-router/fixtures/harness-result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"status": "passed",
"case_count": 2,
"assertion_error_count": 0,
"assertion_errors": [],
"case_names": [
"sealed_unsubscribe_suppression",
"stop_ambiguous_or_unsealed"
],
"receipt_ids": [
"sha256:a21bde8ef82146536fea77aeedba6f0e62e29a727d5315e409abc8bfc0e74425"
],
"graph_case_count": 2
}
18 changes: 18 additions & 0 deletions skills/reply-router/fixtures/sealed-unsubscribe-suppression.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
runner: reply_route
inputs:
inbound_reply:
content: "Please unsubscribe me from this list. No more emails please."
received_from: "ops@example.com"
received_at: "2026-07-02T10:00:00Z"
original_send_receipt:
send_plan: '{"to":"ops@example.com","subject":"weekly digest","from":"ops@example.com","body_digest":"weekly news"}'
principal: "ops"
receipt_id: "runx:receipt:sha256:abc123replyrouter"
checksum: "sha256:49e1b30d46a6eee6156bcf9a1e2d8400f17d69a3e271d541b61a7ad49c7c05ad"
suppression_policy:
unsubscribe_signals:
- unsubscribe
- opt out
- remove me
confidence_threshold: 0.8

17 changes: 17 additions & 0 deletions skills/reply-router/fixtures/stop-ambiguous-or-unsealed.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
runner: reply_route
inputs:
inbound_reply:
content: "ok thanks"
received_from: "ops@example.com"
received_at: "2026-07-02T10:00:00Z"
original_send_receipt:
send_plan: '{"to":"ops@example.com","subject":"weekly digest","from":"ops@example.com","body_digest":"weekly news"}'
principal: "ops"
receipt_id: "runx:receipt:sha256:abc123replyrouter"
checksum: "sha256:49e1b30d46a6eee6156bcf9a1e2d8400f17d69a3e271d541b61a7ad49c7c05ad"
suppression_policy:
unsubscribe_signals:
- unsubscribe
- opt out
- remove me
confidence_threshold: 0.8
Loading