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
73 changes: 73 additions & 0 deletions .github/workflows/reply-router-receipt.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: reply-router receipt

on:
push:
branches:
- reply-router-skill
workflow_dispatch:

permissions:
contents: write

jobs:
receipt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: reply-router-skill
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install runx
run: |
curl -fsSL https://runx.ai/install | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Verify version
run: runx --version | tee runx-version.txt
- name: Harness
env:
RUNX_RECEIPT_SIGN_KID: runx-demo-key
RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64: QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=
RUNX_RECEIPT_SIGN_ISSUER_TYPE: hosted
run: |
mkdir -p receipts
runx harness skills/reply-router --receipt-dir receipts --json | tee harness.json
- name: Dogfood
env:
RUNX_RECEIPT_SIGN_KID: runx-demo-key
RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64: QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=
RUNX_RECEIPT_SIGN_ISSUER_TYPE: hosted
PUBLISHED_REF: iwannabefree00/reply-router@sha-243e8add9e86
run: |
mkdir -p receipts
runx add "$PUBLISHED_REF" --registry https://api.runx.ai --json | tee install.json
runx skill "$PUBLISHED_REF" --registry https://api.runx.ai --json --receipt-dir receipts \
--input-json inbound_reply='{"content":"Please unsubscribe me from these product emails immediately. Stop emailing this address.","received_from":"prospect@example.com","received_at":"2026-06-29T08:15:00Z"}' \
--input-json original_send_receipt='{"sealed":true,"receipt_id":"runx:receipt:sha256:original-send-001","checksum":"sha256:original-send-checksum","principal":"runx:principal:sales-agent","send_plan":{"recipient":"prospect@example.com","campaign":"onboarding-drip","recipient_state_version":7,"allowed_targets":{"interested":"runx:send-target:sales-followup","objection":"runx:send-target:objection-handling","out_of_office":"runx:send-target:delay-resume","wrong_person":"runx:send-target:contact-correction"}}}' \
--input-json suppression_policy='{"unsubscribe_signals":["unsubscribe","stop emailing","remove me","opt out"],"confidence_threshold":0.8}' \
--input data_source_ref="local://runx/reply-router" \
--input store_id="reply-router-fixture" | tee dogfood.json
DOGFOOD_RECEIPT="$(jq -r '.receipt.id // .receipt_id // .id // empty' dogfood.json)"
if [ -z "$DOGFOOD_RECEIPT" ]; then
DOGFOOD_RECEIPT="$(find receipts -maxdepth 1 -name 'sha256:*.json' -printf '%f\n' | sed 's/\.json$//' | sort | tail -n 1)"
fi
test -n "$DOGFOOD_RECEIPT"
runx verify --receipt "receipts/${DOGFOOD_RECEIPT}.json" --json | tee verify.json
jq -n \
--arg runx_version "$(cat runx-version.txt)" \
--arg published_ref "$PUBLISHED_REF" \
--arg receipt_ref "runx:receipt:${DOGFOOD_RECEIPT}" \
--slurpfile harness harness.json \
--slurpfile install install.json \
--slurpfile dogfood dogfood.json \
--slurpfile verify verify.json \
'{status:"passed", runx_version:$runx_version, published_ref:$published_ref, receipt_ref:$receipt_ref, harness:$harness[0], install:$install[0], dogfood:$dogfood[0], verify:$verify[0]}' \
> skills/reply-router/action-verification.json
- name: Commit verification
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add skills/reply-router/action-verification.json
git commit -m "Add reply router action verification [skip ci]" || exit 0
git push origin HEAD:reply-router-skill
71 changes: 71 additions & 0 deletions skills/reply-router/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
name: reply-router
version: 0.1.0
description: Classify inbound replies, write unsubscribe suppression records, and emit bounded routing decisions without sending messages.
source:
type: cli-tool
command: node
args:
- run.mjs
links:
source: https://github.com/iwannabefree00/runx/tree/reply-router-skill/skills/reply-router
runx:
category: business-ops
---

# Reply Router

`reply-router` classifies an inbound reply against a supplied suppression
policy and the sealed receipt for the original send. It has one durable
side-effect seam: for unsubscribe-class replies it prepares a recipient-keyed
suppression `append_event` for `registry:runx/data-store@0.1.2`. For all other
clear classifications it emits a bounded routing decision naming the later
governed send-as target, but it never sends a message itself.

## Inputs

- `inbound_reply`: object with `content`, `received_from`, and `received_at`.
- `original_send_receipt`: object with `send_plan`, `principal`, `receipt_id`,
`checksum`, and `sealed`.
- `suppression_policy`: object with `unsubscribe_signals` and
`confidence_threshold`.
- `data_source_ref`: logical binding for the governed data-store dependency.
- `store_id`: pinned store id for deterministic suppression records.

