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

# Connectors: where your agents live

> The transports that carry messages into Kheish sessions and answers back out — native Telegram, Slack, and HTTP, daemon-supervised sidecars, the credential-lease handshake, reply routing, and the honest status of the experimental platforms.

# Connectors: where your agents live

An agent that can only be reached through a REST call is a demo. An agent that answers in your team's Slack thread, replies to the customer's email, or picks up a Discord mention is a colleague. **Connectors** are what make the difference. They are the doors your agents live behind — the transports that carry a human message (or a machine event) *into* a durable Kheish session, and carry the agent's answer back *out* to the right place.

This page explains the whole connector story, end to end:

* the two things Kheish deliberately keeps separate — **connectors** (transport definitions) and **reply targets** (routing destinations);
* the three **native** connectors baked into the daemon — Telegram, Slack, and HTTP;
* the **external sidecar** model for everything else — Discord, Matrix, email, SMS, Signal, WhatsApp, and generic webhooks — including the two run modes (`remote_http` and `child_process`);
* how a daemon-supervised child process is launched, what environment contract it receives, and how it leases secrets at startup without them ever sitting in its inherited environment;
* how output routing actually resolves, including the write-back behavior that surprises people;
* how to wire all of this from the Kheish Air console;
* and an honest accounting of what is production-ready versus experimental.

For the durable model behind routing (session defaults, run snapshots, the secret store), read [Connectors and reply targets](../automation/connectors). For the wire-level sidecar contract, read [External connectors protocol](../reference/external-connectors-protocol). For fanning several inputs into one session, read [Multiple input connectors in one session](../automation/connectors). For where connectors sit in the system, read [Architecture](../concepts/architecture). For the tools an agent uses once a message arrives, read [Tools and MCP](./tools-and-mcp). For the auth posture around all of it, read [Security](../operations/security).

***

## The core split: connectors are not reply targets

The single most important idea on this page is a separation Kheish makes on purpose. It is the reason routing stays durable, secrets do not leak into every run, and you can re-point an agent's output without touching its transport.

* A **connector** is a daemon-managed *transport resource*. One Telegram bot. One Slack app. One HTTP webhook definition. One external sidecar bridge. It owns transport-specific configuration and secrets: bot tokens, signing secrets, polling versus webhook mode, rate limits, allow-lists.
* A **reply target** is a normalized *output destination* — a `ReplyHandle` of `{ plugin, address }` — attached to a session, a run, or a connector. It says "put the answer *here*."

```mermaid theme={null}
flowchart LR
    C["CONNECTOR, a transport definition<br/>bot token or signing key<br/>webhook vs polling<br/>rate limits and allow-lists<br/>stable connector name<br/>owned by the DAEMON"]
    RT["REPLY TARGET, a routing destination<br/>plugin such as telegram<br/>address, an opaque route<br/>lives on a session, run, or connector"]
    C -->|"derives a natural reply target, e.g. reply in the same chat or thread"| RT
```

* Change the bot token or polling mode → mutate the connector.
* Change where a session answers → mutate the reply targets.

Why the split matters in practice: a connector's secrets (a Slack signing secret, a Telegram bot token) are configured once, at the daemon level, and never copied into individual sessions or runs. A reply target is just a route — no secret material rides inside it. That means you can inspect, log, and move reply targets freely, while the sensitive transport credentials stay in one place under the secret store. It also means a session can answer on a completely different Slack thread than the one it was created from, just by editing its reply targets — no connector change required.

Kheish supports daemon-managed connectors of four kinds:

* `telegram` — native
* `slack` — native
* `http` — native
* `external` — the sidecar bridge for everything else

The daemon can load connectors from a file at startup and can also create or update them at runtime through the control plane (and through the Kheish Air Runtime page).

***

## Native connectors

Three transports are built directly into the daemon. They are native because they are common, well-specified, and benefit from tight integration (durable polling cursors, signature verification, first-class reply routes). Everything else is a sidecar.

### Telegram

The Telegram connector handles both ingress (inbound updates) and egress (Bot API replies). Its configuration surface (from `TelegramConnectorConfig`):

* `bot_token` (or `bot_token_env` / `bot_token_secret_ref`) — the Bot API credential.
* `secret_token` (or env / secret-ref) — the header secret Telegram sends on webhook calls, verified by the daemon.
* `allow_unauthenticated_ingress` — permit webhook ingress without the secret-token header (discouraged).
* `ingress_mode` — `webhook` (default) or `polling`.
* `polling_timeout_seconds` — long-poll timeout for `getUpdates` when polling.
* `ingress_events_per_second` — per-connector inbound rate limit (default 100).
* `allowed_chat_ids` — optional chat allow-list; empty means all chats.
* `fixed_session_id` — pin all inbound traffic to one session (see multi-input sessions).
* `include_self_output` — reply into the same chat/topic by default (default true).
* `additional_reply_targets`, `additional_binding_keys`, `session_policy`.

Two modes, two tradeoffs:

```mermaid theme={null}
flowchart LR
    subgraph WH["WEBHOOK, the default"]
        TG1["Telegram"] -->|"HTTPS route, verified by secret_token header"| D1["daemon"]
    end
    subgraph PO["POLLING"]
        D2["daemon"] -->|"getUpdates long-poll, timeout is polling_timeout_seconds"| TG2["Telegram"]
    end
```

* Webhook needs a public URL and has the lowest latency.
* Polling works behind NAT or locally and is the simplest to run in dev.

