# External Conversation Bridges (/docs/ai/external-conversation-bridges)



An external conversation bridge lets a messaging thread act as a client of Ductor's
[durable agent runtime](/docs/ai/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.

```mermaid
sequenceDiagram
  participant S as Slack
  participant W as Signed webhook
  participant Q as Durable thread queue
  participant A as Agent runtime
  participant C as Governed Slack connector

  S->>W: event_callback + team_id
  W->>W: resolve active connection + verify signature
  W->>W: bind provider user to Ductor actor
  W->>Q: admit immutable event
  W-->>S: 202 accepted / duplicate / queued
  Q->>A: admit ordered durable turn
  A-->>Q: committed events or input_required
  Q->>C: slack.send_message with idempotency key
  C-->>S: reply in originating thread
```

## Enable the bridge [#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:

```yaml title="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:

```text
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 [#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:

```yaml title="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.

<Callout type="warn" title="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.
</Callout>

## Durable ordered admission [#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:

```json
{
  "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.

## Approval links [#approval-links]

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.

```text
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.

<Callout type="warn" title="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.
</Callout>

## Operational checklist [#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.

## Related [#related]

<Cards>
  <Card title="Durable Chat Agent" href="/docs/ai/chat-agent">
    Dashboard admission, session projections, resumable SSE, inputs, and cancellation.
  </Card>

  <Card title="Durable Agent Runtime" href="/docs/ai/durable-agent-runtime">
    Shared journal states, leases, recovery boundaries, and reconciliation.
  </Card>

  <Card title="Connections" href="/docs/connectors/connections">
    Tenant-owned provider connections and encrypted credential lifecycle.
  </Card>

  <Card title="Agent Tool Security" href="/docs/ai/agent-tool-security">
    Tool manifests, schema binding, policy receipts, and approval requirements.
  </Card>
</Cards>
