Skip to main content

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. For the wire-level sidecar contract, read External connectors protocol. For fanning several inputs into one session, read Multiple input connectors in one session. For where connectors sit in the system, read Architecture. For the tools an agent uses once a message arrives, read Tools and MCP. For the auth posture around all of it, read 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.”
  • 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_modewebhook (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:
  • 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.” 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. 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.

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.

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

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, …).
  • moderemote_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.
   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.
   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. 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.

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

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:
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

PlatformStatusIngress mechanismKey env vars
discordproductiondiscord.py gateway clientDISCORD_TOKEN, DISCORD_REQUIRE_MENTION, DISCORD_ATTACHMENT_MAX_BYTES
emailproductionIMAP poll + SMTP sendEMAIL_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
smsproductionTwilio inbound webhookTWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, SMS_WEBHOOK_URL, TWILIO_ALLOWED_MEDIA_HOSTS, SMS_INSECURE_NO_SIGNATURE
webhookproductiongeneric signed webhook routesWEBHOOK_ROUTES_JSON, WEBHOOK_SESSION_MODE
matrixbest-effortlong-poll /syncMATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_USER_ID
signalexperimentalsignal-cli REST SSESIGNAL_HTTP_URL, SIGNAL_ACCOUNT
whatsappexperimentalbridge pollWHATSAPP_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:

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.
   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). 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 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

SymptomLikely causeWhat to do
remote_http connector rejected at configbase URL is loopback/private without opt-inSet allow_private_network=true only for a trusted local sidecar, or use a public URL.
child_process connector rejected at configbase URL is non-loopback, or has a path/query/fragmentUse http://127.0.0.1:PORT with no path.
Sidecar never goes ready/health reports a different instance_id than /manifest, or health never returnsThe readiness gate requires matching instance ids; check the sidecar’s manifest/health implementation and logs.
Ingress returns 401/403missing or wrong shared_token; Telegram secret-token/Slack signature mismatchVerify the transport credential; check allow_unauthenticated_ingress only if you truly meant to disable auth.
Ingress returns 429per-connector rate limit exceededRespect Retry-After; raise ingress_events_per_second if the source is legitimately busy.
Duplicate event_id rejected/dedupedidempotency working as designed, or a reused id with a changed payloadSame id + same payload = dedupe (returns original run/session). Same id + different payload = conflict rejection. Use fresh ids per event.
Session forks unexpectedlythread.path differs between messages you expected to share a sessionStabilize thread.path, or use routing_key/fixed_session_id/binding keys.
Two connectors keep overwriting output routingthe 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 rejectedAuthorization/Cookie/X-API-Key/Proxy-Authorization on an HTTP reply targetMove auth to connector ingress or a purpose-built endpoint; reply routes hold no secrets.
Can’t delete a connectorstill referenced by another connector’s reply targets or a schedule payloadRemove the references first, then delete.
Sidecar secret slot missing at launchcredential_slots points at an absent/empty secret-store slotStore the secret first; the daemon validates slots before launching.
Signal/WhatsApp flakyexperimental status; the external service they depend on is downCheck 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. 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.