Skip to main content

External connectors protocol

External connectors let Kheish talk to user-authored sidecars without rebuilding the daemon. They are intended for:
  • chat transports such as Discord, Matrix, Signal, WhatsApp, or email
  • generic webhook bridges
  • domain events that still need to materialize durable Kheish sessions
The protocol is deliberately split into two layers:
  • one stable runtime sidecar contract for manifest, health, and deliver
  • one additive ingress contract for daemon-owned event submission

Versioning

There are currently two version numbers in play:
  • runtime sidecar contract: protocol_version = 1
  • ingress contract: protocol_version = 2, while the daemon still accepts ingress 1
This split is intentional. The sidecar runtime contract did not need a breaking migration to add ingress semantics such as intent, relation, routing_key, or batch submission. Current compatibility rules:
  • GET /manifest and GET /health must return runtime protocol_version = 1
  • POST /deliver currently receives runtime protocol_version = 1
  • POST /events and POST /events/batch accept ingress protocol_version = 1 or 2
  • the bundled Python sidecar helpers now submit ingress protocol_version = 2 by default and automatically fall back to ingress 1 when talking to an older daemon

Runtime-managed external connectors

Runtime CRUD uses the normal connector surface:
  • GET /v1/runtime/connectors
  • GET /v1/runtime/connectors/external/{name}
  • PUT /v1/runtime/connectors/external/{name}
  • DELETE /v1/runtime/connectors/external/{name}
External connector runtime payloads accept:
  • platform
  • mode: remote_http or child_process
  • base_url
  • shared_token
  • allow_unauthenticated_ingress
  • fixed_session_id
  • include_self_output
  • additional_reply_targets
  • additional_binding_keys
  • session_policy
  • ingress_events_per_second
  • child_process
child_process accepts:
  • command
  • args
  • env
  • credential_slots
  • working_dir

Sidecar runtime endpoints

One external sidecar exposes:
  • GET /manifest
  • GET /health
  • POST /deliver
Optional local test-only endpoint used by bundled sidecars:
  • POST /__test/inject
/__test/inject is disabled by default and only turns on when all of these are true:
  • KHEISH_EXTERNAL_CONNECTOR_ENABLE_TEST_API=true
  • KHEISH_EXTERNAL_CONNECTOR_TEST_MODE=true
  • the sidecar base_url is loopback-only
  • the sidecar has a configured shared token

Ingress endpoints

The daemon-owned external ingress surface is:
  • POST /v1/connectors/external/{name}/events
  • POST /v1/connectors/external/{name}/events/batch
Both endpoints authenticate with the connector shared_token unless:
  • allow_unauthenticated_ingress=true
Both endpoints also enforce:
  • per-connector rate limiting through ingress_events_per_second
  • request body limits
  • per-event content, input_items, attachments, and inline-asset limits
  • idempotency by event_id
  • payload-fingerprint mismatch rejection for reused event_id

Child-process credential bootstrap

shared_token is the normal transport credential for ingress and delivery. It is not the same thing as the child-process credential lease used for credential_slots. When an external connector runs in child_process mode and declares credential_slots, the daemon starts the sidecar with:
  • KHEISH_EXTERNAL_CONNECTOR_CREDENTIAL_TOKEN
  • KHEISH_EXTERNAL_CONNECTOR_CREDENTIAL_KEYS_JSON
The token is an opaque short-lived lease scoped to that connector name and only the listed env keys. The sidecar can then fetch one concrete secret at a time from:
  • GET /v1/connectors/external/{name}/credentials/{env_key}
using Authorization: Bearer $KHEISH_EXTERNAL_CONNECTOR_CREDENTIAL_TOKEN. Successful responses return:
  • value
  • lease_id
  • grant_id
  • expires_at_ms
Operationally this means:
  • the sidecar never receives the daemon’s long-lived root secrets directly in its startup environment
  • restarting or shutting down the child revokes the active credential lease
  • rotating the root secret slot does not require changing the sidecar bootstrap contract

Single-event ingress

POST /events accepts:
{
  "protocol_version": 2,
  "instance_id": "discord-main",
  "event_id": "discord-123",
  "fingerprint": "discord-123",
  "occurred_at_ms": 1730000000000,
  "actor_id": "user-42",
  "source_kind": "discord",
  "intent": "message",
  "relation": {
    "kind": "reply_to",
    "target_event_id": "discord-122"
  },
  "thread": {
    "path": ["guild-1", "channel-2", "thread-3"]
  },
  "routing_key": "ignored-when-thread-is-present",
  "content": "Summarize this thread.",
  "input_items": [],
  "attachments": [],
  "reply_route": "{\"channel_id\":\"2\",\"thread_id\":\"3\"}",
  "metadata": {
    "message_id": "123"
  }
}

