Skip to main content

Authoring Adapters

This guide is for implementing new adapters — channels, sandboxes, blob stores, deploy targets, subagent harnesses, and state stores — and packaging them so agents can use them with zero OpenEve core or host edits. Adapters is the companion reference for consuming the adapters that ship in this repo.

The general shape is always the same: implement the runtime interface for the role, export an openeveProvider registration from your package, and agents opt in through adapter(kind, options, { package }) in gateway.ts (or harness: in subagents/<name>/agent.ts).

The Provider Package Contract

Provider registration is open: any npm package can supply a state, blob, sandbox, deploy, or subagent provider. A provider package exports one well-known symbol:

import type { ProviderModule } from "@openeve/core";

export const openeveProvider: ProviderModule = {
providers: [
{
metadata: {
kind: "neon-state", // the adapter kind used in gateway.ts
role: "state", // deploy | runtime | state | blob | sandbox | scheduler | channel | connection | subagent
packageName: "@acme/openeve-neon",
stability: "preview", // supported | preview | planned
requiredEnv: ["NEON_DATABASE_URL"],
optionalEnv: ["NEON_POOL_SIZE"],
capabilities: ["durable-state"]
},
create(ctx) {
// ctx: { role, kind, options, env, fetch, manifest?, artifactRoot?, devMode }
return new NeonStateAdapter(ctx.env.NEON_DATABASE_URL!, ctx.options);
}
}
]
};

The package owns its metadata; the built-in registry in @openeve/core is only an offline mirror for the adapters that ship in this repo (a test asserts they never drift). create(ctx) receives everything construction needs — adapter options merged with host-supplied role extras, environment, fetch, the compiled manifest, and the artifact root — and returns the instance for the role: a StateAdapter/StateStores facet, BlobAdapter, SandboxAdapter, DeployPublisher, AgentHarness, or SubagentHarness. (Telemetry sinks are not adapters — they are wired in instrumentation.ts; see Customizing Agents → Observability.)

Agents opt in through the third adapter() argument:

export default defineGateway({
runtime: adapter("node"),
state: adapter("neon-state", { pool: 4 }, { package: "@acme/openeve-neon" })
});

Resolution order at runtime: built-in kinds keep their local defaults, then packageName on the adapter definition wins, then the built-in metadata registry's packageName. The Node host imports the package (also resolving from the deployed artifact's node_modules), reads openeveProvider, and calls the registration matching the role and kind. Failures are specific and early: no known package for the kind, a package without the openeveProvider export, or a package without a matching role/kind registration each produce a distinct boot error naming the package. openeve deploy uses the same mechanism for deploy targets beyond local/railway/docker/fly.

Preflight picks up third-party requirements at build time: when the compiler sees a packageName it cannot find in the built-in registry, it imports the package from the agent root, stamps the resolved requiredEnv, setup, capabilities, and stability into manifest.providerMetadata, and emits the matching env requirements into manifest.preflight — so openeve deploy --dry-run reports missing provider env exactly like it does for built-ins. If the package cannot be resolved, the compiler keeps the generic unknown-kind requirement and adds a provider-package-unresolved validation warning instead of failing the build; the Node host re-validates at boot.

Channel Modules

