OpenEve Adapters
OpenEve adapters are deliberately small. gateway.ts chooses where the
runtime runs and which durable services it uses; channel files choose how
external events become OpenEve turns; sandbox files choose where isolated code
and shell work runs. These choices are independent.
Adapters are substitutable substrate: each role (channel, sandbox, blob, state,
scheduler, gateway/deploy) has a generic contract with interchangeable
providers, so the same agent runs unchanged on different infrastructure.
Capabilities are a separate tier. A capability is a single-vendor feature that
gives an agent something new to do rather than somewhere new to run — it exposes
that vendor's own surface and has no generic contract to swap behind. Capability
packages live under capabilities/ (see Capabilities), still
ride the same open provider seam, and describe themselves through provider
metadata so tools like the no-code builder can scaffold them automatically.
Adapter support levels:
- Supported means OpenEve has a real runtime instantiation path, docs, and regression coverage in this repo.
- Preview means the public helper, compiler metadata, and local regression coverage exist, but the adapter still needs live provider smoke coverage or more provider hardening before it should be announced as fully supported.
- Planned means the docs may name the direction, but the adapter is not a production claim yet.
The current matrix is:
| Role | Supported | Preview | Planned |
|---|---|---|---|
| Channels | Slack, Discord, Telegram, Microsoft Teams, Photon/Spectrum | GitHub repo events | - |
| Sandboxes | local dev/test, Docker, Daytona, E2B, Modal | - | - |
| Blob storage | local dev/test, R2, generic S3-compatible storage | - | - |
| Durable state | local dev/test files, Postgres for production, with Neon, Supabase, local, and custom presets | - | other database families |
| Scheduler | local in-process loop, gateway-triggered cloud scheduler, Postgres-backed multi-worker loop | - | - |
| Subagent harnesses | Pi model loop | Codex, Claude Code | - |
| Gateway/deploy | local, Railway | Docker, Fly | other gateway families |
Other database families, gateway families, and sandbox providers are not part of this initial adapter set.
Capabilities are tracked separately from the adapter roles above because they are not substitutable substrate:
| Capability | Supported | Preview | Planned |
|---|---|---|---|
| Voice / telephony | - | LiveKit voice dispatch and SIP calls | - |
Provider Docs Used
The adapter shapes follow current provider docs for:
- Discord interactions
- Discord Gateway
- Slack agents
- Slack Events API
- Slack request verification
- Telegram Bot API
- GitHub App authentication
- GitHub webhook events and payloads
- GitHub webhook delivery validation
- Microsoft Bot Connector authentication
- LiveKit Agents telephony
- LiveKit agent dispatch
- LiveKit SIP API
- LiveKit access tokens
- Docker containers
- Docker resource constraints
- Daytona sandboxes
- E2B sandboxes
- Modal sandboxes
- Modal sandbox files
- Modal sandbox snapshots
- R2 S3 compatibility
- Amazon S3 API
- Neon Postgres connections
- Supabase Postgres connections
- Railway CLI
- Fly deploy
Gateway
Gateway adapters answer one question: where does the compiled OpenEve runtime service run?
They do not choose your state, blob, sandbox, channels, or connections. Those
are separate settings in the same gateway.ts file.
import { adapter, defineGateway } from "@openeve/core";
import { neonPostgres } from "@openeve/postgres";
import { r2Blob } from "@openeve/s3";
import { dockerSandbox } from "@openeve/docker";
export default defineGateway({
deploy: adapter("railway"),
runtime: adapter("node"),
state: neonPostgres(),
blob: r2Blob(),
sandbox: dockerSandbox()
});
Deploy target status:
- Supported:
adapter("railway")orrailwayDeploy()runs the hosted Node runtime through the@openeve/railwaydeploy publisher. - Preview:
adapter("docker")ordockerDeploy()builds the compiled artifact as a Docker image through the@openeve/dockerdeploy publisher and can optionally run it locally. - Preview:
adapter("fly")orflyDeploy()generatesfly.tomland publishes the artifact through the@openeve/flydeploy publisher andflyctl deploy. Fly is not a full support claim until live smoke coverage exists.
Useful CLI flags:
openeve deploy --target docker --docker-image openeve/my-agent --serve
openeve deploy --target fly --fly-app my-agent --fly-region iad
openeve deploy --target railway --railway-project prj_x --railway-service svc_y
Third-Party Provider Packages
Provider registration is open: any npm package can supply a state, blob,
sandbox, deploy, or subagent provider with zero OpenEve core or host
edits. Agents opt in through the third adapter() argument in gateway.ts:
export default defineGateway({
runtime: adapter("node"),
state: adapter("neon-state", { pool: 4 }, { package: "@acme/openeve-neon" })
});
The compiler stamps the package's requiredEnv and setup metadata into
preflight at build time, and the Node host resolves the package's
openeveProvider export at boot. The compiled .openeve/package.json also
declares every third-party provider package as a dependency — pinned to the
version installed in the agent root when present, else the agent's declared
range, else * — so npm install --omit=dev in the deployed artifact pulls
it without manual edits. To implement such a package — or any new channel,
sandbox, blob, deploy, or state adapter — see
Authoring Adapters.
Scheduler
Schedules compile to registration metadata, but the scheduler adapter chooses where the clock lives.
import { adapter, defineGateway } from "@openeve/core";
export default defineGateway({
scheduler: adapter("gateway")
});
Supported scheduler adapters:
adapter("local"): starts an in-process polling loop with the Node host. Use this for local development, tests, and simple single-process hosts.adapter("gateway"): does not start a local loop. Use a platform cron, cloud scheduler, Durable Object alarm, queue worker, or gateway route to callGETorPOST /openeve/scheduler/tick, or callruntime.runDueSchedules()from host code.adapter("postgres"): starts the polling loop and coordinates duplicate workers through the Postgres state adapter's idempotency and dynamic schedule leases. Pair it withstate: adapter("postgres").
For gateway-triggered production schedulers, set OPENEVE_SCHEDULER_SECRET
and send it as Authorization: Bearer <secret> or
x-openeve-scheduler-secret.
Capabilities
Capabilities live under capabilities/ and are single-vendor features, not
substitutable adapters. They plug in through the same openeveProvider seam as
any provider package and carry their own provider metadata — required env,
declared capabilities, and stability. A no-code builder or catalog can render a
plug-in card and credential form directly from that metadata without bespoke
per-capability UI, so adding a capability package is enough to surface it.
Voice is delivered through the subagent-harness seam, which is a mechanism, not an engine claim: LiveKit is a telephony capability, not an alternative reasoning loop like Pi, Codex, or Claude Code.
LiveKit Voice
LiveKit support is preview. OpenEve does not run realtime media inside the
text-model runtime. Instead, @openeve/livekit signs LiveKit server tokens,
calls the LiveKit Agent Dispatch and SIP Twirp APIs, and lets an OpenEve turn
start or route a LiveKit voice session.
Use a tool when the parent agent should decide to dial:
// tools/start_call.ts
import { defineLiveKitOutboundCallTool } from "@openeve/livekit";
export default defineLiveKitOutboundCallTool({
agentName: "support-voice",
outboundTrunkId: "ST_outbound"
});
Use a subagent when delegation should look like any other OpenEve subagent:
// subagents/caller/agent.ts
import { defineSubagent } from "@openeve/core";
import { liveKitVoiceHarness } from "@openeve/livekit";
export default defineSubagent({
description: "Use this subagent for phone-call voice sessions.",
harness: liveKitVoiceHarness({
agentName: "support-voice",
outboundTrunkId: "ST_outbound"
})
});
The LiveKit agent named by agentName must be running in your LiveKit Agents
worker. For outbound phone calls, OpenEve dispatches that worker into a room
and calls CreateSIPParticipant to dial the callee through your outbound SIP
trunk. Incoming phone calls should be routed in LiveKit with SIP dispatch
rules to the LiveKit agent worker; OpenEve can still be used by that worker as
an app/runtime layer, but the phone media path remains LiveKit.
Declare an optional connection file when you want preflight to describe the LiveKit dependency even for tool-only agents:
// connections/livekit.ts
import { defineLiveKitConnection } from "@openeve/livekit";
export default defineLiveKitConnection();
Required environment:
| Purpose | Env |
|---|---|
| LiveKit server API | LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET |
| Default outbound dialing | optional LIVEKIT_OUTBOUND_TRUNK_ID |
| Default voice worker dispatch | optional LIVEKIT_VOICE_AGENT_NAME |
Channels
Channel packages export one-line helpers for normal use. They verify provider
auth, normalize the provider event into ChannelTurn, preserve provider IDs in
metadata/delivery, and send the final reply through provider APIs.
After a deploy, point each channel's provider at the deployed ingress URL with:
openeve channels wire <agentRoot> --url https://your-service.example.com
It computes each channel's ingress URL from the compiled route table and, for
Telegram, calls setWebhook directly (needs TELEGRAM_BOT_TOKEN, and uses
TELEGRAM_WEBHOOK_SECRET when set). For providers that configure their endpoint
in a console (Slack Request URL, Discord Interactions Endpoint, Teams messaging
endpoint), it prints the exact URL to paste. Output is a structured
ChannelWireResult[] (action: "set" | "manual").
// channels/slack.ts
import { defineSlackChannel } from "@openeve/slack";
export default defineSlackChannel();
// channels/discord.ts
import { defineDiscordChannel } from "@openeve/discord";
export default defineDiscordChannel({
gateway: true,
requireMention: true
});
// channels/telegram.ts
import { defineTelegramChannel } from "@openeve/telegram";
export default defineTelegramChannel();
// channels/teams.ts
import { defineTeamsChannel } from "@openeve/teams";
export default defineTeamsChannel();
// channels/github.ts
import { defineGitHubChannel } from "@openeve/github";
export default defineGitHubChannel();
Required channel environment:
| Channel | Required env |
|---|---|
| Slack | SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN, optional SLACK_BOT_USER_ID, optional SLACK_ASSISTANT_ENABLED |
| Discord | DISCORD_PUBLIC_KEY, DISCORD_APPLICATION_ID, DISCORD_BOT_TOKEN, optional DISCORD_GATEWAY_ENABLED, optional DISCORD_GATEWAY_INTENTS, optional DISCORD_BOT_USER_ID |
| Telegram | TELEGRAM_BOT_TOKEN; TELEGRAM_WEBHOOK_SECRET is required outside devMode |
| Teams | MICROSOFT_APP_ID, MICROSOFT_APP_PASSWORD |
| GitHub | GITHUB_WEBHOOK_SECRET; replies need GITHUB_APP_ID + GITHUB_PRIVATE_KEY (installation tokens) or a GITHUB_TOKEN PAT fallback; optional GITHUB_API_URL |
Channel-owned ingress auth and attachment resolution
Channels declare their own production ingress-auth requirements and resolve their own provider attachments; the runtime stays a generic dispatcher.
ChannelDefinition.ingress.requiredSecretEnvis a list of any-of groups of env var names: production boot succeeds when every var in at least one group is set (for example Photon declares[["PHOTON_WEBHOOK_SIGNING_SECRET"], ["PHOTON_INGRESS_TOKEN"]]). The compiler stamps the declaration intoCompiledChannel.metadata.ingress; in production the runtime refuses to boot when no group is satisfied, and indevModeit logs a warning instead. The built-in helpers (defineSlackChannel,defineTelegramChannel,defineDiscordChannel,defineTeamsChannel,definePhotonChannel,defineGitHubChannel) declare this automatically; custom channels can setingresson their channel config and re-export the same shape asingressAuthon the module.ChannelModule.resolveAttachment(attachment, ctx)turns a turn attachment into a download request ({ url, headers, filename? }). The runtime performs the download, applies size/type limits and timeouts, and stores the blob; the channel owns auth lookups (Slackfiles.info, TelegramgetFile, the Teams Bot Framework token flow, Photon bridge bearer headers) and host allowlists. Returnundefinedto fall back to a generic unauthenticated URL download.- Trust boundary: the runtime only calls
resolveAttachmenton the module of the channel that produced the turn, and only when the turn's declared provider matches that channel — a hostile attachment claiming another provider can never route to that provider's credentials. Inside a resolver, useattachmentTrustedForProvider(attachment, "<provider>")andattachmentRemoteUrl(attachment)from@openeve/runtimeto honor the per-attachmentremote.trustedmarkers before attaching credentials.
Slack uses the Events API route /slack/events. It verifies the raw request
body with Slack's signing secret, handles url_verification inline, returns a
2xx ACK for accepted events before model work, and uses Slack event_id as the
delivery idempotency key. It starts turns only for intentional agent entry
points: app mentions, user DM messages, and assistant-thread user messages when
assistant mode is enabled. Delivery always uses the preserved Slack channel and
thread target from the normalized turn.
Slack context augmentation runs after ACK and before default context bundle construction. It supplies the current conversation history plus up to three recent OpenEve Slack conversations for the same user from the last 24 hours as short summaries and structured metadata. It does not fetch workspace-wide Slack history during ingress.
Agent communication channel status:
- Slack and Photon/Spectrum are reference agent channels. Slack maps app mentions, DMs, and assistant-thread user messages into durable OpenEve turns; Photon/Spectrum maps iMessage bridge events into the same lifecycle and adds native actions such as reactions, polls, app cards, and backgrounds.
- Discord is an agent-oriented communication channel when Gateway ingress is enabled. Interactions still cover slash commands, components, modals, and autocomplete; Gateway ingress covers DMs, mentions, and thread/channel messages. Discord starts typing indicators for Gateway turns and delivers through either interaction responses or bot-token channel messages.
- Telegram is an agent-oriented communication channel over Bot API webhooks. It
supports messages, edited messages, callback queries, channel posts, business
messages, forum topics, typing actions, inline keyboards, callback answers,
media sends, and
getFileattachment materialization. - Microsoft Teams is an agent-oriented communication channel over Bot Framework activities. It supports message activities, Adaptive Card invoke submissions, mention stripping, tenant/service URL constraints, typing activities, Adaptive Card replies, suggested actions, and protected attachment materialization.
- GitHub is a repo-event channel (preview: local regression coverage exists;
live GitHub App smoke evidence is still pending).
defineGitHubChannel()serves/github/events, verifiesX-Hub-Signature-256HMACs againstGITHUB_WEBHOOK_SECRET, answerspinginline, and maps repository events (issues,issue_comment,pull_request,pull_request_review,pull_request_review_comment,push,check_run,check_suite,workflow_run,installation) into durable turns. The delivery GUID (X-GitHub-Delivery) is the idempotency key; issue and PR events converse asowner/repo#number, pushes asowner/repo@ref. Bot senders are ignored so the agent's own comments never re-trigger runs. Replies post issue/PR comments using a GitHub App installation token when the event carried an installation id andGITHUB_APP_ID/GITHUB_PRIVATE_KEYare set, falling back to aGITHUB_TOKENPAT; events without an unambiguous comment target (such as pushes) run without a reply delivery.
Connection helpers remain available when agents need provider capabilities beyond receiving messages:
Inbound files follow the same normalized ChannelTurn.attachments contract on
every channel. Adapters should preserve provider file identity plus one of:
- inline content (
content,text,body, or base64data) - a safe remote reference (
url,downloadUrl,contentUrl, orremote: { provider, auth?, url }) - provider-specific lookup metadata such as Telegram
file_id
The runtime materializes remote attachments after the webhook ACK, stores the
original bytes through the configured blob adapter, records them as read-only
/files/original/... resources, and lists them in /files/manifest.json.
Private download details and auth hints are stripped from model-visible
attachment context after storage. Text and Markdown attachments read back as
UTF-8; binary files remain byte-accurate for resource projection. ZIP uploads
are also expanded when possible: the original archive remains available under
/files/original/..., and safe entries are exposed as read-only resources under
/files/extracted/<archive-name>/... for the core file tools. Unsupported, encrypted, oversized, or path-escaping ZIP
entries are skipped or reported without dropping the original uploaded archive.
// connections/github.ts
import { defineGitHubConnection } from "@openeve/github";
export default defineGitHubConnection();
// connections/teams.ts
import { defineTeamsConnection } from "@openeve/teams";
export default defineTeamsConnection();
GitHub App connections require GITHUB_APP_ID and GITHUB_PRIVATE_KEY. Teams
uses Bot Framework credentials.
Live smoke evidence for Discord, Telegram, and Teams lives under
docs/internal/evidence/channels/. The root smoke scripts skip without
credentials and point to those provider checklists when credentials are present.
Sandboxes
The sandbox adapter is acquired lazily when a tool or capability asks for a sandbox. Normal channel receipt, model turns without sandbox-backed tools, skill activation, memory reads/writes, and final delivery do not need to pay sandbox startup cost.
// sandbox/default.ts
import { dockerSandbox } from "@openeve/docker";
export default dockerSandbox({
image: "node:22-slim",
network: "none"
});
Sandbox adapter status:
- Supported:
adapter("local")for trusted dev/test only. - Supported:
adapter("docker")ordockerSandbox()for local container isolation, one container per acquired session, cleanup on dispose, and relative file paths rooted under the sandbox workspace. - Supported:
adapter("daytona")for hosted Daytona sandboxes. - Supported:
adapter("e2b")ore2bSandbox()for hosted E2B sandboxes. - Supported:
adapter("modal")ormodalSandbox()for hosted Modal sandboxes.
All built-in sandbox adapters normalize OpenEve logical paths under the
adapter workspace. /workspace/report.txt resolves under the adapter's sandbox root
inside the provider sandbox, not the host or container root. .. traversal is
rejected. Recursive file listings return OpenEve virtual absolute paths such as
/workspace/report.txt and include file contents so runtime sync can persist
memory and generated artifacts without provider-specific follow-up reads.
Dirty sandbox sessions are retained when async sandbox sync is pending:
Docker stops the container, Daytona pauses/stops, E2B pauses with configurable
memory retention, and Modal detaches. Clean sessions are removed, deleted,
killed, or terminated through the provider lifecycle API. The sync worker
reconnects first and then calls wake()/start when a retained sandbox is warm
or paused.
Hosted adapters do not silently fall back to local execution. Daytona local
fallback exists only for explicit development/test opt-in with
OPENEVE_ALLOW_LOCAL_SANDBOX_FALLBACK=true.
The local sandbox is for trusted dev/test execution. Production Node runtime
construction rejects adapter("local") for sandboxes unless
OPENEVE_ALLOW_LOCAL_SANDBOX_IN_PRODUCTION=true is set to acknowledge that the
host process, filesystem, and network are not isolated. Docker defaults
OPENEVE_DOCKER_NETWORK to none; Daytona supports
OPENEVE_DAYTONA_NETWORK_BLOCK_ALL and allow/domain lists for explicit egress
policy.
Provider env:
| Sandbox | Required env | Lifecycle and policy env |
|---|---|---|
| Docker | Docker CLI/daemon available | Optional DOCKER_HOST, OPENEVE_DOCKER_NETWORK (defaults to none), OPENEVE_DOCKER_CPUS, OPENEVE_DOCKER_MEMORY, OPENEVE_DOCKER_PULL_POLICY, OPENEVE_DOCKER_COMMAND_TIMEOUT_MS |
| Daytona | DAYTONA_API_KEY | Optional DAYTONA_API_URL, DAYTONA_TARGET, OPENEVE_DAYTONA_CREATE_TIMEOUT_SECONDS, OPENEVE_DAYTONA_LIFECYCLE_TIMEOUT_SECONDS, OPENEVE_DAYTONA_AUTO_STOP_MINUTES, OPENEVE_DAYTONA_AUTO_ARCHIVE_MINUTES, OPENEVE_DAYTONA_AUTO_DELETE_MINUTES, OPENEVE_DAYTONA_EPHEMERAL, OPENEVE_DAYTONA_NETWORK_BLOCK_ALL, OPENEVE_DAYTONA_NETWORK_ALLOW_LIST, OPENEVE_DAYTONA_DOMAIN_ALLOW_LIST |
| E2B | E2B_API_KEY | Optional E2B_TEMPLATE, OPENEVE_E2B_TIMEOUT_MS, OPENEVE_E2B_RETAIN_TIMEOUT_MS, OPENEVE_E2B_REQUEST_TIMEOUT_MS, OPENEVE_E2B_PAUSE_KEEP_MEMORY, OPENEVE_E2B_ALLOW_INTERNET_ACCESS |
| Modal | MODAL_TOKEN_ID, MODAL_TOKEN_SECRET | Optional MODAL_APP_NAME, OPENEVE_MODAL_TIMEOUT_MS, OPENEVE_MODAL_WAIT_READY |
Provider snapshots are opt-in through the OpenEve sandbox snapshot policy. Docker reports snapshots as unsupported. Daytona, E2B, and Modal expose snapshot creation only when the installed provider SDK exposes it. Normal production persistence remains OpenEve state/blob sync.
Live sandbox smoke is opt-in:
pnpm smoke:sandbox -- --provider=docker
pnpm smoke:sandbox -- --provider=daytona
pnpm smoke:sandbox -- --provider=e2b
pnpm smoke:sandbox -- --provider=modal
Docker smoke runs when Docker is available. Hosted smoke skips cleanly when
disposable provider credentials are missing. Smoke evidence is written under
docs/internal/smoke/ and records provider, lifecycle result, safe file hashes,
and snapshot status without secrets.
Blob Storage
Production blob storage is S3-compatible. R2 remains first-class through the R2 wrapper, but the runtime contract is the same for R2, AWS S3, and MinIO-style endpoints.
import { defineGateway } from "@openeve/core";
import { s3Blob, r2Blob, minioBlob } from "@openeve/s3";
export default defineGateway({
blob: r2Blob()
// or blob: s3Blob()
// or blob: minioBlob()
});
Generic S3 env:
S3_BUCKETS3_REGIONS3_ACCESS_KEY_IDS3_SECRET_ACCESS_KEY- optional
S3_ENDPOINT - optional
S3_FORCE_PATH_STYLE - optional
S3_PREFIX - optional
S3_PUBLIC_BASE_URL
R2 env:
R2_ACCOUNT_IDR2_BUCKETR2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEY- optional
R2_PREFIX - optional
R2_PUBLIC_BASE_URL
@openeve/r2 still exports r2Adapter(), R2BlobAdapter, and
InMemoryR2Bucket for existing imports.
S3_PUBLIC_BASE_URL and R2_PUBLIC_BASE_URL do not make every blob public.
Blob writes are private by default; adapters return public HTTP URLs only when
the write explicitly uses { visibility: "public" }.
Database
OpenEve stays opinionated here: Postgres is the only production durable state plane in this phase. That keeps runs, messages, tool traces, schedules, dynamic connections, durable skills, idempotency, and migrations on one auditable database contract.
SQLite is not included because it would create a second production state shape
just as deploy targets and channels are expanding. File-backed state remains for
local dev/demo/test, not hosted production: it is single-process by design (its
idempotency reservations live in process memory, so two hosts sharing one state
file cannot coordinate leases or claims). Its writes are crash-safe — temp file,
fsync, then atomic rename — and an unreadable state file is backed up to
<path>.corrupt-<timestamp> and replaced with a fresh state instead of
crashing the host, but multi-replica guarantees always require Postgres.
Connection grant and authorization-session stores follow the same production
rule. Postgres implements those stores directly. File-backed connection
credential stores are for local development or deliberately small deployments;
production Node hosts using them must set OPENEVE_CONNECTION_STORE_SECRET or
OPENEVE_SECRET to a stable secret of at least 32 characters. The known local
development fallback is rejected outside devMode: true.
import { defineGateway } from "@openeve/core";
import { neonPostgres, supabasePostgres, localPostgres, postgresAdapter } from "@openeve/postgres";
export default defineGateway({
state: neonPostgres()
// or state: supabasePostgres()
// or state: localPostgres()
// or state: postgresAdapter({ provider: "custom" })
});
Default env:
DATABASE_URL- optional
OPENEVE_POSTGRES_CONNECTION_ENV - optional
OPENEVE_POSTGRES_SSL_REJECT_UNAUTHORIZED
Neon, Supabase, local Postgres, and custom Postgres all run the same OpenEve migrations and schema.
Observability
Telemetry is not a gateway adapter — it lives in agent/instrumentation.ts.
@openeve/otlp provides an OTLP/HTTP GenAI telemetry sink you wire in the
setup callback; see
Customizing Agents → Observability for the
setup, capture-detail (captureContent) options, and the Langfuse recipe.