> ## Documentation Index
> Fetch the complete documentation index at: https://docs.checkfu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a custom channel adapter

> Put an agent in a provider Checkfu does not rent, using only public operations.

Checkfu rents its channels. Nine providers are configured in the conversation
host today — Slack, Discord, Telegram, Microsoft Teams, iMessage and SMS,
GitHub, Linear, Google Chat, and WhatsApp behind an eligibility gate — and
Checkfu maintains no code for any of them. For a provider that is not on that
list, or an in-app chat surface that is yours alone, you write the adapter and
Checkfu stays headless. The
public collaboration contract is the extension point, and it is the same
surface Checkfu's own conversation host uses: the host reaches the platform
only through `@checkfu/sdk`, with no private hook available to it that is not
available to you.

<Note>
  Review [capability status](/getting-started/status) before planning a
  production integration. The public collaboration surface this guide uses is
  covered by V-CHN-001, V-CHN-002, V-CHN-003, V-CHN-005, and V-CHN-015 in the
  verification ledger; the customer-built journey itself is V-CHN-024 and
  carries a recorded gap.
</Note>

## What your adapter owns, and what Checkfu owns

Your adapter owns everything provider-shaped. Checkfu owns everything the
Session means.

| Yours                                                        | Checkfu's                                           |
| ------------------------------------------------------------ | --------------------------------------------------- |
| The provider app registration and its credentials            | Workspace, Principal, and permission authority      |
| Verifying the inbound webhook signature                      | Conversation binding identity and idempotent ingest |
| Turning a provider payload into a canonical conversation key | Session admission, membership, and the event log    |
| Calling the provider to post, edit, or react                 | The one-winner fence over that provider call        |
| Your own retry, backoff, and process supervision             | Settlement — what the agent actually decided        |

Two consequences are worth stating before you write a line of code.

**Checkfu never verifies your provider's signature.** It cannot: you own the
app registration and the signing secret. Verify the request before you call
any Checkfu operation, and reject it there. A forged payload that reaches
`ingestConversation` is a real message as far as the platform is concerned.