Guardrails the daemon enforces at config time: a polling connector *requires* a bot token (you can't poll without one). Self-output *requires* a bot token even for webhook connectors (you can't reply without one). A webhook connector with neither a `secret_token` nor `allow_unauthenticated_ingress=true` is rejected — webhook ingress must be authenticated unless you explicitly opt out. Empty tokens are rejected outright, and an overridden `api_base_url` must be a clean `http(s)` URL with no userinfo, query, or fragment (so secrets can't hide in the URL).

A Telegram reply route names the connector plus `chat_id`, optional `message_thread_id` (for forum topics), and optional `reply_to_message_id`. That is enough for the daemon to answer in exactly the right chat, topic, and threaded reply.

### Slack

The Slack connector covers Events API ingress and Web API egress, including Enterprise Grid. Its surface (from `SlackConnectorConfig`) adds a few Slack-specific concerns on top of the same pattern:

* `bot_token` (or env / secret-ref) — the Slack bot token.
* `signing_secret` (or env / secret-ref) — used to verify inbound Slack request signatures.
* `allow_unauthenticated_ingress` — accept callbacks without signature verification (discouraged).
* `api_base_url` — override for proxies or self-hosted test doubles.
* `ingress_events_per_second` — inbound rate limit (default 60).
* Allow-lists: `allowed_api_app_ids`, `allowed_enterprise_ids`, `allowed_team_ids`, `allowed_channel_ids`, `allowed_file_hosts`.
* `team_bot_tokens` — per-team bot tokens for Enterprise Grid routing.
* `fixed_session_id`, `include_self_output`, `additional_reply_targets`, `additional_binding_keys`, `session_policy`.

A Slack reply route names the connector, an optional Enterprise Grid enterprise/team scope, a channel, and an optional thread timestamp. The enterprise/team scope is what lets one connector serve a whole Grid: the `team_bot_tokens` list supplies the right bot token per workspace so replies go out under the correct identity.

The allow-lists are the operational security surface here. In a multi-tenant or Grid deployment, you almost always want to pin `allowed_team_ids` and `allowed_channel_ids` so a stray or malicious Events payload from an unexpected workspace or channel is dropped rather than materializing a run.

### HTTP

The HTTP connector is the generic, transport-neutral door: inbound webhooks in, outbound HTTP POSTs out. It is the right choice when the other side is a system rather than a chat surface — a monitoring platform, an internal service, a form backend, a job runner.

Its ingress surface (from `HttpInputConnectorConfig`) is built for machine callers that want strong authentication and replay protection:

* `bearer_token` (or env / secret-ref) — a bearer credential required on inbound requests.
* `require_hmac_signature` + `hmac_secret` — when enabled, inbound requests must carry `X-Kheish-Timestamp` and `X-Kheish-Signature` (HMAC-SHA256), and the daemon rejects requests whose timestamp is older than `signature_max_age_secs` (default 5 minutes). This closes the replay window that a static bearer token alone leaves open.
* `require_idempotency_key` (default **true**) — each accepted payload must carry an idempotency key, so a retried delivery from an at-least-once upstream does not create a duplicate run.
* `allow_unauthenticated_ingress` — the explicit opt-out if you truly want an open endpoint.
* `ingress_events_per_second` — inbound rate limit (default 60).
* `fixed_session_id` / payload `session_id` — either pin a session on the connector, or require the payload to name one.
* `actor_id` — the default actor when the payload omits one.
* `default_reply_targets`, `default_binding_keys`, `session_policy`.

One field is worth calling out for its security posture: `allow_payload_reply_targets` defaults to **false**. By default an inbound HTTP payload **cannot** dictate where the answer is sent — routing comes from the connector's `default_reply_targets` and the session, not from whatever the caller put in the request body. You must opt in explicitly to let payloads override reply routing, and you should only do so when you trust the caller, because letting an inbound request choose the output destination is a routing-hijack risk.

An HTTP reply target names a URL and optional **non-secret** headers. This is a deliberate and enforced limitation: the daemon **rejects** secret-bearing headers on HTTP reply targets — `Authorization`, `Cookie`, `Proxy-Authorization`, and `X-API-Key` are refused. You cannot smuggle a static output credential into a reply route. If your downstream needs auth, use connector-level ingress authentication or a purpose-built downstream endpoint; do not embed a bearer token in a reply handle. This keeps reply targets safe to store, log, and move around, consistent with the connectors-are-not-reply-targets principle.

***

## Reply targets and output routing

Getting a message *in* is half the job. Getting the answer to the *right place* is the other half, and it is where the connector/reply-target split pays off.

### Self-output and additional targets

Every native and external connector supports `include_self_output`. When true (the default), the connector derives a natural reply target from the inbound event — "answer in the same Telegram chat," "reply in the same Slack thread," "respond to the same Discord channel." This is what makes a chat bot feel like a chat bot: you talk to it where you found it, and it answers there.

On top of self-output, a connector can carry `additional_reply_targets` — a fixed list of extra destinations appended after the self target. A support bot might answer the customer in their channel *and* mirror every answer into an internal audit channel. The self target and the additional targets are normalized together into the run's reply routing.

### The resolution order

When a run produces output, Kheish resolves *where it goes* in a strict order. This ordering is the whole reason session reply-target edits are "prospective, not retroactive."

```mermaid theme={null}
flowchart TD
    E["1. EXPLICIT output override, this specific emit says go here"] -->|"none"| RS["2. RUN SNAPSHOT, targets captured when the run was created, immutable"]
    RS -->|"none"| SD["3. SESSION DEFAULTS, durable session reply-target preferences, editable"]
```

A run captures its reply targets at creation time. Editing the SESSION defaults afterward does NOT rewrite that run's snapshot. It only affects FUTURE work and future fallback.

Run reply targets are **immutable snapshots** taken when the concrete work is created. This applies to normal input runs, scheduled input materialized into runs, and parent clarification flows. Because the snapshot is frozen, restart and retry are deterministic — a delivery that retries after a crash routes to the same place it was always going to, not to wherever the session defaults happen to point *now*.

### The write-back behavior that surprises people

Here is the one connector behavior most likely to catch you out, and it deserves a diagram.

When a connector supplies explicit reply targets on an inbound event, those targets do not stay purely run-local. After the daemon schedules a non-daemon, non-scheduler **input** run, the connector's explicit reply targets are also **written back** into the session's stored reply-target *defaults* for future work in that session.

```mermaid theme={null}
flowchart TD
    A["Inbound event, connector A, reply target X"] --> RA["Run created, snapshot reply targets X"]
    RA --> DA["Delivered to X"]
    RA --> SA["Session default reply targets set to X, written back"]
    B["Later, connector B sends an event with target Y"] --> RB["Run created, snapshot Y"]
    RB --> DB["Delivered to Y"]
    RB --> SB["Session default reply targets set to Y, overwrites X"]
```

Net effect: whichever connector arrived LAST sets the session's default output route. Daemon-owned output that falls back to session defaults follows the last arrival.

The consequence: in a session fed by several connectors, "whoever spoke last" quietly becomes the session's default output route. The *current* run always uses its own snapshot, so no in-flight work is misrouted. But daemon-owned output that falls back to session defaults — and future work without explicit routing — follows the most recent arrival.

If you want a stable output policy across many connectors, do not rely on the write-back. Set the session's reply targets deliberately through the control plane, and let per-run snapshots handle the "answer where this specific message came from" cases. This is covered in depth in [Multiple input connectors in one session](../automation/connectors).

### Queued, at-least-once delivery

Outbound delivery is not a synchronous fire-and-hope. Kheish records every answer locally first, then routes it through a persisted delivery queue with retries, a dead-letter log, and operator controls. Delivery is **at-least-once**: a crash after the downstream commits but before the local ledger is written can replay the same delivery, so HTTP and external connectors send an `Idempotency-Key: kheish:<delivery_id>` header and production sidecars must deduplicate on it. The full delivery-queue surface — `deliveries list`, `dead-letter`, `replay`, `resolve`, backpressure reset, retry-timing env vars — is documented in [Output routing](../automation/connectors).

***

## External connectors: the sidecar model

Telegram, Slack, and HTTP are native. Everything else — Discord, Matrix, email, SMS, Signal, WhatsApp, arbitrary webhook bridges, domain-event sources — is an **external connector**: a user-authored *sidecar* that speaks Kheish's external connector protocol. The daemon stays the authority for session binding, idempotency, runs, and delivery state; the sidecar just translates one platform's wire format into Kheish events and back.

There are two run modes.

### `remote_http` vs `child_process`

```mermaid theme={null}
flowchart TD
    CH{"Choose external connector run mode"}
    CH -->|"you operate it elsewhere"| RH["remote_http<br/>sidecar runs somewhere else, you operate it<br/>base_url public or private<br/>private nets need allow_private_network true<br/>you manage lifecycle, restarts, and secrets<br/>good for shared infra or a sidecar behind your own load balancer"]
    CH -->|"turnkey single host"| CP["child_process<br/>daemon launches and supervises the sidecar as a subprocess<br/>base_url loopback only, such as 127.0.0.1 or localhost, no path, query, or fragment<br/>daemon manages lifecycle, restarts, and secret leasing<br/>good for single-host deploys and turnkey setups"]
```

**`remote_http`** connectors point at a sidecar you operate elsewhere. Because SSRF is a real risk when the daemon makes outbound calls to a configurable URL, `remote_http` base URLs are hardened: they must be `http`/`https` with no userinfo, and they **reject loopback, private, link-local, and metadata-network targets by default**. You must set `allow_private_network=true` explicitly to point one at a trusted local sidecar. The daemon re-resolves hostnames at manifest-fetch and delivery time, rejects any private/special address in the answer set, pins the validated addresses for the request, and never follows redirects. Manifests are cached briefly then revalidated, so a sidecar at a stable URL cannot pin an old capability contract forever.

**`child_process`** connectors are launched and supervised by the daemon itself. Their base URL must be **loopback-only** with no path, query, or fragment — the daemon is talking to a process it started on the same host. This is the turnkey mode: you declare the command, and the daemon owns the lifecycle.

Shared configuration for both modes (from `ExternalConnectorConfig`):

* `platform` — a label used in thread keys and diagnostics (`discord`, `email`, `grafana`, …).
* `mode` — `remote_http` or `child_process`.
* `base_url` — the sidecar endpoint.
* `allow_private_network` — the SSRF opt-out for `remote_http` (child-process is loopback by definition).
* `shared_token` (or env / secret-ref) — the bearer credential for ingress and delivery.
* `allow_unauthenticated_ingress` — required to be explicit if there is no shared token.
* `fixed_session_id`, `include_self_output`, `additional_reply_targets`, `additional_binding_keys`.
* `session_policy` — how inbound traffic materializes sessions.
* `ingress_events_per_second` — inbound rate limit (default 100).
* `child_process` — the launch block (only for `child_process` mode).

An empty shared token is rejected. If you configure no shared token at all, you must set `allow_unauthenticated_ingress=true` on purpose — silence is not consent.

### The sidecar runtime contract

Every external sidecar exposes three endpoints. This is the stable runtime contract, currently `protocol_version = 1`.

```text theme={null}
   SIDECAR RUNTIME ENDPOINTS
   =========================

   GET  /manifest   ->  { protocol_version: 1, instance_id, capabilities }
   GET  /health     ->  { protocol_version: 1, instance_id, status }
   POST /deliver    <-  daemon sends an answer to the sidecar

   Optional, test-only:
   POST /__test/inject   (loopback + shared token + two test env
                          flags; disabled by default)
```

Ingress (sidecar → daemon) uses a separate, additive contract at `protocol_version = 2` (the daemon still accepts ingress `1`). The split is deliberate: ingress could grow richer semantics (`intent`, `relation`, `routing_key`, batch submission) without a breaking change to the runtime contract that `deliver` rides on. The bundled Python helpers submit ingress `2` and fall back to `1` against an older daemon.

### What the manifest declares

`GET /manifest` is how a sidecar tells the daemon what it is and what it can do. Beyond `protocol_version` and a stable `instance_id`, it carries a small **capabilities** block and an `experimental` flag. The bundled sidecars declare three capability booleans:

* `attachments_in` — the sidecar can import inbound files from the platform into runs.
* `attachments_out` — the sidecar can deliver outbound assets to the platform.
* `threads` — the platform has a real threaded-conversation model the sidecar maps into `thread.path`.

```text theme={null}
   GET /manifest  ->
   {
     "protocol_version": 1,
     "instance_id": "discord-main",
     "capabilities": {
       "attachments_in": true,     ← imports platform files
       "attachments_out": true,    ← delivers assets back
       "threads": true             ← maps thread.path
     },
     "experimental": false         ← true for signal / whatsapp
   }
```

These flags are honest self-description, not enforcement. The SMS sidecar, for example, sets `attachments_out = false` because Twilio SMS delivery cannot carry daemon attachments — and it fails a delivery that includes assets rather than pretending. The webhook sidecar sets both attachment flags false because it deals in JSON payloads, not files. `experimental = true` on Signal and WhatsApp is the machine-readable version of the warning later in this page.

The **readiness gate** leans on `instance_id`: for a child-process sidecar the daemon considers the process ready only when `GET /health` reports the *same* `instance_id` as `GET /manifest`. A health check that returns a different id (a stale or mismatched process answering the port) is treated as not-ready, which prevents the daemon from routing traffic to the wrong process.

### The delivery payload

When a run produces output for a sidecar-owned conversation, the daemon POSTs `/deliver` (runtime `protocol_version = 1`) with `delivery_id`, `attempt`, `reply_route`, `conversation`, `content`, `parts`, `artifacts`, and `metadata`. Two headers ride along: `Idempotency-Key: kheish:<delivery_id>` and `X-Kheish-External-Protocol-Version: 1`. The sidecar decodes its own opaque `reply_route` (it authored the encoding on ingress) to know where to send.

When a delivery includes attachments, each asset descriptor carries a `download_path` of the form `/v1/connectors/external/{name}/deliveries/{delivery_id}/assets/{asset_id}/raw`. The sidecar fetches that path from the daemon with `Authorization: Bearer <shared_token>`. The daemon only serves assets referenced by the *pending* delivery — a wrong delivery id, connector name, or asset id returns not found — so a sidecar cannot use this as a general asset-read backdoor. A sidecar acknowledges success by returning a `2xx` with a `committed` body; a `429` with `Retry-After` schedules a durable retry (capped at one hour); other non-2xx responses map to retryable or terminal failures by status class.

### The full handshake, with credential bootstrap

Here is the whole life of a `child_process` sidecar, from spawn to first delivery, including how it gets its platform secrets without them ever sitting in its startup environment.

```mermaid theme={null}
sequenceDiagram
    participant D as DAEMON
    participant S as SIDECAR
    participant P as PLATFORM
    D->>S: spawn process with reserved env vars, listed below
    Note over S: process starts
    D->>S: GET /manifest, daemon polls readiness
    S-->>D: protocol_version 1 and instance_id
    D->>S: GET /health
    S-->>D: SAME instance_id, readiness gate passes
    S->>D: GET /v1/connectors/external/NAME/credentials/DISCORD_BOT_TOKEN, Bearer credential token
    D-->>S: value, lease_id, grant_id, expires_at_ms
    S->>P: sidecar connects to Discord and marks ready
    P->>S: inbound event arrives, sidecar builds a Kheish event
    S->>D: POST /v1/connectors/external/NAME/events, ingress protocol 2
    D-->>S: status, session_id, run_id
    Note over D: run produces output
    D->>S: POST /deliver protocol 1, Idempotency-Key kheish delivery_id
    S->>P: sidecar sends to the platform
    S-->>D: 2xx committed
```

Reserved environment variables the daemon sets on spawn:

* `KHEISH_EXTERNAL_CONNECTOR_NAME`
* `KHEISH_EXTERNAL_CONNECTOR_BASE_URL`
* `KHEISH_EXTERNAL_CONNECTOR_DAEMON_BASE_URL`
* `KHEISH_EXTERNAL_CONNECTOR_SHARED_TOKEN`
* `KHEISH_EXTERNAL_CONNECTOR_CREDENTIAL_TOKEN` — a short-lived lease.
* `KHEISH_EXTERNAL_CONNECTOR_CREDENTIAL_KEYS_JSON` — which keys the sidecar may fetch.

Two credential concepts are in play, and confusing them is a classic mistake:

* **`shared_token`** is the *transport* credential. It authenticates ingress (sidecar → daemon `/events`) and delivery (daemon → sidecar `/deliver`). It is a single bearer secret for the connector's traffic.
* **`credential_slots`** are the *platform* secrets the sidecar needs to talk to its upstream (a Discord bot token, IMAP password, Twilio auth token). These are **not** shipped in the startup environment. Instead the daemon injects a short-lived `KHEISH_EXTERNAL_CONNECTOR_CREDENTIAL_TOKEN` (a lease) plus `KHEISH_EXTERNAL_CONNECTOR_CREDENTIAL_KEYS_JSON` (the exact set of env keys the sidecar is allowed to fetch). The sidecar then pulls one concrete secret at a time from `GET /v1/connectors/external/{name}/credentials/{env_key}` using that lease.

Why this design earns its complexity:

* The sidecar never receives the daemon's long-lived root secrets directly in its inherited environment. A process listing or a leaked env dump does not expose the Discord bot token; it exposes at most a short-lived, narrowly-scoped lease.
* Restarting or shutting the child down **revokes** the active credential lease.
* Rotating the underlying root secret slot does **not** require changing the sidecar's bootstrap contract — the sidecar still fetches "DISCORD\_BOT\_TOKEN," it just gets the new value.

At config time, `credential_slots` map an env-key name to a secret-store slot ref, and the daemon validates that each referenced slot exists and is non-empty *before* launching — a missing or empty secret ref fails resolution rather than launching a sidecar that will immediately fall over. The reserved `KHEISH_EXTERNAL_CONNECTOR_*` names cannot be overridden by your `env` or `credential_slots`; attempting to do so is rejected.

### Supervision and isolation

The daemon runs child-process sidecars with real operational hygiene:

* daemon *provider* credentials are removed from the inherited environment (the sidecar has no reason to hold your model-provider keys);
* the daemon does not inherit the sidecar's stdin/stdout/stderr streams;
* spawn, readiness, and exit failures are retried with bounded backoff;
* on Unix the sidecar starts in its own process group, so shutdown can signal the whole descendant tree rather than orphaning grandchildren.

```mermaid theme={null}
flowchart TD
    CFG["configure"] --> VAL["validate secret slots"]
    VAL --> SPAWN["spawn in its own process group"]
    SPAWN --> POLL["poll /manifest and /health"]
    POLL --> CHK{"same instance_id?"}
    CHK -->|"yes"| READY["READY, serve ingress and delivery"]
    CHK -->|"no or timeout"| RETRY["retry with backoff"]
    RETRY --> POLL
    READY --> SHUT["daemon shutdown, signal the process group"]
    SHUT --> EXIT["child exits"]
    EXIT --> REVOKE["credential lease revoked"]
```

### Ingress semantics: session resolution

When a sidecar posts an event, the daemon must decide *which session* it belongs to. The resolution order is fixed and worth memorizing, because it determines whether two messages continue one conversation or fork into two.

```mermaid theme={null}
flowchart TD
    S1["1. connector fixed_session_id, everything into ONE session"] -->|"not set"| S2["2. a session already bound to the event's derived binding keys, sticky binding keys"]
    S2 -->|"none"| S3["3. natural session id from thread.path, hierarchical conversation identity of guild, channel, thread"]
    S3 -->|"thread.path empty"| S4["4. natural session id from routing_key, threadless domain events"]
    S4 -->|"none of the above"| REJ["REJECTED"]
```

Rule: if thread.path is present, it drives identity. routing\_key is only used for identity when thread.path is empty, but routing\_key is always PERSISTED to metadata.

Choosing among these is a design decision:

* Use **`fixed_session_id`** when you truly want many inputs to converge on one pre-decided session (one customer, one incident).
* Use **`thread.path`** when the platform already has a durable conversation identity (a Discord guild/channel/thread, a Matrix room, an email thread anchor).
* Use **`routing_key`** for threadless domain events that should still map repeatedly to one derived session (a Grafana alert stream, a mailbox tag).
* Use **`additional_binding_keys`** when several connectors should share a session that one of them established naturally.

Binding reuse is **sticky, not a rebinding tool**: once a durable binding key is associated with a session, later ingress presenting the same key keeps hitting that session; pointing the same key at a *different* session is rejected rather than silently moved. And the connector's `session_policy` still governs whether a missing session may be auto-created (`create_if_missing`) — and it cannot conflict with an existing session's persona or capability scope, or ingress is rejected instead of silently rebinding.

### Ingress protections

Both `/events` and `/events/batch` enforce, per connector: shared-token auth (unless `allow_unauthenticated_ingress`), the `ingress_events_per_second` rate limit, request-body and per-event size limits, idempotency by `event_id`, and payload-fingerprint mismatch rejection for a reused `event_id`. A deterministic input-shape rule applies: `input_items` cannot be combined with legacy `content`/`attachments`, so provider prompt construction stays deterministic. Single-event overload returns HTTP `429` with `Retry-After`; batch overload returns a per-item `rate_limited` with `retry_after_ms`. Durable ingress receipts (hashed event ids plus payload fingerprints) mean restart recovery does not depend only on run metadata. The full wire contract, field-by-field, is in [External connectors protocol](../reference/external-connectors-protocol).

***

## The bundled platform sidecars

Kheish ships a set of production-facing Python sidecars in `connectors/python/`, each translating one platform into the external connector protocol. Run one directly:

```bash theme={null}
python3 connectors/python/run_connector.py discord
```

The shared helper (`common.py`) exposes `submit_event(...)` and `submit_events_batch(...)`, automatically including the ingress protocol version, the sidecar `instance_id`, and connector auth headers.

### Platform matrix

| Platform     | Status           | Ingress mechanism             | Key env vars                                                                                                                                                                              |
| ------------ | ---------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **discord**  | production       | `discord.py` gateway client   | `DISCORD_TOKEN`, `DISCORD_REQUIRE_MENTION`, `DISCORD_ATTACHMENT_MAX_BYTES`                                                                                                                |
| **email**    | production       | IMAP poll + SMTP send         | `EMAIL_IMAP_HOST`, `EMAIL_IMAP_PORT`, `EMAIL_SMTP_HOST`, `EMAIL_SMTP_PORT`, `EMAIL_ADDRESS`, `EMAIL_PASSWORD`, `EMAIL_POLL_INTERVAL`, `EMAIL_ALLOWED_USERS`, `EMAIL_ATTACHMENT_MAX_BYTES` |
| **sms**      | production       | Twilio inbound webhook        | `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER`, `SMS_WEBHOOK_URL`, `TWILIO_ALLOWED_MEDIA_HOSTS`, `SMS_INSECURE_NO_SIGNATURE`                                            |
| **webhook**  | production       | generic signed webhook routes | `WEBHOOK_ROUTES_JSON`, `WEBHOOK_SESSION_MODE`                                                                                                                                             |
| **matrix**   | best-effort      | long-poll `/sync`             | `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`                                                                                                                              |
| **signal**   | **experimental** | signal-cli REST SSE           | `SIGNAL_HTTP_URL`, `SIGNAL_ACCOUNT`                                                                                                                                                       |
| **whatsapp** | **experimental** | bridge poll                   | `WHATSAPP_BRIDGE_URL`, `WHATSAPP_ALLOWED_MEDIA_HOSTS`, `WHATSAPP_MEDIA_MAX_BYTES`                                                                                                         |

### Discord

The Discord sidecar connects a bot via the `discord.py` gateway. By default it only responds when mentioned (`DISCORD_REQUIRE_MENTION=true`) except in DMs, which keeps it from replying to every message in a busy channel. It maps guild/channel/thread structure into a `thread_path` so a threaded conversation stays one session, imports attachments up to `DISCORD_ATTACHMENT_MAX_BYTES`, and encodes a reply route with the channel, thread, and the message to reply to. Delivery sends back into the same channel/thread, threading the reply where possible. Discord is production-ready.

### Email

The email sidecar polls IMAP for unseen messages and sends replies over SMTP with STARTTLS. It threads by `References`/`In-Reply-To`/`Message-ID` (falling back to subject), so a reply chain maps to one session. `EMAIL_ALLOWED_USERS` restricts which senders are accepted — essential, since an inbox is an open door. It only marks a message seen after the daemon accepts (or dedupes) the event, so a transient failure re-processes rather than silently drops. Attachments up to `EMAIL_ATTACHMENT_MAX_BYTES` are imported. Email is production-ready.

### SMS (Twilio)

The SMS sidecar receives Twilio inbound webhooks at `/twilio/inbound` and validates the `X-Twilio-Signature` HMAC against your `SMS_WEBHOOK_URL` (unless you deliberately set `SMS_INSECURE_NO_SIGNATURE`). Inbound MMS media is fetched only from an allow-list of hosts (`TWILIO_ALLOWED_MEDIA_HOSTS`, default Twilio's own CDNs). Outbound is the Twilio Messages API. Note that SMS delivery does **not** support daemon attachments — a delivery carrying assets is a terminal error. SMS is production-ready within those constraints.

### Webhook

The webhook sidecar turns arbitrary signed HTTP callbacks into Kheish events, keyed by named routes in `WEBHOOK_ROUTES_JSON`. Each route can verify an HMAC signature (`X-Hub-Signature-256` style), filter by event type, render a prompt template around the payload, and optionally post the agent's answer back to a callback URL (host-allow-listed). `WEBHOOK_SESSION_MODE` chooses between one session per delivery or a shared session per route. This is the bridge for CI systems, alerting platforms, form backends, and any "system-to-agent" domain event. It emits `intent: "domain_event"` rather than `message`, reflecting that these are events, not chat. Production-ready.

### Matrix — best-effort

The Matrix sidecar works today, with honest limits: **unencrypted rooms only, no end-to-end crypto**, and it drives inbound via long-poll `/sync`. It threads by Matrix thread relations, imports `mxc://` media, and uploads media on delivery. If your Matrix use is unencrypted rooms, it is serviceable. If you need E2E-encrypted rooms, it is not there yet. Treat it as best-effort, not production-hardened.

### Signal and WhatsApp — experimental

These two are marked **experimental** in their manifests, and the label is not decoration.

* **Signal** talks to a `signal-cli` REST service over JSON-RPC plus an SSE event stream. It depends on you running and maintaining that external service and a registered Signal account. The moving parts (SSE reconnection, attachment fetch via `getAttachment`, group vs direct addressing) are functional but not battle-tested at scale.
* **WhatsApp** talks to an external "bridge" service (`WHATSAPP_BRIDGE_URL`) that you must supply and operate; the sidecar polls it for messages and posts sends/media to it. The bridge itself is out of scope for Kheish, and WhatsApp's platform policies around automation are strict.

Use Signal and WhatsApp for evaluation and internal experiments. Do not put a customer-facing SLA behind them yet. If you deploy them, pin the media host allow-lists, watch the degraded-health signals the sidecars emit, and expect to babysit the external services they depend on.

Tiers from most to least production-ready:

```mermaid theme={null}
flowchart TD
    PROD["production: discord, email, sms, webhook<br/>native transports are telegram, slack, http"] --> BE["best-effort: matrix<br/>unencrypted rooms only, no E2E crypto"]
    BE --> EXP["experimental: signal, whatsapp<br/>depend on external services you run, not SLA-ready"]
```

***

## Configuring connectors from Kheish Air

The Kheish Air console's **Runtime** page is the graphical front door for connectors. Instead of hand-writing connector JSON and POSTing it, you configure transports there and the console drives the same runtime control plane the API exposes.

For a platform sidecar specifically, the flow is **Runtime → Add platform sidecar**. You pick the platform (Discord, email, SMS, Matrix, and so on), choose the run mode, and fill in the connector fields: the `shared_token`, the `fixed_session_id` if you want convergence onto one session, `include_self_output`, `additional_reply_targets`, rate limits, and — for `child_process` mode — the command and the `credential_slots` that map platform env keys to secret-store slots.

```text theme={null}
   KHEISH AIR — RUNTIME → ADD PLATFORM SIDECAR
   ===========================================

   [ Platform      ▼ discord            ]
   [ Mode          ▼ child_process      ]
   [ Base URL        http://127.0.0.1:8802 ]   (loopback for child_process)
   [ Shared token    •••••••••          ]   (stored to secret store)
   [ Fixed session   (optional)         ]
   [ Self output     ☑ include_self_output ]
   [ Credential slots                    ]
     DISCORD_BOT_TOKEN  ->  connectors.external.discord.bot_token
   [ Additional reply targets  + add     ]
                         [  Save  ]

   Save -> daemon validates secret slots -> (child_process)
   launches & supervises the sidecar -> health goes green.
```

The console never shows you raw secret values back. Consistent with the daemon's redaction rules, connector reads return only redacted metadata (`configured`, `source`, `secret_ref`, `env`) — you can see *that* a token is configured and where it comes from, never the token itself. Writes can point at an existing `secret_ref` or supply a write-only `value` that the daemon stores into the secret store before binding the connector to that slot.

Once a sidecar is configured and healthy, its inbound messages appear as runs in the Air **Inbox**, and the schedules that drive recurring work show up on the **Schedules** calendar (see [Schedules, goals, and recurring work](./schedules)). The three Air surfaces line up with the three phases of an agent's life: Runtime is where it *lives* (connectors and sidecars), Schedules is where it *wakes up* (cadences and goals), and Inbox is where its *work lands* (runs to review).

***

## Deletion safety and recovery

Connectors are not deleted blindly. The daemon **refuses** to delete a connector that is still referenced by another connector's configured reply targets or by a stored schedule payload — deleting it would leave dangling routes and broken scheduled work. When a connector does go away, the daemon proactively clears now-invalid session reply-target defaults rather than leaving stale durable routing behind pointing at a connector that no longer exists.

On restart, the runtime connector inventory and session reply-target defaults both survive. Daemon-managed connectors are stored under the state root in the runtime connector store; session reply-target defaults live in session metadata (and are cached in the compact daemon index). The daemon reloads connectors, re-resolves their secrets against the secret store, and repairs reply-target cache state from authoritative session metadata when needed. For child-process sidecars, that means the daemon relaunches and re-leases credentials for them on boot — you do not re-run `run_connector.py` by hand after a restart.

***

## Operator playbooks

### Playbook: a Telegram bot for one team channel

1. Create a Telegram connector with a `bot_token`. Choose `webhook` mode if you have a public URL, `polling` if you don't (e.g., local or behind NAT).
2. For webhook mode, set a `secret_token` so the daemon verifies inbound calls. Do **not** set `allow_unauthenticated_ingress`.
3. Pin `allowed_chat_ids` to your team's chat so the bot ignores strangers.
4. Leave `include_self_output` true so it answers in-chat.
5. If several people should share one agent memory, set a `fixed_session_id`.

### Playbook: a Discord support bot on one host

1. Store the Discord bot token in the secret store (e.g., slot `connectors.external.discord.bot_token`).
2. In Air, Runtime → Add platform sidecar → Discord, mode `child_process`, loopback base URL.
3. Add a `credential_slots` entry: `DISCORD_BOT_TOKEN` → that secret slot. The token never enters the sidecar's startup env.
4. Set a `shared_token` for ingress/delivery auth.
5. Save. The daemon validates the slot, launches the sidecar, waits for `/manifest` and `/health` to agree on `instance_id`, and goes green.
6. Mentions in Discord now create runs; answers thread back into the same channel.

### Playbook: fan several connectors into one incident session

1. Pre-create the session (e.g., `incident-2026-04-18-db-latency`).
2. Configure each connector — the monitoring webhook, the Slack connector, the email sidecar — with the same `fixed_session_id`.
3. Decide output deliberately: set the session's reply targets through the control plane rather than relying on the last-arrival write-back.
4. Each inbound event now creates a run in the shared session; the agent continues from one timeline. See [Multiple input connectors in one session](../automation/connectors) for the full pattern and its concurrency caveats.

### Playbook: a webhook bridge for CI events

1. Run the webhook sidecar with `WEBHOOK_ROUTES_JSON` defining a route per event source, each with its own HMAC `secret` and `allowed_events`.
2. Choose `WEBHOOK_SESSION_MODE`: `per_delivery` for isolated one-shot handling, a shared route session for accumulated context.
3. Point your CI system at `/webhooks/<route_name>`.
4. Optionally set a `callback_url` (host-allow-listed) so the agent's verdict posts back to CI.

***

## Failure modes and how they present

| Symptom                                        | Likely cause                                                                          | What to do                                                                                                                                 |
| ---------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `remote_http` connector rejected at config     | base URL is loopback/private without opt-in                                           | Set `allow_private_network=true` only for a trusted local sidecar, or use a public URL.                                                    |
| `child_process` connector rejected at config   | base URL is non-loopback, or has a path/query/fragment                                | Use `http://127.0.0.1:PORT` with no path.                                                                                                  |
| Sidecar never goes ready                       | `/health` reports a different `instance_id` than `/manifest`, or health never returns | The readiness gate requires matching instance ids; check the sidecar's manifest/health implementation and logs.                            |
| Ingress returns 401/403                        | missing or wrong `shared_token`; Telegram secret-token/Slack signature mismatch       | Verify the transport credential; check `allow_unauthenticated_ingress` only if you truly meant to disable auth.                            |
| Ingress returns 429                            | per-connector rate limit exceeded                                                     | Respect `Retry-After`; raise `ingress_events_per_second` if the source is legitimately busy.                                               |
| Duplicate `event_id` rejected/deduped          | idempotency working as designed, or a reused id with a changed payload                | Same id + same payload = dedupe (returns original run/session). Same id + different payload = conflict rejection. Use fresh ids per event. |
| Session forks unexpectedly                     | `thread.path` differs between messages you expected to share a session                | Stabilize `thread.path`, or use `routing_key`/`fixed_session_id`/binding keys.                                                             |
| Two connectors keep overwriting output routing | the explicit reply-target write-back (last arrival wins)                              | Set session reply targets deliberately; don't rely on write-back for stable policy.                                                        |
| Secret-bearing HTTP reply header rejected      | `Authorization`/`Cookie`/`X-API-Key`/`Proxy-Authorization` on an HTTP reply target    | Move auth to connector ingress or a purpose-built endpoint; reply routes hold no secrets.                                                  |
| Can't delete a connector                       | still referenced by another connector's reply targets or a schedule payload           | Remove the references first, then delete.                                                                                                  |
| Sidecar secret slot missing at launch          | `credential_slots` points at an absent/empty secret-store slot                        | Store the secret first; the daemon validates slots before launching.                                                                       |
| Signal/WhatsApp flaky                          | experimental status; the external service they depend on is down                      | Check the sidecar's degraded-health message and the upstream service; do not put an SLA here.                                              |

***

## FAQ

**What's the difference between a connector and a reply target?**
A connector is a daemon-managed transport definition (a bot, a webhook, a sidecar) that owns secrets and ingress behavior. A reply target is a normalized `{plugin, address}` route that says where output goes. Change the token → mutate the connector. Change where a session answers → mutate the reply targets.

**Which platforms are native versus sidecars?**
Telegram, Slack, and HTTP are native (built into the daemon). Discord, Matrix, email, SMS, Signal, WhatsApp, and generic webhooks are external sidecars speaking the connector protocol.

**`remote_http` or `child_process` — which do I pick?**
`child_process` for a single-host, turnkey deploy where you want the daemon to launch, supervise, and secret-lease the sidecar. `remote_http` when you operate the sidecar elsewhere (shared infra, behind your own load balancer).

**Do platform secrets go in the sidecar's environment?**
No. `shared_token` (transport auth) is in the environment, but platform credentials declared as `credential_slots` are fetched at startup via a short-lived lease token from the daemon's credentials endpoint. They never sit in the inherited env, and the lease is revoked when the child stops.

**Why did my session's default output route change on its own?**
The explicit reply-target write-back: when a connector supplies explicit reply targets on an input run, those become the session's default reply targets for future work. Whoever arrived last wins. Set session reply targets deliberately if you need stability.

**Can I put an API key in an HTTP reply target header?**
No. The daemon rejects `Authorization`, `Cookie`, `Proxy-Authorization`, and `X-API-Key` on HTTP reply targets. Authenticate at ingress or use a dedicated downstream endpoint.

**How does the daemon decide which session an inbound event belongs to?**
In order: connector `fixed_session_id`, then a session already bound to the event's binding keys, then a session derived from `thread.path`, then one derived from `routing_key`. If none resolve and the policy forbids creation, ingress is rejected.

**Is delivery guaranteed exactly once?**
It is at-least-once. HTTP and external connectors send `Idempotency-Key: kheish:<delivery_id>`; production sidecars must deduplicate on it. See [Output routing](../automation/connectors).

**Are Signal and WhatsApp safe for production?**
No — they are experimental and depend on external services you run. Matrix is best-effort (unencrypted rooms only, no E2E). Use them for evaluation, not customer SLAs.

**Do I need to restart my sidecars after a daemon restart?**
For `child_process` sidecars, no — the daemon relaunches and re-leases credentials for them on boot. For `remote_http` sidecars you operate, you own their lifecycle.

***

## Related reading

* [Schedules, goals, and recurring work](./schedules) — the clock that drives runs through these connectors.
* [Tools and MCP](./tools-and-mcp) — what an agent can do once a connector delivers a message.
* [Connectors and reply targets](../automation/connectors) — the durable concept model behind routing.
* [External connectors protocol](../reference/external-connectors-protocol) — the field-by-field sidecar wire contract.
* [Multiple input connectors in one session](../automation/connectors) — fanning several transports into one durable session.
* [Output routing](../automation/connectors) — the queued, at-least-once delivery subsystem.
* [Sessions and runs](../concepts/sessions-and-runs) — the durable state connectors feed into.
* [Architecture](../concepts/architecture) — where connectors sit in the daemon.
* [Security](../operations/security) — auth, secrets, and hardening for connectors and the control plane.