Fields

  • instance_id: opaque sidecar instance identifier; child-process ingress is validated against the sidecar manifest
  • event_id: durable idempotency key inside one connector
  • fingerprint: optional extra hash or upstream event revision used to reject conflicting replays
  • occurred_at_ms: optional source timestamp
  • actor_id: optional upstream actor identifier
  • source_kind: free-form source family such as discord, email, webhook, grafana_alert
  • intent: optional free-form semantic hint such as message or domain_event
  • relation: optional typed relation with:
    • kind
    • target_event_id
  • thread.path: hierarchical conversation identity
  • routing_key: optional non-thread conversation key for threadless events
  • content: canonical human-readable summary of the event
  • input_items: multimodal prompt items
  • attachments: durable inbound files
  • reply_route: opaque connector-defined reply handle payload
  • metadata: opaque JSON bag preserved into run input metadata

Session resolution

External ingress materializes or resolves one session in this order:
  1. connector fixed_session_id
  2. session already bound to the derived external binding keys
  3. natural session id derived from thread.path
  4. natural session id derived from routing_key
Important rule:
  • if thread.path is present, it drives session identity
  • routing_key is only used for session identity when thread.path is empty
  • routing_key is still persisted into metadata when supplied
If none of these can resolve a session, the event is rejected.

Typed semantics

intent, relation, and routing_key are accepted and preserved by the daemon. They are currently projected into reserved run input metadata fields:
  • external_protocol_version
  • external_intent
  • external_relation
  • external_routing_key
They are not yet used by the daemon to alter prompt construction, delivery, or permissions.

Content, input items, and attachments

Use these fields consistently:
  • content: the concise text an agent should always be able to read
  • input_items: multimodal items that should enter the prompt surface directly
  • attachments: durable files or binary blobs that should be preserved as attachments
Recommended rule:
  • always send a meaningful content summary, even for domain events
  • use input_items for images/audio/documents that should be directly consumable by the model
  • use attachments for durable file preservation

Batch ingress

POST /events/batch accepts one shared protocol_version and an ordered events array:
{
  "protocol_version": 2,
  "events": [
    {
      "instance_id": "mail-importer",
      "event_id": "email-1",
      "source_kind": "email",
      "intent": "message",
      "routing_key": "mailbox:ops",
      "content": "Email 1"
    },
    {
      "instance_id": "mail-importer",
      "event_id": "email-2",
      "source_kind": "email",
      "intent": "message",
      "routing_key": "mailbox:ops",
      "content": "Email 2"
    }
  ]
}
Batch semantics:
  • request order is preserved in the response
  • there is no atomicity
  • each event consumes rate limit independently
  • each event goes through the same validation, idempotency, and session-resolution pipeline as /events
  • oversized batches are rejected at the HTTP layer
Batch item result statuses are:
  • accepted
  • duplicate
  • rejected
  • rate_limited
  • error
Use batch for:
  • backfills
  • imports
  • low-latency round-trip reduction when events are already buffered
Do not use batch as a replacement for a telemetry firehose. The connector ingress pipeline still materializes Kheish sessions and runs.

Single-event responses

POST /events returns:
{
  "event_id": "discord-123",
  "status": "accepted",
  "session_id": "external:discord-main:deadbeefcafebabe",
  "run_id": "run_123"
}
Possible status values:
  • accepted
  • duplicate
  • rejected
Important difference from batch:
  • rate limiting on POST /events is still an HTTP 429
  • rate limiting inside POST /events/batch is item-scoped and returns rate_limited

Delivery

Daemon-to-sidecar delivery is unchanged by the current ingress semantics. POST /deliver still sends runtime protocol_version = 1 with:
  • delivery_id
  • attempt
  • reply_route
  • conversation
  • content
  • parts
  • artifacts
  • metadata
That keeps older sidecars compatible while ingress semantics evolve independently.

Bundled Python helper surface

The Python sidecar helper in connectors/python/common.py exposes:
  • submit_event(payload)
  • submit_events_batch(payloads)
Both automatically include:
  • the ingress protocol version
  • the sidecar instance_id
  • connector auth headers when configured
  • For chat transports, use thread.path and reply_route.
  • For threadless sources, prefer routing_key over fake thread coordinates.
  • For edits, replies, or reactions, fill relation.
  • Keep content human-readable even when the authoritative source data lives in metadata.
  • If one source can emit both chat-like and domain-like events, keep source_kind stable and vary intent.