**Delivery is best effort, and that is a deliberate posture.** External
channels carry the grade their substrate provides. Checkfu does not claim
exactly-once provider delivery on any channel, rented or custom. What it does
guarantee is that two instances of your adapter cannot both post the same
answer — see [One winner](#one-winner-not-exactly-once) below.

## The five calls, in the order a turn makes them

### 1. Pair the installation and its scopes, once

Before any traffic, pair the provider installation that stands for your
provider tenant, create the surface scopes that stand for the places inside it
— `root`, `team`, `channel`, `personal` — and place an agent on those scopes,
declaring its identity posture: `autonomous` (the agent acts as its own
service Principal) or `delegated` (it acts as the resolved person).

This is one-time setup and it is not channel-specific: it is the same flow
every installed agent uses, whoever built the surface.
[Installations](/concepts/installations) covers pairing proofs, the scope
hierarchy, and identity posture in full, and
[Install an agent blueprint](/guides/install-an-agent-blueprint) walks a
worked example.

What matters for the rest of this guide is the three ids that setup produces —
the provider installation, one surface scope, and the agent placement. They
are your adapter's whole configuration, and every call below carries all
three.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const config = {
  external_installation_id: process.env.ACME_EXTERNAL_INSTALLATION_ID,
  surface_scope_id: process.env.ACME_SURFACE_SCOPE_ID,
  agent_installation_id: process.env.ACME_AGENT_INSTALLATION_ID,
}
```

### 2. Resolve the speaker to a Principal

Map the provider's stable user id — never a display name, never an email — to
a Checkfu Principal. Checkfu stamps the provider identity link from those
stable identifiers itself and writes an audit record; your adapter cannot
assert a link, which is exactly what makes multiplayer steering, approvals,
and audit attribute correctly.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const identity = await checkfu.collaboration.externalIdentities.resolve({
  external_installation_id: installation.id,
  external_user_id: `acme:${event.user.id}`,
  display_name: event.user.profile.display_name,
})
```

Namespace the `external_user_id` with your channel key. Two providers can hand
you the same opaque id, and a Principal is not a place to discover that.

### 3. Resolve the conversation binding

One canonical external conversation binds to one Checkfu Session. The
conversation key is a `kind`, a root, and a thread; `resolve` creates the
binding or returns the existing one, so your adapter never tracks which
threads it has seen.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
// `kind` is the required discriminant: `thread` carries a thread id, while
// `ambient` and `direct` carry `thread_external_id: null`. Omit it and both
// calls below reject the body.
const conversation = {
  kind: "thread",
  root_external_id: `acme:${event.channel.id}`,
  thread_external_id: event.thread_ts ?? event.ts,
}

await checkfu.collaboration.conversationBindings.resolve({
  external_installation_id: installation.id,
  agent_installation_id: placement.id,
  surface_scope_id: scope.id,
  conversation,
  principal: identity.principal.id,
})
```

### 4. Ingest exactly one provider event

`ingestConversation` appends one authored drive event to the bound live
Session. It is idempotent on `external_event_id`, so a provider redelivery
converges on the same disposition instead of driving the agent twice. Give it
the provider's own message id, namespaced.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const receipt = await checkfu.collaboration.conversationBindings.ingest({
  external_installation_id: installation.id,
  agent_installation_id: placement.id,
  surface_scope_id: scope.id,
  conversation,
  external_event_id: `acme:${event.ts}`,
  principal: identity.principal.id,
  input: {
    kind: "invoke",
    content: event.text,
    is_direct_mention: event.text.includes(`<@${botUserId}>`),
  },
})
```

The receipt carries `disposition` — one of `driven`, `observed`, `replayed`,
or `filtered` — the bound `session_id`, and, for a driven turn, the exact
`event_id` and `event_sequence` of the authored event. `observed` is what the
authorless occurrences below settle as: the turn is recorded, no Run is
driven, and both coordinates are null. Treat anything other than `driven` as
"there is no reply coming for this delivery", and never read a null coordinate
as a zero. **Those coordinates are an exclusive
cursor.** Read the Session log strictly after `event_sequence` and you will
never replay an earlier turn's reply or stop at a historical settlement. Do not
substitute the Session's latest sequence, and do not treat a missing sequence
as zero; a driven receipt without one is a failure, not a default.

Ingest with retries disabled. A provider redelivery is the retry path, and it
converges server-side on `external_event_id`. Your own retry loop just makes
duplicates more likely to race.

### 5. Read the answer from the event log, then post it once

Stream the Session event log from the exclusive cursor and derive the
interaction result. Settlement is readable from the log — that is the whole
point of the ledger — so never infer it from elapsed time, from the provider's
delivery telemetry, or from the fact that a Run appears to be over.

Before you call the provider, claim the presentation.

## One winner, not exactly-once

The presentation fence is four operations and one rule: **claim before you
post, settle after.**

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const key = {
  conversation_binding_id: receipt.binding.id,
  session_id: receipt.session_id,
  interaction_id: receipt.event_id,
  kind: "final",
  revision: String(reply.through_sequence),
  delivery_role: "reply",
}

const claimed = await checkfu.collaboration.conversationPresentations.claim({
  external_installation_id: installation.id,
  key,
  through_sequence: reply.through_sequence,
})

if (!claimed.won) return // another replica owns this presentation; post nothing

// One token identifies this invocation across begin and settle. Mint it once
// and hold it: settling with the same token replays the committed fact
// instead of re-entering the provider.
const dispatch = {
  external_installation_id: installation.id,
  key,
  generation: claimed.attempt.generation,
  owner: claimed.attempt.owner,
  dispatch_token: crypto.randomUUID(),
}

await checkfu.collaboration.conversationPresentations.beginDispatch(dispatch)

// ... your provider call, and only yours ...

await checkfu.collaboration.conversationPresentations.settle({
  ...dispatch,
  outcome: { state: "posted", provider_message_id: null },
})
```

The claim is content-free — Checkfu never sees the message text — and the
`owner` token is a finite lease, not a permanent grant. Two replicas racing the
same key produce exactly one winner, and the loser posts nothing.

Three rules the fence depends on:

* **A lost outcome settles `indeterminate`, never a blind retry.** If your
  provider call times out, dies mid-post, or returns nothing you can read,
  settle `indeterminate`. Retrying a provider call whose outcome you do not
  know is how one answer becomes two.
* **A settle retry is a settle retry.** Repeating `settle` with the same
  `dispatch_token` and outcome replays the committed fact. It never re-enters
  the provider.
* **`inspectConversationPresentation` is how a restarted process finds out
  where it was**, without exposing another replica's owner token.

## Fences your adapter must not cross

* **Approvals never ride the channel.** Render an approval as a plain link
  into the Checkfu-native approval surface, where the one-shot decision
  authority already lives behind Checkfu authentication. No channel carries
  authorization weight, and no provider's interactive buttons settle anything.
* **Provider delivery receipts are not settlement.** `delivered`, `read`, and
  the provider's own status webhooks are telemetry about a transport. What the
  agent decided is in the Session event log and nowhere else.
* **Do not fabricate a person.** An occurrence your provider authenticates but
  cannot attribute — a deletion, a reaction, a system message — is ingested as
  an authorless observation and comes back `observed`, not `driven`. Do not
  borrow the agent placement's acting Principal to fill the field, and do not
  invent one.
* **Do not put message content in your coordination store.** Locks, dedupe
  keys, and cursors are coordination. The Session is the transcript.

## When the contract is not enough

If you find something the public operations cannot express, **record it as a
gap** — open an issue naming the operation you needed and the journey it
blocks. Do not route around it with a private hook, an undocumented endpoint,
or a second transcript. That fence (D115) is the reason the public contract is
worth building on: everything Checkfu's own conversation host can do, your
adapter can do, and the day that stops being true is a bug in the contract
rather than a feature of being first-party.

## Next

* [Custom channel adapter](/examples/custom-channel-adapter) — a minimal
  reference adapter in one file.
* [Conversation bindings](/concepts/conversation-bindings) — the binding and
  ingest semantics in full.
* [Installations](/concepts/installations) — installations, surface scopes,
  and identity posture.
* [Handle an approval](/guides/handle-an-approval) — the surface an approval
  link points at.
