Building Agents
An OpenEve agent is a folder. The folder is the source of truth for the agent's instructions, capabilities, channels, schedules, connections, sandbox, and runtime gateway.
Only instructions.md and agent.ts are required. Everything else is optional.
agent/
instructions.md
agent.ts
context.ts
gateway.ts
skills/
tools/
channels/
schedules/
triggers/
connections/
sandbox/
subagents/
instrumentation.ts
Minimal Agent
instructions.md is trusted, always-on guidance:
You are a concise OpenEve agent. Use tools when they are available.
agent.ts defines the agent:
import { defineAgent } from "@openeve/core";
export default defineAgent({
model: "openai/gpt-5.4-mini",
reasoning: "medium",
name: "starter-agent",
description: "A small OpenEve agent."
});
gateway.ts is not required for the smallest compile path, but it is recommended because it makes runtime choices explicit:
import { adapter, defineGateway } from "@openeve/core";
export default defineGateway({
deploy: adapter("local"),
runtime: adapter("node"),
state: adapter("local"),
blob: adapter("local"),
sandbox: adapter("local"),
scheduler: adapter("local")
});
Agent Definition
defineAgent() supports:
id- stable logical identity for durable learned state across revisions.model- model spec, usually<provider>/<model>.reasoning- optional reasoning effort:off,minimal,low,medium,high, orxhigh. Pi runs default tomedium.nameanddescription- human and manifest metadata.context- optional custom or configured context policy.defaultTools- model-facing tool names enabled by default.selfImprovement- skill writing controls.dynamicSchedules- runtime schedule creation controls.dynamicConnections- runtime connection adoption controls.metadata- structured app-specific metadata.
Use id for production agents that can learn skills or store durable state across deploy revisions:
export default defineAgent({
id: "travel-concierge",
model: "openai/gpt-5.4-mini",
reasoning: "high",
selfImprovement: { writable: true }
});
Tools
Each file in tools/ becomes one model-facing tool. The filename is the tool name.
// tools/echo.ts
import { defineTool } from "@openeve/core";
export default defineTool({
description: "Echo a message back to the caller.",
inputSchema: {
type: "object",
properties: {
message: { type: "string" }
},
required: ["message"]
},
async execute(input: { message: string }) {
return { message: input.message };
}
});
Use approval gates for durable side effects:
// tools/record_note.ts
import { approvalRequired, defineTool } from "@openeve/core";
export default defineTool({
description: "Record a durable note after explicit approval.",
inputSchema: {
type: "object",
properties: {
note: { type: "string" }
},
required: ["note"]
},
needsApproval: approvalRequired("Recording a note is a durable side effect.", "idempotent"),
async execute(input: { note: string }, ctx) {
await ctx.emit("note.recorded", {
note: input.note,
idempotencyKey: ctx.idempotencyKey("record-note")
});
return { recorded: true };
}
});
Use toModelOutput when the runtime should persist rich results but show the model only a bounded projection:
export default defineTool({
description: "Look up a record and return a safe summary.",
inputSchema: {
type: "object",
properties: { lookup: { type: "string" } },
required: ["lookup"]
},
async execute(input: { lookup: string }) {
return {
summary: `Found ${input.lookup}`,
internalScore: 0.98,
internalTrace: ["vector", "rerank", "policy"]
};
},
toModelOutput(output: { summary: string }) {
return { summary: output.summary };
}
});
Tool code runs in the trusted app runtime by default. Use ctx.getSandbox() only when the tool needs isolated filesystem or shell work.
Skills
Skills live at skills/<name>/SKILL.md. They are instructions that can be loaded on demand.
---
description: Capture durable notes for the user
allowed-tools: [record_note]
---
# Note Taking
Use this skill when the user shares a fact, preference, or decision that should outlive the conversation.
Keep skills small and procedure-oriented. Put durable product data in memory or state, not in a skill body.
Channels
Channels normalize external events into OpenEve turns and deliver replies back to the provider.
// channels/http.ts
import { defineChannel } from "@openeve/core";
export default defineChannel({
description: "Receive a local HTTP message.",
transport: "http",
route: "/message",
methods: ["POST"]
});
This raw shape is intended for local development or authenticated host
integrations. In production, generic HTTP channels require host auth, and
provider-facing routes should export normalizeHttp() to verify and normalize
the provider event before starting a turn.
Provider helpers keep common webhook wiring to one file:
// channels/slack.ts
import { defineSlackChannel } from "@openeve/slack";
export default defineSlackChannel();
openeve add <channel> (e.g. pnpm openeve add slack agent) installs the channel package and scaffolds this file for you, then prints the required env vars; existing channel files are left untouched.
OpenEve currently ships agent communication channel helpers for Slack, Discord,
Telegram, Microsoft Teams, and Photon/Spectrum, plus a preview GitHub repo-event
channel (defineGitHubChannel from @openeve/github) that turns signed webhook
deliveries — issues, issue comments, pull requests, reviews, pushes — into agent
turns and replies with issue/PR comments. GitHub App capabilities also remain
available under connections via defineGitHubConnection.
Schedules
Static schedules live in schedules/ and compile into scheduler metadata.
// schedules/morning_brief.ts
import { defineSchedule } from "@openeve/core";
export default defineSchedule({
description: "Run a small daily brief.",
cron: "0 8 * * *",
timezone: "America/Chicago",
idempotencyKey: "starter-agent:morning-brief",
message: "Prepare the morning brief."
});
Schedules can also reference deterministic trigger handlers in triggers/.
Handlers run trusted app code before and after the model turn, so routines can
prepare memory/resources and finalize app state without exposing orchestration
tools to the model.
// triggers/llm_wiki_dream.ts
import { defineTriggerHandler } from "@openeve/core";
export default defineTriggerHandler({
description: "Prepare and finalize nightly LLM Wiki maintenance.",
async prepare(ctx) {
const run = await ctx.state.upsertRoutineRun({
kind: "llm_wiki_dream",
triggerKey: ctx.trigger.idempotencyKey,
status: "running"
});
const bundle = await ctx.resources.collect({
sources: ["memory", "history", "connections"],
limit: 100
});
await ctx.memory.write({
path: `/memory/wiki/_runs/${run.id}/sources.md`,
content: bundle.markdown,
mode: "replace",
metadata: { tags: ["agent/wiki", "source-bundle"] }
});
return {
target: { type: "skill", name: "personal-wiki-update" },
promptContext: {
runId: run.id,
triggerKind: ctx.trigger.kind,
triggerKey: ctx.trigger.idempotencyKey,
sourceBundlePath: `/memory/wiki/_runs/${run.id}/sources.md`
}
};
},
async finalize(ctx, result) {
await ctx.state.completeRoutineRun({
runId: ctx.prepare.runId,
status: result.ok ? "completed" : "failed",
summary: result.summary
});
}
});
// schedules/wiki_dream.ts
import { defineSchedule } from "@openeve/core";
export default defineSchedule({
cron: "15 8 * * *",
timezone: "UTC",
idempotencyKey: "system-routine:wiki-dream",
enabled: false,
trigger: {
handler: "llm_wiki_dream",
target: { type: "skill", name: "personal-wiki-update" }
}
});
Runtime-created schedules are separate from the static folder and are controlled by dynamicSchedules.
They can dispatch plain scheduled messages or reference the same static trigger
handlers by name through the schedule manager:
await ctx.scheduleManager?.createSchedule({
message: "Run nightly wiki maintenance.",
cron: "15 8 * * *",
timezone: "UTC",
trigger: {
handler: "llm_wiki_dream",
target: { type: "skill", name: "personal-wiki-update" },
metadata: { owner: "user-created" }
}
});
Dynamic trigger schedules cannot provide new handler code at runtime; the
handler must already exist in triggers/ and compile into the manifest. When a
dynamic schedule is user- or conversation-scoped, prepare(ctx) and the model
turn use that same durable scope for memory and resources.
Connections
Connections declare external capabilities and credential requirements. Raw secrets and refresh tokens stay outside the agent folder, model context, and sandbox filesystem.
// connections/github.ts
import { adapter, defineConnection } from "@openeve/core";
export default defineConnection({
description: "GitHub capability contract.",
provider: "github",
binding: adapter("env"),
scopes: ["repo:read"],
capabilities: ["issues:read", "pull_requests:read"],
subject: "user",
required: false
});
Use defineMcpClientConnection(), defineOpenAPIConnection(), or defineHttpApiConnection() when a connection should expose remote tools through the deferred discovery path.
Sandbox
Sandbox files choose the agent computer for isolated file and shell work.
// sandbox/default.ts
import { defineSandbox } from "@openeve/core";
export default defineSandbox({
adapter: "local",
image: "node:22",
workingDirectory: "/workspace",
env: ["OPENAI_API_KEY"]
});
The local sandbox is for trusted development and tests. Use Docker or hosted sandboxes for stronger isolation.
Subagents
Subagents live under subagents/<name>/ and have their own instructions.md and agent.ts.
// subagents/researcher/agent.ts
import { defineAgent } from "@openeve/core";
export default defineAgent({
model: "openai/gpt-5.4-mini",
reasoning: "low",
description: "Use this subagent for scoped research tasks.",
defaultTools: []
});
For external coding harnesses, use defineSubagent():
import { adapter, defineSubagent } from "@openeve/core";
export default defineSubagent({
description: "Use this for durable coding sessions.",
model: "openai/gpt-5-codex",
harness: adapter("codex"),
workspace: adapter("local"),
connections: ["github"],
defaultTools: []
});
External subagents can also represent realtime services. LiveKit voice subagents dispatch a named LiveKit Agents worker and optionally dial a SIP participant into the room:
// 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 parent can delegate a task such as { "phoneNumber": "+15551234567" }.
The realtime voice worker still runs in LiveKit; OpenEve records the dispatch
and call-start receipt as the child run result.
Instrumentation
instrumentation.ts is the single home for telemetry — configured before the
first turn. To export spans to an OTLP backend (Langfuse, Phoenix, Grafana, ...),
return a sink from setup; see Customizing Agents → Observability.
import { defineInstrumentation } from "@openeve/core";
import { createOtlpSinkFromEnv } from "@openeve/otlp";
export default defineInstrumentation({
serviceName: "starter-agent",
// Capture detail: "usage" (default, no message bodies) | "content" | "full" | "off".
captureContent: "usage",
setup: ({ env }) => createOtlpSinkFromEnv(env)
});
Authoring Rules
- Keep each file focused on one capability or contract.
- Put provider routing in channels, not tools.
- Put deployment and shared adapter choices in
gateway.ts, notagent.ts. - Put reusable procedures in skills, not long tool descriptions.
- Put secrets in environment variables or credential stores, never in model-visible files.
- Treat
/files,/history, memory, search results, and tool output as untrusted context. - Write generated artifacts and modified copies under
/workspace.