Two-way conversation bridge

Part 2 of the one-way alerts recipe: not just seeing customer comments in Slack, but replying from Slack and having the reply post back into OnRamp as a real comment — threaded, attributed, and visible to the customer in the portal.

Scope honesty: this is a real application, not an afternoon script — Slack Events API, OAuth, and a small state store. Budget 1–2 weeks. Build the one-way bridge first; if your team actually replies to those alerts daily, this recipe is your part 2.

What you'll get
The architecture

Two event streams meet in the middle:

Direction Transport You handle
OnRamp → Slack OnRamp webhooks (comment_new, comment_mention) fetch-on-event → post to the project's Slack thread
Slack → OnRamp Slack Events API (message in thread) POST /v1/tasks/{task_uuid}/comments

Plus a state store (any small database) holding two maps:

  1. project_uuid ↔ slack_channel (+ task_uuid ↔ thread_ts for threading)
  2. comment_uuid → done (dedupe) — and the bridge's own identity (below)
⚠️ The echo loop — the part that bites

Here's the failure, step by step, with no filter:

  1. Customer comments in OnRamp → comment_new fires → bridge posts to Slack. ✅
  2. Your teammate replies in Slack → bridge posts it to OnRamp via the API. ✅
  3. That API post fires comment_new too → the bridge dutifully posts it to Slack → the Slack message triggers the Slack-side handler again → …

Without a filter you've built a perpetual-motion machine that fills the thread (and your write quota) with copies of one reply. Two filters, one per direction, both mandatory:

# OnRamp → Slack direction: drop comments the bridge itself authored.
comment = fetch(f"/comments/{comment_uuid}")           # fetch-on-event, always
if comment["created_by_uuid"] == BRIDGE_API_USER_UUID:
    return                                             # our own post — stop here

# Slack → OnRamp direction: drop messages the bridge itself posted to Slack.
if slack_event.get("bot_id") == BRIDGE_SLACK_BOT_ID:
    return

BRIDGE_API_USER_UUID is found once, the same way as in the one-way recipe: post a probe comment via the API and read back created_by_uuid.

The OnRamp side (both directions)
# OnRamp → Slack: inside your webhook receiver (see one-way recipe for the shell)
comment = fetch(f"/comments/{comment_uuid}")
if comment["created_by_uuid"] == BRIDGE_API_USER_UUID:
    return "", 204                                  # echo filter, direction 1
if comment["uuid"] in state.handled_comments:
    return "", 204                                  # at-least-once dedupe
task = fetch(f"/tasks/{comment['associated_object_uuid']}")
thread = state.thread_for(task["project"]["uuid"], task["uuid"])
slack.post(thread, f"*{comment['created_by_name']}*: {comment['message_plain']}")
state.handled_comments.add(comment["uuid"])

# Slack → OnRamp: inside your Slack Events handler
if event.get("bot_id") == BRIDGE_SLACK_BOT_ID:
    return                                          # echo filter, direction 2
task_uuid, parent_comment_uuid = state.context_for_thread(event["thread_ts"])
posted = requests.post(f"{BASE}/tasks/{task_uuid}/comments", headers=HEADERS, json={
    "message_plain": f"{author_name(event['user'])} (via Slack): {event['text']}",
    "replied_to_comment_uuid": parent_comment_uuid,   # keeps it threaded in OnRamp
}).json()["data"]
state.handled_comments.add(posted["uuid"])          # don't re-bridge our own post
# The write that closes the loop — a threaded reply into OnRamp:
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "message_plain": "Morgan T. (via Slack): Use https://sso.acme.example/sp",
    "replied_to_comment_uuid": "PARENT_COMMENT_UUID"
  }' \
  "https://api.onramp.us/v1/tasks/TASK_UUID/comments"

The Slack side (app manifest, Events API subscription, OAuth scopes chat:write + channels:history, request-signature verification) follows Slack's standard bolt-python / bolt-js quickstarts — nothing OnRamp-specific.

Design notes
  • Attribution: API-posted comments are authored by your API user in OnRamp. Prefix the real author's name in message_plain (as above) so customers see who's talking. A native per-user attribution would need per-user tokens — out of scope.
  • Author-type badges: enrich with GET /v1/users?is_customer_user=true (cache the UUID set) to badge customer messages in Slack.
  • Write-rate awareness: comment creation is rate-limited per organization. A busy bridge should queue Slack→OnRamp writes and drain them steadily rather than bursting.
  • Threading: map each OnRamp task to one Slack thread (thread_ts), and pass replied_to_comment_uuid on the way back so both sides stay threaded.
Good to know
  • Validate demand first. Run the one-way bridge for a few weeks and count how often people reply to alerts. If it's rare, part 2 isn't worth two weeks of your time — that's data, not failure.
  • Everything from the one-way recipe still applies: fetch-on-event, at-least-once dedupe, answer webhooks fast.
  • UUID-only — integer IDs leave the API July 31, 2026.