## Outputs

- `classification`: `{ type, confidence, evidence[] }`.
- `suppression_result`: recipient-keyed suppression CAS packet when suppressed,
otherwise `null`.
- `routing_decision`: `runx.reply.routing.v1` packet when the reply is routed,
otherwise `null`.
- `escalation`: human-review lane for unsealed receipts, ambiguous replies, or
insufficient confidence.
- `evidence`: receipt id, policy signals, idempotency key, before/after data
versions, and no-send guarantees.

## Rules

1. Refuse to classify on an unsealed or malformed `original_send_receipt`.
2. Suppress when the reply content contains unsubscribe intent grounded in
`suppression_policy.unsubscribe_signals`.
3. Never route a send alongside an unsubscribe-class reply.
4. For non-unsubscribe replies, emit only a bounded routing packet; the later
send is a separate governed send-as run chosen by an operator or downstream
driver.
5. Stop before write when the reply is ambiguous or confidence is below
`suppression_policy.confidence_threshold`.

## Data-store seam

For unsubscribe replies the output includes a CAS `append_event` packet:

1. `read_projection` for the recipient aggregate.
2. `append_event` through `registry:runx/data-store@0.1.2`.
3. `aggregate_id = inbound_reply.received_from`.
4. `expected_version` comes from `original_send_receipt.send_plan.recipient_state_version` when present, else `0`.
5. `idempotency_key` is derived from `received_from + receipt_id + classification`.

That suppression record is the compliance block a later governed send-as
preflight reads. This skill does not consume credentials, call a mail provider,
emit an `AttenuationRequest`, or create an `operational_proposal` envelope.
132 changes: 132 additions & 0 deletions skills/reply-router/X.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
skill: reply-router
version: "0.1.0"

catalog:
kind: skill
audience: operator
visibility: public
role: canonical

policy:
allow:
- provider: data-source
method: READ
scope: runx:data:read
- provider: data-source
method: APPEND
scope: runx:data:append
deny:
- send_message
- route_unsubscribe
- suppress_without_policy_signal
- classify_unsealed_receipt
- invent_reply_classification
- attenuation_request
- operational_proposal

harness:
cases:
- name: sealed_unsubscribe_suppression
inputs:
inbound_reply:
content: |
Please unsubscribe me from these product emails immediately. Stop emailing this address.
received_from: prospect@example.com
received_at: "2026-06-29T08:15:00Z"
original_send_receipt:
sealed: true
receipt_id: runx:receipt:sha256:original-send-001
checksum: sha256:original-send-checksum
principal: runx:principal:sales-agent
send_plan:
recipient: prospect@example.com
campaign: onboarding-drip
recipient_state_version: 7
allowed_targets:
interested: runx:send-target:sales-followup
objection: runx:send-target:objection-handling
out_of_office: runx:send-target:delay-resume
wrong_person: runx:send-target:contact-correction
suppression_policy:
unsubscribe_signals:
- unsubscribe
- stop emailing
- remove me
- opt out
confidence_threshold: 0.8
data_source_ref: local://runx/reply-router
store_id: reply-router-fixture
expect:
status: sealed
receipt:
schema: runx.receipt.v1
state: sealed
disposition: closed

- name: stop_ambiguous_or_unsealed
inputs:
inbound_reply:
content: |
Maybe later, but I am not sure this was meant for me.
received_from: unknown@example.com
received_at: "2026-06-29T08:16:00Z"
original_send_receipt:
sealed: false
receipt_id: ""
checksum: ""
principal: runx:principal:sales-agent
send_plan:
recipient: unknown@example.com
recipient_state_version: 0
suppression_policy:
unsubscribe_signals:
- unsubscribe
- stop emailing
- remove me
confidence_threshold: 0.85
data_source_ref: local://runx/reply-router
store_id: reply-router-fixture
expect:
status: failure
receipt:
schema: runx.receipt.v1
state: sealed
disposition: failed

runners:
classify:
default: true
type: cli-tool
command: node
args:
- run.mjs
inputs:
inbound_reply:
type: json
required: true
description: "Object with content, received_from, and received_at."
original_send_receipt:
type: json
required: true
description: "Sealed original-send receipt packet with send_plan, principal, receipt_id, checksum, and sealed."
suppression_policy:
type: json
required: true
description: "Suppression policy with unsubscribe_signals and confidence_threshold."
data_source_ref:
type: string
required: true
description: "Logical binding for registry:runx/data-store@0.1.2."
store_id:
type: string
required: true
description: "Pinned store id used for deterministic harness and receipts."
outputs:
classification: object
suppression_result: object
routing_decision: object
escalation: object
evidence: object
artifacts:
wrap_as: reply_router
packet: runx.reply_router.v1
Loading