# Delivery Routing

> Route research summaries to local markdown, Telegram, or Slack with approval gates and delivery-state updates.

- Skill: `grandamenium/delivery-routing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add grandamenium/delivery-routing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/grandamenium/delivery-routing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: grandamenium (https://skillmd.com/u/grandamenium)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/grandamenium/delivery-routing

---


# Delivery Routing

Send the run summary to the configured destination through cortextOS-safe
delivery paths. Mark items as delivered only after successful delivery.

---

## When to Use

After brief-generation writes the run summary.

---

## Input

- `research/output/YYYY-MM-DD/summary.md`
- `research/output/YYYY-MM-DD/signals-selected.json` (needed to mark `delivered_at` on success)
- `config.json` (delivery config)
- `research/db/signals.db` (to mark `delivered_at` after successful delivery)

## Output

- Message sent to configured destination, OR approval request created through cortextOS approvals
- `delivered_at` set on delivered items in `research/db/signals.db`
- Delivery status appended to `research/output/YYYY-MM-DD/run.log`

---

## Approval Gate

**Always check `research.delivery.requires_approval` before sending externally.**
External destinations are `telegram` and `slack`. `local_markdown` and `none` do
not leave the machine.

### If `true` (default)

Create a cortextOS approval, write a local approval context file, notify the
user, and stop. Do not send externally until the approval is granted.

Every approval must have a parent task. If this delivery was started by a cron
or ad-hoc workflow and no task exists yet, create a run task first and mark it
`in_progress`.

```
research/output/YYYY-MM-DD/PENDING-APPROVAL.md
```

```markdown
# Delivery Pending Approval -- YYYY-MM-DD

Run complete. Summary ready but not sent (`research.delivery.requires_approval = true`).

**Approval:** Created through `cortextos bus create-approval`.

**Summary path:** research/output/YYYY-MM-DD/summary.md
**Signals selected:** N
**Top signals:**
1. [Title] (score: X.X)
2. [Title] (score: X.X)
3. [Title] (score: X.X)
```

Use the normal approval workflow:

```bash
APPR_ID=$(cortextos bus create-approval \
  "Send research summary for YYYY-MM-DD" \
  external-comms \
  "Destination: ${DESTINATION}. Summary: research/output/YYYY-MM-DD/summary.md. Pending context: research/output/YYYY-MM-DD/PENDING-APPROVAL.md")
cortextos bus send-telegram "$CTX_TELEGRAM_CHAT_ID" \
  "Approval needed: research summary for YYYY-MM-DD is ready. Check dashboard approval ${APPR_ID}."
cortextos bus log-event action approval_created info \
  --meta "{\"approval_id\":\"${APPR_ID}\",\"destination\":\"${DESTINATION}\"}"
```

If this run has a parent task, block that task on the approval. If it was cron
started and has no parent task, create one before requesting approval, then
block that task on the approval ID.

Log: `Delivery pending approval. Request at research/output/YYYY-MM-DD/PENDING-APPROVAL.md approval_id=$APPR_ID`

### If `false`

Send to configured destination automatically, then mark `delivered_at`.

---

## Destinations

### Telegram

```bash
cortextos bus send-telegram "$CTX_TELEGRAM_CHAT_ID" "$MESSAGE"
```

Message format: run date, signals collected vs. selected, top 3-5 titles with scores,
any source failures, path to full output folder.

Keep under 4,096 characters. If longer, split into two messages.

### Slack

Use Slack only when the workspace webhook is configured and approval policy
allows external delivery.

```bash
curl -s -X POST "${SLACK_WEBHOOK_URL}" \
  -H "Content-Type: application/json" \
  -d '{"text": "MESSAGE"}'
```

### Local Markdown

Write `research/output/YYYY-MM-DD/DELIVERED-summary.md` as a copy of the summary.
No external HTTP calls.

### None

No delivery. Summary stays at `research/output/YYYY-MM-DD/summary.md`.

---

## Message Content

Send only the summary -- not full brief text. The full briefs live on disk at
`research/output/YYYY-MM-DD/briefs/`.

Minimum content:
- Run date and time
- Number of signals selected out of total collected
- Top 3-5 signals: title, score
- Any source failures from run.log
- Path to full output folder

---

## Error Handling

Retry up to 3 times with 5-minute intervals between attempts.

```python
import time

def deliver_with_retry(send_fn, max_retries=3, delay_seconds=300):
    for attempt in range(1, max_retries + 1):
        try:
            send_fn()
            return True
        except Exception as e:
            log(f"delivery_failure attempt={attempt} detail={e}")
            if attempt < max_retries:
                time.sleep(delay_seconds)
    return False
```

If all 3 attempts fail:
1. Write the summary to `research/output/YYYY-MM-DD/DELIVERY-FAILED-summary.md`.
2. Log: `delivery_fallback path=research/output/YYYY-MM-DD/DELIVERY-FAILED-summary.md`
3. Do not raise -- delivery failure is non-fatal to the run.

---

## Config Schema

Read delivery settings from `config.json`:

```json
{
  "research": {
    "delivery": {
      "destination": "local_markdown",
      "requires_approval": true,
      "summary_only": true
    }
  }
}
```

Supported destinations:
- `local_markdown`
- `telegram`
- `slack`
- `none`

## Mark delivered_at (After Successful Delivery Only)

After the message sends successfully, call `mark_delivered()`:

```python
import sqlite3, datetime as dt, json

def mark_delivered(db_path, selected_json_path):
    with open(selected_json_path) as f:
        selected = json.load(f)
    conn = sqlite3.connect(db_path)
    now = dt.datetime.utcnow().isoformat()
    for item in selected:
        conn.execute(
            "UPDATE items SET delivered_at=? WHERE canonical_key=?",
            (now, item["canonical_key"])
        )
        conn.execute(
            """UPDATE daily_brief_items
               SET delivered=1, delivered_at=?
               WHERE item_id = (SELECT id FROM items WHERE canonical_key = ?)""",
            (now, item["canonical_key"])
        )
    conn.commit()
    conn.close()
```

This ensures items are only suppressed from future runs if delivery actually succeeded.
If delivery failed, they remain eligible for the next run.

