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
- one stable runtime sidecar contract for
manifest,health, anddeliver - 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 ingress1
intent, relation, routing_key, or batch submission.
Current compatibility rules:
GET /manifestandGET /healthmust return runtimeprotocol_version = 1- child-process readiness requires
GET /healthto report the sameinstance_idasGET /manifest POST /delivercurrently receives runtimeprotocol_version = 1POST /eventsandPOST /events/batchaccept ingressprotocol_version = 1or2- the bundled Python sidecar helpers now submit ingress
protocol_version = 2by default and automatically fall back to ingress1when talking to an older daemon
Runtime-managed external connectors
Runtime CRUD uses the normal connector surface:GET /v1/runtime/connectorsGET /v1/runtime/connectors/external/{name}PUT /v1/runtime/connectors/external/{name}DELETE /v1/runtime/connectors/external/{name}
platformmode:remote_httporchild_processbase_urlallow_private_networkshared_tokenallow_unauthenticated_ingressfixed_session_idinclude_self_outputadditional_reply_targetsadditional_binding_keyssession_policyingress_events_per_secondchild_process
base_url must use http:// or https:// and must not include URL userinfo. remote_http connectors reject loopback, private, link-local, and metadata-network targets by default; set allow_private_network=true only for trusted local sidecars. Runtime manifest fetches and delivery re-resolve hostnames, reject any private/special address in the answer set, pin the validated addresses for the request, and never follow redirects. child_process connectors must use a loopback base_url without a path, query, or fragment. Empty shared_token values are rejected; if no shared token is configured, allow_unauthenticated_ingress=true must be explicit.
Daemon HTTP clients for sidecar runtime checks and delivery use bounded timeouts, ignore proxy environment variables, and do not follow redirects. Runtime manifests are cached briefly and then revalidated so a sidecar at a stable URL cannot keep an old capability contract indefinitely.
child_process accepts:
commandargsenvcredential_slotsworking_dir
Sidecar runtime endpoints
One external sidecar exposes:GET /manifestGET /healthPOST /deliver
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=trueKHEISH_EXTERNAL_CONNECTOR_TEST_MODE=true- the sidecar
base_urlis loopback-only - the sidecar has a configured shared token
Ingress endpoints
The daemon-owned external ingress surface is:POST /v1/connectors/external/{name}/eventsPOST /v1/connectors/external/{name}/events/batch
shared_token unless:
allow_unauthenticated_ingress=true
- per-connector rate limiting through
ingress_events_per_second - request body limits
- per-event
content,input_items,attachments, and inline-asset limits - a deterministic input shape:
input_itemscannot be combined with legacycontentorattachments - idempotency by
event_id - payload-fingerprint mismatch rejection for reused
event_id - durable connector-ingress receipts with hashed event ids and payload fingerprints, so restart recovery does not depend only on run metadata
429 with Retry-After. For batch overload, the affected item returns rate_limited with retry_after_ms.
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_TOKENKHEISH_EXTERNAL_CONNECTOR_CREDENTIAL_KEYS_JSON
GET /v1/connectors/external/{name}/credentials/{env_key}
Authorization: Bearer $KHEISH_EXTERNAL_CONNECTOR_CREDENTIAL_TOKEN.
Successful responses return:
valuelease_idgrant_idexpires_at_ms
- 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:
Fields
instance_id: opaque sidecar instance identifier; child-process ingress is validated against the sidecar manifestevent_id: durable idempotency key inside one connectorfingerprint: optional extra hash or upstream event revision used to reject conflicting replaysoccurred_at_ms: optional source timestampactor_id: optional upstream actor identifiersource_kind: free-form source family such asdiscord,email,webhook,grafana_alertintent: optional free-form semantic hint such asmessageordomain_eventrelation: optional typed relation with:kindtarget_event_id
thread.path: hierarchical conversation identityrouting_key: optional non-thread conversation key for threadless eventscontent: canonical human-readable summary of the eventinput_items: multimodal prompt itemsattachments: durable inbound filesreply_route: opaque connector-defined reply handle payloadmetadata: opaque JSON bag preserved into run input metadata
Session resolution
External ingress materializes or resolves one session in this order:- connector
fixed_session_id - session already bound to the derived external binding keys
- natural session id derived from
thread.path - natural session id derived from
routing_key
- if
thread.pathis present, it drives session identity routing_keyis only used for session identity whenthread.pathis emptyrouting_keyis still persisted into metadata when supplied
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_versionexternal_event_key_sha256external_event_fingerprintexternal_intentexternal_relationexternal_routing_key
Content, input items, and attachments
Use one input shape per event:content: compatibility text-only inputcontentplusattachments: compatibility text plus appended durable assetsinput_items: ordered multimodal input that should enter the prompt surface directly
- for text-only events, send
content - for legacy text-plus-files events, send
contentandattachments - for ordered multimodal events, put every prompt-visible text and asset in
input_items
content or attachments together with input_items. The daemon rejects mixed legacy and ordered input shapes so provider prompt construction stays deterministic.
Batch ingress
POST /events/batch accepts one shared protocol_version and an ordered events array:
- 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
acceptedduplicaterejectedrate_limitederror
- backfills
- imports
- low-latency round-trip reduction when events are already buffered
Single-event responses
POST /events returns:
status values:
acceptedduplicaterejected
- rate limiting on
POST /eventsis still an HTTP429 - rate limiting inside
POST /events/batchis item-scoped and returnsrate_limited
event_id responses return the original run and session identifiers, even if connector routing configuration has changed since the first accepted event. The daemon persists a hashed connector_ingress_key plus external_event_key_sha256 and the payload fingerprint in run metadata; the raw event_id remains part of the protocol response and external metadata contract.
Delivery
Daemon-to-sidecar delivery is unchanged by the current ingress semantics.POST /deliver still sends runtime protocol_version = 1 with:
delivery_idattemptreply_routeconversationcontentpartsartifactsmetadata
Idempotency-Key: kheish:<delivery_id>X-Kheish-External-Protocol-Version: 1
delivery_id, so production sidecars must deduplicate by delivery_id or the Idempotency-Key header. If the sidecar returns HTTP 429 with a numeric or HTTP-date Retry-After header, the daemon schedules the durable retry from that delay.
Retry-After may be numeric seconds or an HTTP-date; the daemon caps downstream retry delay at one hour. A sidecar committed delivery response is only accepted on a 2xx HTTP response. Non-2xx responses with an invalid or missing JSON body are mapped to retryable or terminal delivery failures by HTTP status class. Delivery response bodies are capped at 64 KiB.
When a delivery includes attachments, each asset descriptor contains a download_path:
Authorization: Bearer <shared_token>. The daemon only serves assets referenced by the pending external delivery, so a wrong delivery id, connector name, or asset id returns not found.
Bundled Python helper surface
The Python sidecar helper in connectors/python/common.py exposes:submit_event(payload)submit_events_batch(payloads)
- the ingress protocol version
- the sidecar
instance_id - connector auth headers when configured
Recommended sidecar patterns
- For chat transports, use
thread.pathandreply_route. - For threadless sources, prefer
routing_keyover fake thread coordinates. - For edits, replies, or reactions, fill
relation. - Keep
contenthuman-readable even when the authoritative source data lives inmetadata. - If one source can emit both chat-like and domain-like events, keep
source_kindstable and varyintent.
