Skip to content
Merged
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
31 changes: 31 additions & 0 deletions examples/jvagent_app/agents/jvagent/example_agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,34 @@ Demonstrates custom action development with:
- Custom endpoints
- Lifecycle hooks

### SentDM Broadcast Action

Sends template-based SMS / WhatsApp broadcasts through the
[SentDM v3 API](https://docs.sent.dm/reference/api).

**Required environment variables:**
- `SENTDM_API_KEY` — account API key (sent as the `x-api-key` header).
- `JVAGENT_PUBLIC_BASE_URL` — needed to auto-register a delivery-status webhook.
- `JVSPATIAL_JWT_SECRET_KEY` — needed to create the system user that owns the webhook API key.

The action ships with `sandbox: true` in this example so calls validate but do
not actually send messages — flip to `false` once you have a real template
configured and want to broadcast for real.

**Key admin endpoints** (`action_id` is the id of this registered action):

| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/api/actions/{action_id}/broadcast` | Send a broadcast |
| `POST` | `/api/actions/{action_id}/webhook/register` | Force webhook reconcile |
| `GET` | `/api/actions/{action_id}/webhook` | View the registered webhook URL |
| `POST` | `/api/webhook/{action_id}?api_key=...` | Inbound SentDM webhook (registered automatically) |

An interactive tester ships with the action at
[`jvagent/action/sentdm_broadcast/test_cli.py`](../../../../../jvagent/action/sentdm_broadcast/test_cli.py).
Full docs in the
[SentDM Broadcast README](../../../../../jvagent/action/sentdm_broadcast/README.md).

## Usage

When jvagent starts from the app directory:
Expand Down Expand Up @@ -206,6 +234,9 @@ To create your own agent:

This agent uses environment variables for configuration:
- `${OPENAI_API_KEY}`: OpenAI API key for the model and embedding actions (set in `.env` file)
- `${OLLAMA_API_KEY}`: Ollama Cloud API key for the cockpit's primary engine (set in `.env` file)
- `${TYPESENSE_API_KEY}`: Typesense API key for the vector store (set in `.env` file)
- `${SENTDM_API_KEY}`: SentDM API key for the broadcast action (set in `.env` file)
- `${JVAGENT_PUBLIC_BASE_URL}`: Public origin used by SentDM (and other) webhook auto-registration (set in `.env` file)

See the main [jvagent README](../../../../../../README.md) for more information about environment variable resolution.
31 changes: 31 additions & 0 deletions examples/jvagent_app/agents/jvagent/example_agent/agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,37 @@ actions:
model_temperature: 0.1
model_max_tokens: 8192

# ── SentDM broadcast (SMS / WhatsApp) ────────────────────
# Sends template-based broadcasts via the SentDM v3 API. Requires the
# SENTDM_API_KEY environment variable. For webhook auto-registration of
# delivery events, also set JVAGENT_PUBLIC_BASE_URL and
# JVSPATIAL_JWT_SECRET_KEY. Templates must already exist in your SentDM
# account (create them via the SentDM dashboard).
- action: jvagent/sentdm_broadcast_action
context:
enabled: true
description: "SentDM broadcast — template-based SMS / WhatsApp messages"
default_channels: ["sms"]
# Uncomment to set an account-wide fallback template:
# default_template_name: "order_confirmation"
# default_template_id: ""
# profile_id: "5311d08c-f79a-41b1-8ebb-1f0efaefdf8f"
sandbox: true # validate only, no real send — flip to false to go live
timeout: 30
# Webhook tuning (used when JVAGENT_PUBLIC_BASE_URL is set)
webhook_display_name: "jvagent SentDM"
webhook_event_types: ["message"]
# webhook_event_filters: {} # omit Sent event_filters (all message.*); default includes queued + received + outbound lifecycle
webhook_retry_count: 3
webhook_timeout_seconds: 30
# Graph persistence — each send creates a SentDMBroadcastRecord node
# (one per recipient/channel), and the inbound webhook handler folds
# delivery events into it. Disable persist_records to skip entirely.
persist_records: true
# Sandbox validates only — no carrier send. Records are skipped unless true:
persist_sandbox_sends: true
record_event_history_limit: 25

# ── Signup interview (local custom action) ───────────────
- action: jvagent/signup_interview_interact_action
context:
Expand Down
193 changes: 193 additions & 0 deletions jvagent/action/sentdm_broadcast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# SentDM Broadcast Action

`SentDMBroadcastAction` wraps SentDM's [v3 REST API](https://docs.sent.dm/reference/api)
to send template-based SMS / WhatsApp broadcasts, read message status, list
templates, and receive delivery-status callbacks via an auto-registered webhook.

## Required environment variables

| Variable | Required | Purpose |
| --- | --- | --- |
| `SENTDM_API_KEY` | yes | Authenticates every call (`x-api-key` header). Create one in the SentDM dashboard. |
| `JVAGENT_PUBLIC_BASE_URL` | for webhooks | Public origin used to build the callback URL given to SentDM. |
| `JVSPATIAL_JWT_SECRET_KEY` | for webhooks | Required by jvspatial to create the system user that owns the webhook API key. |

The send endpoint requires a **pre-existing template** in your SentDM account.
Create templates from the SentDM dashboard (or `POST /v3/templates`) before
calling `send_broadcast`.

## Configuration

```yaml
actions:
- action: jvagent/sentdm_broadcast_action
context:
enabled: true
default_channels: ["sms", "whatsapp"]
default_template_name: "order_confirmation"
sandbox: false
```

### Attributes

| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `api_base` | `str` | `"https://api.sent.dm"` | Base URL for the SentDM v3 API. |
| `default_channels` | `List[str]` | `["sms"]` | Channels used when a call omits `channels`. Values: `sms`, `whatsapp`, `rcs`. |
| `default_template_id` | `str` | `""` | Fallback template UUID for `send_broadcast`. |
| `default_template_name` | `str` | `""` | Fallback template name for `send_broadcast`. |
| `profile_id` | `str` | `""` | Optional `x-profile-id` value (org API keys scoping to a child profile). |
| `timeout` | `int` | `30` | HTTP timeout in seconds. |
| `sandbox` | `bool` | `false` | Default `sandbox` flag for mutating calls. |
| `webhook_display_name` | `str` | `"jvagent SentDM"` | Display name used when (re)creating the SentDM webhook. |
| `webhook_event_types` | `List[str]` | `["message"]` | Parent categories for Sent ``POST /v3/webhooks`` (``message``, ``templates``). Sub-types use ``webhook_event_filters``. |
| `webhook_event_filters` | `Dict[str, List[str]] \| null` | `null` → default ``{message: [queued, sent, delivered, read, failed, received]}`` | Sent ``event_filters``; ``{}`` means all sub-types for subscribed parents. |
| `webhook_retry_count` | `int` | `3` | SentDM webhook retry count. |
| `webhook_timeout_seconds` | `int` | `30` | SentDM webhook delivery timeout. |
| `persist_records` | `bool` | `true` | Persist a `SentDMBroadcastRecord` per `(recipient, channel)` on each successful send. Webhooks **always** upsert by `sentdm_message_id` (create a minimal record if none exists), independent of this flag. |
| `persist_sandbox_sends` | `bool` | `false` | Persist sandbox sends as well (off by default to keep the graph free of test traffic). |
| `record_event_history_limit` | `int` | `25` | Bound on the `events[]` audit log per record (FIFO eviction). |

## Graph data model

When `persist_records` is on, every successful `POST /v3/messages` writes one
`SentDMBroadcastRecord` node per `(message_id, recipient, channel)` and
connects it to the action node, so the graph layout is:

```
Agent -> Actions -> SentDMBroadcastAction -> SentDMBroadcastRecord (one per message)
```

Indexed fields (cheap lookups):

| Field | Purpose |
| --- | --- |
| `action_id` | Filter records belonging to a specific action node. |
| `agent_id` | Cross-action queries scoped to one agent. |
| `sentdm_message_id` | Primary key used by the webhook handler to reconcile delivery status into the local record. |

Mutable fields:

| Field | Notes |
| --- | --- |
| `status` | Normalized: `accepted` / `processing` / `queued` / `sent` / `delivered` / `read` / `received` / `failed` / `rejected` / `undelivered`. |
| `last_event_field`, `last_event_payload`, `last_status_at` | Snapshot of the most recent webhook (or refresh) event. |
| `events` | Bounded audit log (newest last); cap from `record_event_history_limit`. |
| `error` | Populated when `status in {failed, rejected, undelivered}`. |
| `to`, `channel`, `template_id`, `template_name`, `parameters`, `idempotency_key`, `profile_id`, `sandbox`, `created_at`, `updated_at` | Captured at send time; webhook-only records fill `to` / `channel` / `template_id` from the first event when Sent provides them. |

Status derivation when a webhook arrives:

1. If `payload.status` or `payload.message_status` (or nested `data.*`) matches a known token, use it.
2. Else if `payload.event`, `eventType`, or `sub_type` looks like `message.delivered` / `message.sent` / `message.failed`, use the suffix after the dot.
3. Else keep the prior status; only `last_event_field` / `events` are updated.

If the message id cannot be resolved from the payload, the handler still
returns `200` so SentDM stops retrying; otherwise a record is created or
updated in place.

## Usage from another action

```python
sentdm = await self.get_action("SentDMBroadcastAction")
result = await sentdm.send_broadcast(
to=["+14155551234", "+14155555678"],
template={
"name": "order_confirmation",
"parameters": {"name": "Jane", "order_id": "12345"},
},
channels=["sms", "whatsapp"],
)
message_ids = [m.get("id") for m in (result.get("messages") or [])]
```

The response is the raw SentDM JSON. Inspect the API reference for the
per-recipient/per-channel message metadata.

## HTTP endpoints

All admin endpoints require auth (admin role) and are scoped by the action id:

| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/api/actions/{action_id}/broadcast` | Send a broadcast. Body: `{to, template?, channels?, parameters?, sandbox?, idempotency_key?, profile_id?}` (see OpenAPI / docstring example). |
| `POST` | `/api/actions/{action_id}/webhook/register` | Force webhook reconcile (re-creates SentDM webhook to point at us). |
| `GET` | `/api/actions/{action_id}/webhook` | Read-only view of the currently registered webhook URL + signing-secret status. |

Public webhook receiver (api_key auth via query/header — registered with SentDM
automatically on startup):

| Method | Path |
| --- | --- |
| `POST` | `/api/webhook/{action_id}?api_key=...` |

The handler verifies the `X-Webhook-Signature` HMAC against the stored signing
secret, de-duplicates by `X-Webhook-ID`, looks up the matching
`SentDMBroadcastRecord` (by `sentdm_message_id` parsed out of the event
payload), and folds the event into the record's `status`, `last_status_at`,
`last_event_payload` and `events[]` audit log.

Webhook **reconcile** (on action load or `POST …/webhook/register`) deletes every
other Sent webhook whose URL starts with
`{JVAGENT_PUBLIC_BASE_URL}/api/webhook/` **or** the legacy
`/api/sentdm/webhook/` prefix on the same host, except the exact URL for **this**
action — including other action ids on the same host. If you run multiple
SentDM broadcast actions behind one public base URL, reconciling one action
removes the others' Sent registrations; use distinct base URLs (or hostnames)
per deployment if you need them concurrently.

## Interactive test CLI

A standalone, dependency-light CLI for exercising the endpoints lives at
[test_cli.py](test_cli.py). It does not import any jvagent code — it just
talks to a running jvagent server over HTTP.

```bash
python jvagent/action/sentdm_broadcast/test_cli.py
python jvagent/action/sentdm_broadcast/test_cli.py --env-file path/to/.env
python jvagent/action/sentdm_broadcast/test_cli.py --no-env # ignore .env files
python jvagent/action/sentdm_broadcast/test_cli.py --prompt # force interactive auth
python jvagent/action/sentdm_broadcast/test_cli.py -y # explicit auto-mode
```

By default the CLI runs in **auto-mode** whenever the resolved env supplies
enough to authenticate (`JVAGENT_API_KEY`, or `JVAGENT_ADMIN_EMAIL` +
`JVAGENT_ADMIN_PASSWORD`). In auto-mode it skips every prompt that has a
default, auto-selects the agent that has a `SentDMBroadcastAction`, and drops
straight into the menu. Pass `--prompt` to force the legacy interactive flow.

What it does:

1. Loads a `.env` file for prompt defaults. By default it uses
`examples/jvagent_app/.env` in this repository (the same file as the example
app). If that path does not exist, it falls back to walking upward from the
current working directory. Override with `--env-file`.
2. Prompts for the jvagent base URL — the default is
`JVAGENT_BASE_URL` / `JVAGENT_PUBLIC_BASE_URL`, otherwise
`http://{JVAGENT_HOST or localhost}:{JVAGENT_PORT or 8000}`.
3. Authenticates with either admin credentials (`POST /api/auth/login`) or an
existing jvagent API key (`x-api-key` header).
4. Lists agents, lets you pick one, and locates its `SentDMBroadcastAction`.
5. Opens a menu: send broadcast (few prompts; optional ``SENTDM_TEST_*`` in ``.env``), reconcile webhook,
show registered webhook URL, switch agent, quit.

### Env vars used as defaults

| Variable | Used for |
| --- | --- |
| `JVAGENT_BASE_URL` *(optional)* | Explicit base URL for the CLI |
| `JVAGENT_PUBLIC_BASE_URL` | Fallback base URL |
| `JVAGENT_HOST`, `JVAGENT_PORT` | Built into `http://host:port` as a last resort |
| `JVAGENT_ADMIN_EMAIL` | Default email for `POST /api/auth/login` (the jvspatial endpoint expects `email`, not `username`); falls back to `JVAGENT_ADMIN_USERNAME` |
| `JVAGENT_ADMIN_PASSWORD` | Skips the password prompt when present |
| `JVAGENT_API_KEY` | Skips the API-key prompt when present |
| `JVAGENT_API_KEY_HEADER` | Default header name (defaults to `x-api-key`) |
| `SENTDM_TEST_TO` | Default recipient list for the send-broadcast prompt (E.164, comma-separated) |
| `SENTDM_TEST_TEMPLATE_ID` | Default template UUID for the send-broadcast prompt |
| `SENTDM_TEST_PARAMETERS_JSON` | Default parameters JSON string for the send-broadcast prompt |

The CLI requires `httpx` (already a jvagent dependency) and uses
`python-dotenv` (also a jvagent dependency) for `.env` parsing. The last base
URL + chosen auth method are cached at `~/.sentdm_test_cli.json`. **Passwords
and API keys are never written to disk** — they're read fresh from `.env`
(or prompted) on each run.
11 changes: 11 additions & 0 deletions jvagent/action/sentdm_broadcast/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""SentDM broadcast action package.

Provides a thin wrapper around the SentDM v3 REST API for sending template-based
SMS / WhatsApp broadcasts and receiving delivery-status webhooks.
"""

from . import endpoints # noqa: F401 (import for endpoint registration)
from .models import SentDMBroadcastRecord
from .sentdm_broadcast_action import SentDMBroadcastAction

__all__ = ["SentDMBroadcastAction", "SentDMBroadcastRecord"]
Loading
Loading