Skip to main content

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, or xhigh. Pi runs default to medium.
  • name and description - 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, not agent.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.