Customizing Agents
OpenEve starts small, but every major runtime choice has an explicit customization point. Add the smallest file or config block that owns the behavior you need.
Context
Agents do not need context.ts. Without it, OpenEve uses defaultContext().
The default context includes trusted instructions, the active event, bounded recent history, memory shape, file manifest, current attachments, compact skill and capability catalogs, visible tool summaries, channel metadata, and trust boundaries.
Configure the default:
import { defaultContext, defineAgent } from "@openeve/core";
export default defineAgent({
model: "openai/gpt-5.4-mini",
context: defaultContext({
recentHistory: { maxMessages: 12 },
files: { includeManifest: true }
})
});
Extend it with custom policy:
// context.ts
import { defaultContext, defineContext } from "@openeve/core";
export const customContext = defineContext({
kind: "custom",
name: "customContext",
extends: defaultContext({
recentHistory: { maxMessages: 5 }
}),
options: {
includeProjectMarker: true
}
});
// agent.ts
import { defineAgent } from "@openeve/core";
import { customContext } from "./context";
export default defineAgent({
model: "openai/gpt-5.4-mini",
context: customContext
});
Context policy is trusted runtime code. It is recorded in the manifest with source attribution.
Runtime Filesystem
Every model prompt receives the stable OpenEve filesystem contract:
/memory durable memory, writable through memory tools by default
/history read-only conversation history
/files read-only input context and attachments
/workspace writable workspace for artifacts and modified copies
/skills durable skills when self-improvement is enabled
Sandboxes are acquired lazily. Normal channel receipt, model turns without sandbox-backed tools, skill activation, memory reads/writes, and final delivery do not need a sandbox.
Gateway Adapters
gateway.ts chooses portable runtime infrastructure:
import { adapter, defineGateway } from "@openeve/core";
import { dockerDeploy, dockerSandbox } from "@openeve/docker";
import { neonPostgres } from "@openeve/postgres";
import { r2Blob } from "@openeve/s3";
export default defineGateway({
deploy: dockerDeploy(),
runtime: adapter("node"),
state: neonPostgres(),
blob: r2Blob(),
sandbox: dockerSandbox({ image: "node:22-slim", network: "none" }),
scheduler: adapter("local")
});
Adapter choices are independent:
- Deploy target: local or Railway supported; Docker and Fly preview.
- Runtime: Node.
- Durable state: local dev/test files or Postgres for production.
- Blob storage: local dev/test files, R2, or generic S3-compatible storage.
- Sandbox: local dev/test, Docker, Daytona, E2B, and Modal supported.
- Scheduler: local for development and static schedule dispatch.
See Adapters for helper functions and environment variables.
State Stores
Durable state is defined by capability facets rather than one monolithic
interface. RuntimeOptions.state accepts either:
- a full
StateAdapter(the intersection of every facet — whatInMemoryStateAdapter,FileStateAdapter, andPostgresStateAdapterimplement), detected by duck-typing (createRun+appendEvent); or - a
StateStoresobject that brings only the facets you can persist.
import { OpenEveRuntime, type StateStores } from "@openeve/runtime";
const state: StateStores = {
runs: myRunStore // required: runs, events, tool calls, checkpoints,
// deliveries (incl. the durable queue), idempotency
// conversations?, schedules?, files?, usage?, sandboxSessions?, memory?
};
const runtime = new OpenEveRuntime({ manifest, state });
runs (a RunStore) is required — omitting it fails the constructor.
Optional facets are conversations (ConversationStore), schedules
(ScheduleStateStore), files (FileIndexStore), usage (UsageStore),
sandboxSessions (SandboxSessionStore), and memory (MemoryStateStore).
Any facet you omit falls back to a non-durable in-memory implementation and
the runtime logs a single state.degraded warning at boot listing the
missing facets. Capability guards (isRunStore, isConversationStore,
isScheduleStateStore, isFileIndexStore, isUsageStore,
isSandboxSessionStore, isMemoryStateStore, isStateAdapter) are
exported for hosts that feature-detect adapters.
Host Hardening (Embedders)
Hosts embedding the runtime or the Node server get concurrency limits, ingress rate limiting, and graceful shutdown as configuration:
import { installSignalHandlers, listenNodeRuntime, type RateLimiterStore } from "@openeve/node";
const handle = await listenNodeRuntime({
...runtimeOptions,
maxConcurrentRuns: 16, // RunCapacityError / HTTP 429 beyond this
maxQueuedRuns: 8, // optional wait queue before rejection
rateLimit: {
limits: {
"provider-channel": { capacity: 60, refillPerSecond: 10 },
"run-create": { capacity: 10, refillPerSecond: 1 }
}
// store?: RateLimiterStore — plug a shared (e.g. Redis-shaped) bucket
// store for multi-replica deployments; defaults to in-process memory.
// keyFor?(request) — custom bucket key; return undefined to exempt.
}
});
installSignalHandlers(handle); // SIGTERM/SIGINT -> handle.shutdown()
await handle.closed;
maxConcurrentRuns/maxQueuedRuns(orOPENEVE_MAX_CONCURRENT_RUNS/OPENEVE_MAX_QUEUED_RUNS) bound brand-new runs only. Capacity is claimed before idempotency keys are consumed, so rejected webhooks replay cleanly; resumes always bypass the limit. Direct embedders callingruntime.run()should catchRunCapacityError(it carriesretryAfterMs).rateLimitis off by default (falsedisables even the env config). TheRateLimiterStoreinterface is a single asynctake(key, { capacity, refillPerSecond, cost?, now? }), so shared stores are easy to implement.handle.shutdown({ timeoutMs? })is idempotent and drains in order:/readyz-> 503, listener + ingress, scheduler, workers,runtime.onIdle()(bounded byOPENEVE_SHUTDOWN_TIMEOUT_MS),TelemetrySink.flush?(), thenStateAdapter.close?(). Custom state adapters and telemetry sinks can implement those optional methods to participate.runtime.hasRunCapacity()andruntime.onIdle()are public for hosts that build their own servers.
Agent Engine
Pi (@openeve/pi) is the canonical primary engine; agents do not select one,
and agent.ts rejects a harness: slot at validate time. The runtime speaks
to pi only through the internal AgentHarness contract from @openeve/core,
which keeps continuations opaque JSON and all durability (tool execution,
approvals, checkpoints, events, usage) runtime-owned.
Embedders and tests can still replace the engine through RuntimeOptions:
agentHarness: a singleAgentHarnessthat overrides the engine for every run — the seam the durability test suite uses to drive scripted engines. A harness implements one method,runTurn(input, ctx), and returns a response plus an opaque, versioned continuation when the runtime pauses the run.models: an optional pi model catalog forwarded topiAgentHarness({ models }).
Subagents remain the extension point for delegate engines: declare
harness: adapter("codex" | "claude-code" | "livekit" | "pi") per subagent
folder. A subagent whose kind is pi runs through the same primary-loop
machinery, including follow-up messages resumed from persisted continuations.
Tool Discovery And Capability Metadata
OpenEve starts model runs with core harness tools and keeps long-tail capabilities behind deferred discovery. Authored tools can provide capability metadata when inference is not enough:
capability: {
visibility: "deferred",
execution: "direct",
namespace: "billing",
tags: ["invoice", "customer"],
aliases: ["receivables"]
}
Visibility values:
always- visible up front.deferred- discoverable throughtool_search.skill- relevant when a skill is active.hidden- runtime/internal use.
Execution values:
direct- trusted app runtime.sandbox- isolated filesystem or shell work.both- can use more than one path.
Approvals And Safe Outputs
Use needsApproval when a tool has durable or external side effects:
import { approvalRequired, defineTool } from "@openeve/core";
export default defineTool({
description: "Publish a status update.",
inputSchema: {
type: "object",
properties: { body: { type: "string" } },
required: ["body"]
},
needsApproval: approvalRequired("Publishing is externally visible.", "external"),
async execute(input: { body: string }, ctx) {
await ctx.emit("status.publish_requested", {
idempotencyKey: ctx.idempotencyKey("status")
});
return { published: true };
}
});
Use toModelOutput to keep the model-visible result smaller than the persisted tool result.
Self-Improvement
Self-improvement means one specific thing: the agent can author and edit its own skills as it learns repeatable procedures.
export default defineAgent({
id: "learning-agent",
model: "openai/gpt-5.4-mini",
selfImprovement: {
writable: true,
writeApproval: false
}
});
Compiled skills/ seed a durable skill store. Redeploys upgrade pristine seeded skills and preserve anything the agent or user changed. Set selfImprovement.writable to false for static, manifest-only skills.
Dynamic Schedules
Dynamic schedules are runtime-created routines. They are separate from static files in schedules/.
export default defineAgent({
model: "openai/gpt-5.4-mini",
dynamicSchedules: {
dynamic: true,
approval: false
}
});
A tool can use ctx.scheduleManager to create, list, update, or delete schedules. The runtime dispatcher leases due rows and starts one durable run per row. Scheduled work should be idempotent.
Dynamic Connections
Dynamic connections let an agent persist runtime-provided MCP, OpenAPI, or HTTP services. They are off by default and should be allowlisted.
export default defineAgent({
model: "openai/gpt-5.4-mini",
dynamicConnections: {
dynamic: true,
approval: true,
allowedHosts: ["mcp.example.com", "api.example.com"]
}
});
Credentials must come from host APIs, authorization flows, or encrypted grant stores. Never put secrets into skills, messages, tool inputs, agent folders, or sandbox files.
Channels
Use a provider helper when one exists:
import { defineDiscordChannel } from "@openeve/discord";
export default defineDiscordChannel();
Provider helpers verify incoming requests, normalize provider events into ChannelTurn, preserve provider delivery metadata, return fast acknowledgements when appropriate, and deliver replies through provider APIs.
Use defineChannel() when implementing a new provider boundary:
import { defineChannel } from "@openeve/core";
export default defineChannel({
transport: "http",
route: "/message",
methods: ["POST"]
});
Raw custom HTTP routes are local/dev-friendly, but production requests need a
host auth policy or bearer auth before the runtime will use the default message
fallback. Provider-facing channels should export normalizeHttp() and perform
provider signature, token, or tenant validation there.
Channels also declare what production ingress requires and how their attachments download:
- Set
ingress: { requiredSecretEnv: [["MY_WEBHOOK_SECRET"]] }on the channel config (any-of groups of env vars). The compiler stamps it into the manifest; production boot fails until at least one group is fully set, anddevModelogs a warning instead. - Export
resolveAttachment(attachment, ctx)to turn a turn attachment into a{ url, headers, filename? }download request with your provider's credentials and host allowlist. The runtime downloads, size-caps, and stores the bytes, and only ever consults the module of the channel that produced the turn, so credentials cannot leak across channels. Returnundefinedfor a generic unauthenticated URL download.
Provider-specific parsing belongs in channel modules. Durable state, blob storage, model selection, and sandbox lifecycle belong to the runtime and adapters.
Sandboxes
Local sandbox:
import { defineSandbox } from "@openeve/core";
export default defineSandbox({
adapter: "local",
image: "node:22",
workingDirectory: "/workspace"
});
Docker sandbox:
import { dockerSandbox } from "@openeve/docker";
export default dockerSandbox({
image: "node:22-slim",
network: "none"
});
Supported hosted sandbox helpers include Daytona, E2B, and Modal. Snapshot policy is opt-in:
export default defineSandbox({
adapter: "daytona",
image: "node:22",
snapshot: {
mode: "manual",
retainLast: 3,
reason: "operator-requested checkpoint"
}
});
Observability
OpenEve records runs, events, checkpoints, tool calls, delivery obligations, usage records, and timelines by default, and the Node runtime exposes /runs inspection endpoints for dashboards, tests, and operator tooling — all with no configuration.
To also export OpenTelemetry spans to an external backend, configure telemetry in agent/instrumentation.ts — the single home for telemetry. It is auto-discovered and run once at startup; its presence enables telemetry (there is no separate toggle). Return a sink from the setup callback. @openeve/otlp exports OpenTelemetry GenAI spans over OTLP/HTTP to Langfuse, Phoenix, Grafana, Honeycomb, and any other OTLP backend — the backend is chosen by endpoint, with no OpenTelemetry SDK dependency.
import { defineInstrumentation } from "@openeve/core";
import { createOtlpSinkFromEnv } from "@openeve/otlp";
export default defineInstrumentation({
serviceName: "learning-agent",
captureContent: "usage", // "usage" (default) | "content" | "full" | "off"
setup: ({ env }) => createOtlpSinkFromEnv(env)
});
Capture detail. captureContent decides how verbose logging is, per environment. usage (default) records token/cost/model/tool metadata but no message bodies; content/full add prompt/completion and tool input/output, truncated and key-redacted by the runtime before any sink sees them. Prompts can contain secrets/PII, so keep usage in production and reserve full for trusted debugging. See the capture-detail table.
Langfuse recipe. Point the endpoint at Langfuse's OTLP ingestion and pass a Basic auth header built from your Langfuse public/secret keys:
OTEL_EXPORTER_OTLP_ENDPOINT=https://cloud.langfuse.com/api/public/otel
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <base64(public_key:secret_key)>"
createOtlpSink({ endpoint, headers, ... }) is available too when you prefer explicit options over environment variables.
Structured logs
The runtime emits structured JSON log lines (one object per line with level, time, msg, and event-specific fields) for channel ingress lifecycle, HTTP requests, security warnings, and recoverable internal failures. Control verbosity with OPENEVE_LOG_LEVEL (debug, info, warn, or error; default info). Hosts embedding the runtime directly can replace the logger by passing logger (a RuntimeLogger) in RuntimeOptions — for example to route logs into an existing logging pipeline — or silence it with noopLogger().