A channel package exports a ChannelModule (defined in @openeve/runtime's channel.ts). Every member is optional — implement only what your provider needs:

MemberPurpose
normalizeHttp(request, ctx)Verify and normalize an inbound HTTP request into a turn. Returns { kind: "run" }, { kind: "accepted" } (ACK before model work), { kind: "response" } (reply without a run, e.g. a 401), or { kind: "ignored" }.
startIngress(ctx, emit)Long-lived provider listener (e.g. Discord Gateway). emit.accepted({ turn, idempotencyKey, ... }) feeds the same durable run path as HTTP webhooks and reports capacity rejections with retryAfterMs.
startTurn(turn, ctx)Turn lifecycle hook — typing indicators and similar. The runtime starts it before attachment intake and stops it before final delivery.
augmentContext(turn, ctx)Add recentHistory/channelContext after ACK and before context bundle construction.
send(delivery, ctx)Deliver the final response through the provider API.
ingressAuth{ requiredSecretEnv?: string[][] } — production ingress-auth declaration (see below).
resolveAttachment(attachment, ctx)Turn a turn attachment into an authenticated download request (see below).

Every member receives the same ChannelContext: the compiled channel, agentScope, resolved connections, env, fetch, state, and blob.

Guidance that keeps channels durable and safe:

  • Idempotency. Providers redeliver webhooks. Use the provider's stable delivery id (Slack event_id, Spectrum webhook id + message id, Telegram update_id) as the turn's idempotencyKey so retries never start duplicate runs, and return { kind: "accepted" } before model work for providers that enforce fast ACK deadlines.
  • Verify every request. normalizeHttp owns signature/token verification. Compare secrets with constantTimeSecretEqual from @openeve/runtime and return { kind: "response", status: 401, ... } on mismatch. Provider channel routes are public in production — verification is your only gate.
  • Declare ingress secrets. Set ingress: { requiredSecretEnv: [["MY_WEBHOOK_SECRET"], ["MY_BEARER_TOKEN"]] } on the channel config (any-of groups: boot succeeds when every var in at least one group is set) and export the same shape as ingressAuth on the module. The compiler stamps it into CompiledChannel.metadata.ingress; production boot fails until a group is satisfied, dev mode warns.
  • Resolve your own attachments. resolveAttachment returns { url, headers, filename? } with your provider's credentials and host allowlist; the runtime performs the download, applies size/type limits and timeouts, and stores the blob. Return undefined to fall back to a generic unauthenticated URL download. The trust boundary is enforced by the runtime: only the module of the channel that produced the turn is ever consulted, and only when the attachment's declared provider matches, so a hostile attachment can never route to another channel's credentials. Use attachmentTrustedForProvider(attachment, "<provider>") and attachmentRemoteUrl(attachment) from @openeve/runtime before attaching credentials.
  • Delivery results. A send failure is retried in-process and then deferred onto the durable delivery queue when retryable. Throw for transient provider errors; the runtime treats failures as retryable unless marked otherwise. When resending from the queue, the original turn may be gone — fall back to the persisted payload.delivery target.
  • Outbound HTTP. Use the shared runtime HTTP client (fetchWithPolicy/fetchJson from @openeve/runtime) for provider API calls: per-attempt timeouts, Retry-After handling, backoff with jitter, and size-capped bodies come for free, and the fetchImpl parameter preserves the ctx.fetch injection seam tests rely on.

The Slack, Telegram, Teams, Discord, and Photon packages are the reference implementations for all of the above.

Sandbox Adapters

Implement SandboxAdapter from @openeve/runtime. Only acquire is required; the optional members opt into warm reconnects and the dirty-session retention that sandbox sync depends on:

interface SandboxAdapter {
provider?: string;
acquire(run: RunRecord): Promise<SandboxSession>;
create?(run, input?): Promise<SandboxSession>;
lookup?(input): Promise<SandboxLookupResult | undefined>; // { state: "live" | "warm" }
connect?(input): Promise<SandboxSession | undefined>;
wake?(input): Promise<SandboxSession | undefined>;
pauseOrRetain?(session, input?: { dirty?: boolean; reason?: string }): Promise<void>;
disposeClean?(session): Promise<void>;
}

Rules the built-in adapters follow and yours should too:

  • Paths are OpenEve logical paths rooted under the adapter workspace: /workspace/report.txt resolves under the adapter's sandbox root inside the provider sandbox. Use the shared helpers from @openeve/corenormalizeSandboxPath, normalizeSandboxRoot, resolveSandboxPath (rejects .. traversal), relativizeSandboxPath, and shellQuote — instead of reimplementing path handling.
  • Listings return virtual absolute paths and include contents so runtime sync can persist memory and artifacts without provider-specific follow-up reads.
  • Dirty sessions are retained/paused (not destroyed) while sandbox sync is pending; clean sessions are disposed through the provider lifecycle API.
  • Never fall back to local execution silently.

LocalSandboxAdapter (@openeve/runtime) is the simplest reference; @openeve/docker is the reference for a real isolation boundary.

Blob Adapters

BlobAdapter is two methods:

interface BlobAdapter {
put(key, value, contentTypeOrOptions?): Promise<BlobRecord>;
get(key): Promise<Uint8Array | undefined>;
}

put accepts a content type string or { contentType?, visibility? } and returns a BlobRecord (id, key, uri, sha256, size, ...). Blobs are private by default; only return public HTTP URLs when the write explicitly used { visibility: "public" }. @openeve/s3 is the reference implementation.

Deploy Publishers

Deploy targets implement DeployPublisher from @openeve/core:

interface DeployPublisher {
target: string;
publish(artifact: DeployArtifact, plan: DeployPlan): Promise<DeployReceipt>;
syncSecrets?(secrets: Record<string, string>, plan: DeployPlan): Promise<DeploySecretsReceipt>;
}
// DeployArtifact: { root: string; manifest?: { preflight?: { kind, name }[] } }
// DeployPlan: { target: string; environment: string; agentRevision: string }

publish receives the built .openeve artifact root plus the plan, shells out to provider tooling (the built-ins take a RunDeployCommand so tests can inject a fake), records deployment.json in the artifact, and returns a receipt. Register it with role "deploy" in your openeveProvider and openeve deploy --target <kind> picks it up through the resolver — no CLI edits. @openeve/railway, @openeve/docker, and @openeve/fly are the references.

syncSecrets is optional and should be implemented only for hosted targets that have a remote secret store. It receives key/value pairs from openeve deploy --sync-secrets and must log or return key names only, never secret values.

Engines: Pi Is Canonical, Subagents Are The Extension Point

The primary model engine is not an adapter role. Pi (@openeve/pi) is the canonical engine, constructed directly by the runtime; agent.ts rejects a harness: slot at validate time. The internal AgentHarness contract in @openeve/core remains the seam the runtime speaks through — it keeps the runtime free of engine types, continuations opaque JSON, and durability runtime-owned — and RuntimeOptions.agentHarness exposes it to embedders and tests (the durability suite drives scripted engines through it). It is not a provider-registered extension point.

Delegate engines plug in as subagents instead. SubagentHarness (@openeve/runtime) orchestrates a coarse external session — codex, Claude Code, a LiveKit voice worker — through start/send?/stop?/approve?, with harness-owned state snapshots. Register it with role "subagent" in your openeveProvider and declare harness: adapter("my-engine", {}, { package: "@acme/engine" }) in subagents/<name>/agent.ts. @openeve/codex, @openeve/claude-code, and @openeve/livekit are the references. Any AgentHarness also works as a subagent engine (including follow-ups from persisted continuations) via runtime.agentHarnessAsSubagentHarness(harness).

State Adapters

Durable state is capability-faceted. Start from RunStore — the only required facet — and add facets as your backend supports them:

  • RunStore (required): runs, run events, tool calls, checkpoints, deliveries, and idempotency keys (createRun, updateRun, getRun, listRuns, appendEvent, listEvents, createToolCall, updateToolCall, listToolCalls, createCheckpoint, listCheckpoints, createDelivery, updateDelivery, listDeliveries, reserveIdempotencyKey).
  • Optional RunStore methods unlock durability features: leaseDueDeliveries, completeDeliverySend, failDeliverySend, and recoverExpiredDeliveries enable the durable delivery queue (make the lease multi-replica safe — Postgres uses for update skip locked); listRunsByStatus makes orphan sweeps efficient; touchRun gives the run heartbeat a guarded write that bumps updatedAt only while the run is still created/running — implement it with a single conditional statement (never read-modify-write) so a heartbeat can never race a status transition; close participates in graceful shutdown.
  • Optional facets: ConversationStore, ScheduleStateStore, FileIndexStore, UsageStore, SandboxSessionStore, MemoryStateStore. Hand them to the runtime as a StateStores object ({ runs, conversations?, schedules?, files?, usage?, sandboxSessions?, memory? }); missing facets fall back to in-memory implementations with a single state.degraded boot warning. A full StateAdapter is just the intersection of all facets.

References, in order of approachability: the per-facet InMemory*Store classes and FileStateAdapter in @openeve/runtime, then PostgresStateAdapter in @openeve/postgres (migrations, leases, multi-replica coordination). Capability guards (isRunStore, isStateAdapter, ...) are exported for feature detection.

Testing Your Adapter

Copy the patterns from this repo's acceptance tests (Node node:test + node:assert/strict against built dist/ output):

  • tests/fixtures/provider-fixture/ is a minimal third-party provider package: a package.json with a bare openeveProvider export plus a deliberate no-provider entry for error paths. Model your package (and its tests) on it.
  • tests/provider-registry.test.mjs shows the registration contract tests worth having: adapter(kind, opts, { package }) records packageName; your openeveProvider metadata matches what you document; resolveProvider constructs your adapter with merged options and env; the compiler stamps your requiredEnv into preflight; and the exact error messages for missing/misshapen packages.
  • tests/adapters.test.mjs shows channel-module tests: a custom channel with ingress.requiredSecretEnv and resolveAttachment compiled and exercised end-to-end with zero runtime edits, plus the cross-channel credential trust-boundary test (a forged attachment must never reach your resolver with credentials attached).
  • tests/state-stores.test.mjs shows facet tests: a runs-only StateStores boots with one state.degraded warning; a monolithic adapter is detected with none; a missing runs facet throws.
  • Inject fakes through the seams the contracts already provide: ctx.fetch for HTTP, RunDeployCommand for deploy CLIs, provider client injection for sandboxes.

Document required/optional env, verification behavior, idempotency keys, and preflight requirements alongside the package — Contributing has the checklist.