# Connecting a chat account

Pairing connects one chat account — a Slack app or a Telegram bot — to your Enso home
and records you as its owner. `enso setup` guides a fresh local home through provider
choices and that private chat pairing. A host such as [Enso Cloud](/docs/cloud/) drives the
same pairing over JSON with `enso connect`. Neither flow logs into a provider account or
starts a provider during pairing. Provider subscription login remains manual; see
[Install](/docs/installation/).

## Pairing

You create the bot yourself in Slack or Telegram; Enso then waits for one challenged
private message from you and keeps the identity it learns. The default installation
includes both transports. If you installed with `--extras` and left one out, pairing stops
and tells you to install `enso[slack]` or `enso[telegram]`; see
[Installation](/docs/installation/#install).

Each attempt waits for at most five minutes, then closes; a closed attempt cannot be
resumed, so start a new one to get a fresh code. Success sends one connection
acknowledgment before closing; it does not dispatch the pairing message to an AI provider.
An acknowledgment failure is a pairing failure. `serve`, native setup, and hosted pairing
share a per-home receive lock, so only one of them can receive chat messages at a time. The
normal service must stop before a new connection or changed hosted defaults are applied.

### Slack

Slack needs two tokens from the same app: the Bot User OAuth Token (`xoxb-…`) and an
App-Level Token (`xapp-…`) with `connections:write`.

1. Print the bundled app definition with `enso slack manifest`. Preparing a home also
   leaves a copy at `~/.enso/slack/manifest.json`.
2. At <https://api.slack.com/apps?new_app=1>, choose **From an app manifest**, pick your
   workspace, and paste that JSON. It enables Socket Mode and requests exactly the scopes
   and events Enso uses.
3. Install the app to the workspace, approve the permissions, and copy its **Bot User
   OAuth Token**.
4. In **Basic Information → App-Level Tokens**, create a token with `connections:write`
   and copy it.

Setup checks bot identity, opens Socket Mode with the app token, and checks that both
tokens belong to the same app. It then gives the user a private-chat link and a fresh
`ENSO-…` code. Only a new direct message whose whole text is that code, sent from a human
account, can pair the owner. Thread replies, bot messages, and events from another
workspace do not pair an account. The resulting `slack:dm:USER` binding and notification
target use that owner's private conversation.

### Telegram

Telegram needs the bot token created by [BotFather](https://t.me/BotFather) with `/newbot`:
send `/newbot`, answer its naming prompts, and copy the token it prints. Use a new bot, so
no other service is already receiving its updates.

Setup verifies bot identity and refuses an existing webhook. A fresh `t.me` Start link
carries a random challenge; open it and tap **Start** in the bot's private chat. Only the
matching new private `/start` from a human account can pair the owner. Old, forwarded,
group, and unrelated messages are ignored. Setup records the owner in `allowed_users`,
binds their private chat, and uses it for notifications. A Telegram `409` stops pairing and
reports the conflict, so the user can stop the competing receiver or create a new bot.
Setup never deletes a webhook or takes over another polling process automatically.

### What pairing writes

Pairing fills in the chat part of `config.json`:

| Key | What pairing sets |
| --- | --- |
| `transports.slack` | `bot_token`, `app_token`, and `notify` — the owner's private conversation |
| `transports.telegram` | `bot_token`, `allowed_users` holding the owner's numeric id, and `notify` |
| `bindings` | `slack:dm:U…` or `telegram:<user id>` mapped to the `default` workspace |

The same write also records your chosen provider, model, and effort. More channels, more
workspaces, and extra bindings are ordinary configuration you edit afterwards; see
[Transports](/docs/configuration/#transports) and [Bindings](/docs/configuration/#bindings).
A home that already has `config.json` cannot be paired again; change an existing connection
there instead.

## Hosted command contract

```text
enso connect start --transport slack|telegram --file FILE|- [--json]
enso connect status [ATTEMPT_ID] [--json]
enso connect cancel [ATTEMPT_ID] [--json]
enso connect finish --file FILE|- [--json]
```

`start` initializes the home if needed; a new attempt requires an absent active config.
Its input is one JSON object, at most 16,384 characters, containing `request_id`, `bot_token`,
and for Slack `app_token`. The request ID is 16–128 letters, digits, underscores, or hyphens. Supply secrets
through stdin or a private file, never command arguments. Retrying the latest request ID
with the same transport and credentials returns its existing attempt, even after apply;
changed credentials or a fresh attempt require a new ID. Only the latest attempt is retained.
A second receiver is refused until the first closes or is cancelled.

```bash
enso connect start --transport slack --file - --json <<'JSON'
{
  "request_id": "setup-2026-09-15-0001",
  "bot_token": "xoxb-your-bot-token",
  "app_token": "xapp-your-app-token"
}
JSON
```

Each credential must be 8–4096 characters with no spaces. Telegram sends only `request_id`
and `bot_token`; sending it an `app_token` value is rejected. Unknown keys are rejected for
both transports.

`status` and `cancel` without an ID operate on the latest attempt. Supplying a different ID
fails instead of affecting a newer attempt. The ID is a positional argument, for example
`enso connect status ATTEMPT_ID --json`, not an `--attempt-id` option.
Cancellation invalidates the challenge, closes the receiver, and removes temporary
credentials. An interrupted process is reconciled on the next status read. Cancel never
removes or changes an applied config.

`finish` accepts exactly `attempt_id`, `defaults` (the `provider`, `model`, `effort` triple),
and `expected_hash`. The agent must be a bundled model and an effort that its adapter accepts
without clamping; use [`enso providers --json`](/docs/cli/#onboarding-contracts) for offline
choices, not as an authentication check. For the first apply use `"missing"`. It constructs
the complete config on the local machine from the paired identity and private credentials,
then uses validated, atomic [configuration apply](/docs/configuration/#applying-configuration). A repeated successful
apply is idempotent, including a lost command response. Once applied, the same attempt can
revise its AI defaults using the current hash; other config fields are preserved. A config
created outside this pairing flow is preserved and refused. Changing transports requires a
fresh home; existing connections retain the ordinary manual configuration workflow.

```bash
enso connect finish --file - --json <<'JSON'
{
  "attempt_id": "3f0b0f1c-6b0c-4a1f-9f5e-1c2d3e4f5a6b",
  "defaults": {"provider": "claude", "model": "opus", "effort": "high"},
  "expected_hash": "missing"
}
JSON
```

All four commands with `--json` emit one result and exit 0 on success:

```json
{
  "version": 1,
  "ok": true,
  "connection": {
    "attempt_id": "…", "request_id": "…", "transport": "slack", "state": "waiting",
    "bot_name": "enso", "workspace_name": "My workspace",
    "open_url": "https://slack.com/app_redirect?…", "instruction": "ENSO-…",
    "expires_at": "2026-09-09T20:00:00+00:00"
  },
  "config_valid": false,
  "config_hash": "missing",
  "first_reply": null
}
```

`connection` is null before an attempt. Fields not yet known are omitted. States are
`verifying`, `waiting`, `paired`, `applied`, `failed`, `cancelled`, and `expired`.
`paired` is exposed only after the receiver releases its connection. Terminal failures add
`error` and `error_code`; applied state adds `defaults`. `config_valid` means local validation
passed, not that a provider can respond. `config_hash` identifies the current exact config.
A successful `status` command can still report a failed or expired connection: consumers
must inspect `connection.state`, not only the top-level `ok`.

Command failures exit 1 with `{"version":1,"ok":false,"error":"…","error_code":"…"}`.
Codes distinguish `invalid_input`, `already_configured`, `busy`, `conflict`, `missing_extra`,
`invalid_token`, `missing_scope`, `mismatched_app`, `webhook_conflict`, `polling_conflict`,
`connection_failed`, `cancelled`, `expired`, `not_paired`, `not_found`, `config_invalid`,
and `storage_error`. Messages are safe for display and never contain a
third-party response or submitted bot credentials. Invalid CLI syntax still exits 2.

Without `--json` the same commands print `ATTEMPT_ID: state` followed by the open link,
the pairing code, or the error, or `No connection attempt yet.` before the first attempt;
a failure prints only its message and exits 1.

## Private state and completion

Hosted pairing stores its pending credentials in `cache/connect/credentials.json` with
owner-only file access, inside an owner-only directory. The attempt state, advisory locks,
and optional reply receipt live alongside it. Pending credentials remain while a paired
owner finishes setup; failure, expiry, cancellation, or successful apply removes that
temporary file. The applied tokens remain literal values in the private `config.json`.
Native setup keeps credentials in memory until its config write.

First-reply receipts belong to the hosted `connect` flow; native `setup` does not create a
hosted attempt or receipt. After manual provider login and service start, the user sends a
normal message. Runtime records a receipt only after delivering a successful answer using
the selected provider, model and effort to the paired owner and conversation. Empty responses, provider errors,
failed formatting repairs, timeouts, cancellations, and failed delivery do not qualify.
The receipt contains the attempt ID, agent triple, transport, owner, conversation, timestamp,
and config hash; it contains no prompt or response text. It matches the configuration
snapshot the answering turn ran with — each turn reads `config.json` fresh — and the hash
saved by `connect finish`. A config change hides old completion evidence. Reconcile changed
configuration with `connect finish` using the current hash, then get a successful reply
from that configuration before expecting a new receipt; the next turn already runs under
the changed file, but changing the file alone does not update the pairing attempt's saved
hash.
