AI & Agents

External Conversation Bridges

Admit authenticated Slack threads into Ductor's durable agent runtime with explicit actor bindings, ordered processing, same-thread replies, and single-use browser approvals.

An external conversation bridge lets a messaging thread act as a client of Ductor's durable agent runtime. Slack is the first supported provider. The bridge verifies the request, resolves tenant and actor identity from server-owned connector state, durably admits the message, and returns the result to the same thread through a governed connector action.

event_callback + team_id resolve active connection + verify signature bind provider user to Ductor actor admit immutable event 202 accepted / duplicate / queued admit ordered durable turn committed events or input_required slack.send_message with idempotency key reply in originating thread Slack Signed webhook Durable thread queue Agent runtime Governed Slack connector

Enable the bridge

The Slack webhook is part of enabled durable chat. Configure chat, inference, Postgres, connector encryption/storage, an operational tool backend, and the Slack action executor before startup:

configs/ductor.yaml
aichat:
  enabled: true
  durable_runtime: true
  model: gemini-2.5-flash-lite
  max_iterations: 8
  max_tool_calls: 16

  # Optional: browser decisions for approval-gated Slack turns.
  approval_links_enabled: true
  approval_links_base_url: https://ductor.example.com
  approval_link_ttl: 15m

approval_links_base_url must be an externally reachable HTTPS origin or path prefix without credentials, query, or fragment. TTL must be greater than zero and no more than 24 hours. Startup fails closed when approval links are enabled without the durable chat runtime or valid link settings.

Configure Slack Events API to send requests to:

POST https://<ductor-host>/agent-webhook/slack

The endpoint also answers Slack's signed URL-verification challenge. It accepts app_mention events and direct messages. Bot messages, message subtypes, messages from the app's own authorized user, and unsupported event types are ignored or rejected before admission, preventing reply loops.

Connection and identity binding

The webhook does not trust tenant, actor, role, or subscriber identifiers from the provider body. It resolves the Slack team_id to one active connector connection by external account id, reloads that connection through the credential boundary, and verifies the request with the connection's signing_secret (or webhook-secret credential fallback) and Slack timestamp/signature headers.

Each permitted Slack user also needs an explicit tenant-owned binding in connection metadata. Keys are provider user ids, never display names:

Slack connection metadata
agent_identity_bindings:
  U08ABC123:
    actor_id: user_01JZ7B9A4X
    subscriber_id: customer_42
    role: member

actor_id is required. subscriber_id is optional correlation data. An omitted role defaults to member. Missing, malformed, cross-connection, or unverified bindings fail before conversation state changes.

Webhook authentication is not actor authorization

A valid Slack signature proves which connected workspace sent the event. The explicit provider-user binding decides which Ductor actor and role may operate the agent. Both checks are required.

Durable ordered admission

One Slack workspace/channel/thread tuple maps to one durable Ductor agent session. The first message creates the mapping; later messages reopen the same session and are processed FIFO. Admission returns quickly after persistence:

{
  "ok": true,
  "disposition": "accepted",
  "queue_position": 0
}

Disposition is accepted, duplicate, or queued. Provider event id plus canonical event hash make exact webhook retries idempotent; the same event id with different content is a conflict. The encrypted inbound record stores immutable correlation and message data separately from mutable lease, attempt, wait, and terminal fields.

Current Slack bounds are a 1 MiB webhook body, 32 KiB normalized message, 100 pending messages per conversation, and 24-hour inbound expiry. Workers use 30-second fenced leases and at most five processing attempts before dead-lettering. A full backlog returns HTTP 429 with Retry-After: 30; durable dependency failure returns 503 so Slack can retry.

Each message becomes one idempotent external turn. Replies execute slack.send_message through the governed connector registry with the original connection, channel, and thread_ts. Terminal and approval replies use stable idempotency keys, so worker restart does not intentionally post duplicates.

Slack's current bridge capability declares NativeActions: false. Approval-gated tools therefore use an encrypted browser link instead of a Slack button. The link binds tenant, inbound message, agent session, input request, expected journal sequence, actor, roles, and expiry.

GET  /agent-approval?token=<opaque-token>  # verify and render only
POST /agent-approval                       # commit approve or reject

GET never changes agent state. It verifies the token and renders a no-cache, frame-denied confirmation page. Only POST records a decision. The durable input sequence fence makes the decision single-use; stale or replayed submissions return HTTP 409. Expired links return 410, and invalid tokens return 400.

After a valid decision, the waiting inbound message moves from waiting_input back into ordered processing. The worker responds to the exact pending input request and continues the existing turn; it does not ask the model to invent a replacement tool call.

Treat the approval URL as a short-lived bearer secret

Do not log, forward, prefetch, or place the full URL in analytics. Ductor encrypts its claims, sets Cache-Control: no-store and Referrer-Policy: no-referrer, and enforces expiry and single use, but possession still authorizes the bound decision until one of those fences closes it.

Operational checklist

  1. Create one active Slack connector connection whose external account id is the Slack team id.
  2. Store the signing secret through connector credentials, never plain application config.
  3. Add explicit agent_identity_bindings for every permitted Slack user.
  4. Expose /agent-webhook/slack over HTTPS and register it with Slack Events API.
  5. Enable approval links only with a public HTTPS base URL and the shortest practical TTL.
  6. Monitor backlog-full, retry, dead-letter, approval-conflict, and connector-action failures without logging message or token content.
  7. Test duplicate delivery, worker restart, out-of-order arrival, bot-loop rejection, expired approval, double submission, and same-thread reply in staging.