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:
| Member | Purpose |
|---|---|
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, Telegramupdate_id) as the turn'sidempotencyKeyso retries never start duplicate runs, and return{ kind: "accepted" }before model work for providers that enforce fast ACK deadlines. - Verify every request.
normalizeHttpowns signature/token verification. Compare secrets withconstantTimeSecretEqualfrom@openeve/runtimeand 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 asingressAuthon the module. The compiler stamps it intoCompiledChannel.metadata.ingress; production boot fails until a group is satisfied, dev mode warns. - Resolve your own attachments.
resolveAttachmentreturns{ 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. Returnundefinedto 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. UseattachmentTrustedForProvider(attachment, "<provider>")andattachmentRemoteUrl(attachment)from@openeve/runtimebefore attaching credentials. - Delivery results. A
sendfailure 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 persistedpayload.deliverytarget. - Outbound HTTP. Use the shared runtime HTTP client
(
fetchWithPolicy/fetchJsonfrom@openeve/runtime) for provider API calls: per-attempt timeouts,Retry-Afterhandling, backoff with jitter, and size-capped bodies come for free, and thefetchImplparameter preserves thectx.fetchinjection 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.txtresolves under the adapter's sandbox root inside the provider sandbox. Use the shared helpers from@openeve/core—normalizeSandboxPath,normalizeSandboxRoot,resolveSandboxPath(rejects..traversal),relativizeSandboxPath, andshellQuote— 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
RunStoremethods unlock durability features:leaseDueDeliveries,completeDeliverySend,failDeliverySend, andrecoverExpiredDeliveriesenable the durable delivery queue (make the lease multi-replica safe — Postgres usesfor update skip locked);listRunsByStatusmakes orphan sweeps efficient;touchRungives the run heartbeat a guarded write that bumpsupdatedAtonly while the run is stillcreated/running— implement it with a single conditional statement (never read-modify-write) so a heartbeat can never race a status transition;closeparticipates in graceful shutdown. - Optional facets:
ConversationStore,ScheduleStateStore,FileIndexStore,UsageStore,SandboxSessionStore,MemoryStateStore. Hand them to the runtime as aStateStoresobject ({ runs, conversations?, schedules?, files?, usage?, sandboxSessions?, memory? }); missing facets fall back to in-memory implementations with a singlestate.degradedboot warning. A fullStateAdapteris 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: apackage.jsonwith a bareopeneveProviderexport plus a deliberateno-providerentry for error paths. Model your package (and its tests) on it.tests/provider-registry.test.mjsshows the registration contract tests worth having:adapter(kind, opts, { package })recordspackageName; youropeneveProvidermetadata matches what you document;resolveProviderconstructs your adapter with merged options and env; the compiler stamps yourrequiredEnvinto preflight; and the exact error messages for missing/misshapen packages.tests/adapters.test.mjsshows channel-module tests: a custom channel withingress.requiredSecretEnvandresolveAttachmentcompiled 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.mjsshows facet tests: a runs-onlyStateStoresboots with onestate.degradedwarning; a monolithic adapter is detected with none; a missingrunsfacet throws.- Inject fakes through the seams the contracts already provide:
ctx.fetchfor HTTP,RunDeployCommandfor 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.