Integration Guide#
This guide is for developers building an integration — an external application that talks to an opendray-v2 gateway over HTTP and WebSockets, in any language.
If you're operating a deployment, see docs/operator-guide.md.
If you're contributing to opendray itself, see CONTRIBUTING.md.
What is an integration?#
An integration is a separate process — your app — that registers with opendray and consumes its capabilities:
- Create and drive PTY sessions backed by Claude Code, Codex, Gemini, or arbitrary shells
- Subscribe to event streams (session output, idle, ended, channel events, etc.)
- Be reverse-proxied through opendray for inbound traffic
opendray itself does not embed integration code. Every integration is external; this keeps the gateway lean and language-agnostic.
The full surface is documented in this guide and in
docs/operator-guide.md §Integrations.
Quick walk-through#
A 6-step concrete flow for a hypothetical Slack-style bot that posts to Slack when a session ends:
- Operator registers your integration with admin auth, picking a
route_prefixand a list of scopes - opendray returns a plaintext API key once (not stored, not shown again — you save it securely)
- You expose
GET /healthso opendray can probe you every 30s - You subscribe to events via WebSocket using the API key
- When
session.endedfires, your bot posts to Slack - When the user replies in Slack, you
POST /api/v1/sessions/{id}/inputto drive the conversation back into opendray
Registration#
Only an admin can register an integration. Endpoint:
[object Promise]Required fields:
| Field | Notes |
|---|---|
name |
Human-readable label |
base_url |
Where opendray reverse-proxies inbound requests addressed to your route_prefix. Optional — pure consumers can omit it. |
route_prefix |
Unique slug. Reserved: _events, _kinds, _internal, _*. |
scopes |
Array of allow-listed capabilities (see below). |
version |
Free-form, your choice. opendray surfaces it in admin UI. |
Response (HTTP 201):
[object Promise]The api_key is the only time the plaintext is visible. opendray
stores a bcrypt hash (cost 12) and discards the plaintext. Lose the
key and you'll need to rotate via:
…which invalidates the old key immediately and returns a new one.
Authentication#
Every request your integration makes uses the API key as a bearer token:
[object Promise]For WebSocket endpoints (browsers can't set custom headers on the handshake), use the query-parameter form:
[object Promise]opendray validates the bearer token by:
- Trying admin first (in-memory token lookup)
- Falling back to integration (bcrypt-compare against every integration's hash, then attaching the matched integration's scopes to the request principal)
Requests with no Authorization header / no token query param fail
with 401.
Scopes#
Scopes gate what your integration can do. Defined values:
| Scope | What it allows |
|---|---|
session:read |
List sessions, read buffers and metadata |
session:create |
Spawn new sessions |
session:input |
Send keyboard input to a session |
channel:send |
Post messages to channel adapters |
channel:receive |
Receive inbound messages from channel adapters |
provider:read |
List CLI providers + per-provider config |
event:subscribe:<topic> |
Subscribe to one event topic on the WS endpoint |
event:subscribe:* and event:subscribe:session.* work as wildcards
(prefix match).
Today (M3): only event:subscribe:<topic> is enforced — every
other scope is declared but every business endpoint accepts any
valid integration token. Per-route scope enforcement on session /
channel / provider endpoints is on the v1.1 roadmap.
Default scopes for a new integration:
["session:read", "event:subscribe:session.*"].
REST endpoints exposed for integrations#
All under /api/v1/. Full method/route table below; the dual-auth
group accepts both admin and integration tokens.
Admin-only#
| Method | Path | Purpose |
|---|---|---|
POST |
/integrations |
Register |
GET |
/integrations |
List |
GET |
/integrations/{id} |
Detail |
PATCH |
/integrations/{id} |
Update (base_url, scopes, version, enabled) |
DELETE |
/integrations/{id} |
Deregister |
POST |
/integrations/{id}/rotate-key |
Issue new API key |
GET |
/integrations/_calls |
Call-log audit |
Dual-auth (admin or integration token)#
| Method | Path | Purpose |
|---|---|---|
POST |
/sessions |
Create a new PTY session |
GET |
/sessions |
List sessions |
GET |
/sessions/{id} |
Session detail |
DELETE |
/sessions/{id} |
Terminate |
POST |
/sessions/{id}/input |
Send keyboard input |
POST |
/sessions/{id}/resize |
Resize PTY |
WS |
/sessions/{id}/stream |
Bidirectional terminal stream |
GET |
/sessions/{id}/buffer |
Replay the ring buffer |
GET |
/providers |
List CLI providers |
PATCH |
/providers/{id}/config |
Set per-provider config |
GET |
/channels |
List channels |
WS |
/integrations/_events |
Subscribe to event topics |
WebSocket events#
Subscribe by query string:
[object Promise]topicsis comma-separated; wildcards via trailing.*(e.g.session.*)- The handler enforces
event:subscribe:<topic>for each requested topic — admin tokens bypass the check
Frame schema#
Every event frame is a JSON object:
[object Promise]Standard topics#
| Topic prefix | Source |
|---|---|
session.* |
output, idle, ended from the PTY session manager |
channel.* |
message_received, command_received, message_sent from channels |
integration.* |
registered, health_change, rotated from the registry |
Connection management#
- Server pings every 20s; clients should respond with pong (handled automatically by most WS libraries)
- Per-message write timeout is 5s
- The connection drops on auth failure, scope violation, or topic parse error — reconnect with backoff
Reverse proxy#
If you set a base_url on registration, opendray exposes a passthrough:
Example: with route_prefix = "slack-bot" and base_url = "http://slack-bot-server:3000",
a request to:
…is forwarded to:
[object Promise]opendray injects three headers:
X-OpenDray-Forwarded-For— the original client IPX-Integration-ID— your integration's ID (so you know it's coming from opendray, not direct)X-OpenDray-API: v1
…and strips the inbound Authorization header before forwarding,
so admins can hit your integration through opendray without leaking
their token to your process.
If your integration is disabled or unhealthy, opendray returns HTTP 503 and never forwards the request.
Health probe#
opendray probes every registered integration's GET /health every
30 seconds. Expected response:
A non-200 response or a non-healthy status flips the integration's
health flag in opendray's registry; the next reverse-proxy request
will return 503 instead of forwarding.
Worked example: Slack-style bot#
[object Promise]Reference implementation#
A working TypeScript demo client lives at
examples/integrations/demo-client/.
It shows:
- First-run registration flow + secure local API-key storage (mode 0600)
- Key rotation recovery (auto-trigger on 401 → admin rotates → new key persisted)
- Session creation, input, event subscription
- The full 9-step lifecycle from
registertounregister
Files to read in order:
examples/integrations/demo-client/src/index.ts— top-level flowexamples/integrations/demo-client/src/client.ts—OpendrayClientREST + WS abstraction (a model for your own SDK)examples/integrations/demo-client/src/state.ts— local key persistence
The demo deliberately avoids any framework dependency — it's pure
node fetch + the ws library. Port to your language of choice.
See also#
docs/operator-guide.md— running opendraySECURITY.md— vulnerability disclosure