@elizaos/core is the runtime and contract layer of elizaOS. It defines the AgentRuntime and the plugin abstractions (actions, providers, evaluators, services, models, routes, events), the canonical type system, and the supporting subsystems (memory, search, settings, scheduling, prompts). It is consumed by @elizaos/agent (which also hosts the HTTP API server), @elizaos/app-core (the API + dashboard host), and every @elizaos/* plugin.
- AgentRuntime: Central orchestrator for the agent lifecycle, plugin loading, and the message loop.
- Actions: Tasks the agent can perform, each with a
validateandhandlerfunction. - Providers: Supply data and context to the runtime and its components.
- Evaluators: Process conversation data to extract facts, build memory, and reflect.
- Plugin system:
Pluginobjects contribute actions/providers/evaluators/services to the runtime. - Built-in bundle: Foundational capabilities ship as
basicCapabilities(andbasicActions/basicProviders/basicEvaluators/basicServices); there is nocorePluginsingleton.
-
Add
@elizaos/coreto youragent/package.jsondependencies:{ "dependencies": { "@elizaos/core": "workspace:*" } } -
Navigate to your
agent/directory. -
Install dependencies:
bun install
-
Build your project:
bun run build
@elizaos/core builds to three targets via conditional exports:
- Node.js Build: Full API surface with all features including server utilities (
index.node.ts) - Browser Build: Browser-safe subset, no fs/process-bound modules (
index.browser.ts) - Edge Build: Edge-runtime subset (
index.edge.ts)
The correct build is automatically selected based on your environment through package.json conditional exports. For browser usage, ensure your app provides the standard platform primitives it depends on, such as Buffer where needed.
The following environment variables are used by @elizaos/core. Configure them in a .env file at your project root.
LOG_LEVEL: Logging verbosity (e.g., 'debug', 'info', 'error').LOG_JSON_FORMAT: Output logs in JSON format (true/false).SECRET_SALT: Encryption salt, read bygetSalt()insrc/settings.ts. In production it must be set to a non-default value unlessELIZA_ALLOW_DEFAULT_SECRET_SALT=true.ALLOW_NO_DATABASE: Allow running without a persistent database adapter. Whentrue,AgentRuntime.initialize()will fall back to an in-memory adapter (useful for benchmarks/tests).LOG_FILE: When set totrue/1or a path, enables file logging:output.log,prompts.log, andchat.log(in cwd or at the given path). Why: Lets you inspect full prompts and chat flow without scraping console; ANSI is stripped so files stay grep-friendly.BASIC_CAPABILITIES_KEEP_RESP: Whentrue, the message service does not discard a response when a newer message is being processed (avoids "stale reply" race). Why: Some deployments want to keep or display every response; this is the config equivalent of passingkeepExistingResponses: truein options.SHOULD_RESPOND_MODEL: Which model size to use for the "should I respond?" decision (smallorlarge, read insrc/services/message.ts). Defaults from runtime settings if not set in options.
Example .env:
LOG_LEVEL=debug
LOG_JSON_FORMAT=false
SECRET_SALT=yourSecretSaltHere
ALLOW_NO_DATABASE=true
LOG_FILE=true
Note: Add your .env file to .gitignore to protect sensitive information.
Per-change notes with the WHY for each addition or fix live in CHANGELOG.md. The sections below document the reasoning behind the major subsystems so future changes stay consistent with intent.
Benchmarks and harnesses can attach metadata to inbound messages:
message.metadata.trajectoryStepId: when present, provider access + model calls are captured for that step.message.metadata.benchmarkContext: when present, theCONTEXT_BENCHprovider setsstate.values.benchmark_has_context=true, and the message loop forces action-based execution (so the full Provider → Model → Action → Evaluator loop is exercised).
The canonical message loop expects model outputs in the <response>...</response> XML format (with <actions>, <providers>, and <text> fields).
Some deterministic/offline backends may return plain text instead. In that case, the runtime will treat the raw output as a simple REPLY so the system remains usable even when strict XML formatting is unavailable.
The core can pass prompt segments to model providers so they can use prompt-caching APIs when supported. Each segment has content (string) and stable (boolean). Stable means the content is the same across calls for the same schema/character (e.g. instructions, format, examples); unstable means it changes every call (e.g. state, validation codes).
Why this exists: Repeated calls (e.g. message handling, batched evaluators) often send the same instructions and format while only the context/state changes. Provider caching (Anthropic ephemeral cache, OpenAI/Gemini prefix cache) can reuse tokens for the stable prefix, reducing cost and latency. The core describes which parts are stable so providers can opt in without parsing the prompt.
- Invariant: When
promptSegmentsis set on generation params,promptMUST equalpromptSegments.map(s => s.content).join(""). Why: Providers that ignore segments still get correct behavior by usingprompt; those that use segments must send the same total text so model behavior is unchanged. - Providers: Anthropic uses the Messages API with
cache_control: { type: "ephemeral" }on stable blocks so the API can cache those blocks. OpenAI and Gemini use prefix ordering: when segments are present, the prompt sent to the API is built with stable segments first, then unstable. Why: OpenAI and Gemini cache by prefix (e.g. OpenAI ≥1024 tokens); putting stable content first maximizes cache hits.
Pitfalls for operators:
- OpenAI caching only applies when the prompt is ≥1024 tokens; very short prompts will not show cache savings.
- Small or low-parameter models may not support or benefit from caching; behavior is unchanged.
- Caching is a performance/cost optimization; correctness does not depend on it.
Pitfalls for implementers:
- Do not mutate segment objects; always create new
{ content, stable }objects. Why: Params may be passed to multiple handlers or stored; mutation can cause cross-request bugs. - Segment order must match the order in which the prompt string is built; add an assertion that
prompt === promptSegments.map(s => s.content).join(""). Why: Wrong order breaks the invariant and can send the wrong prompt to the model. - When using segments in the API (e.g. messages or reordered prompt), ensure the final text seen by the model equals the intended full prompt (e.g.
params.promptor the stable-first concatenation). - Only mark content as
stable: trueif it is identical across calls for the same schema/character. Why: Content that includes per-call UUIDs or changing state will never cache; mislabeling it as stable wastes cache capacity and can confuse operators.
@elizaos/core is built around a few key concepts that work together within the AgentRuntime.
@elizaos/core now includes a unified prompt batching subsystem on runtime.promptBatcher.
Why this exists:
- Evaluators, startup warmups, and autonomous reasoning were all paying separate LLM round trips for structurally similar work.
- Batching reduces cost, queue depth, and local GPU contention by turning many small prompt calls into fewer structured calls.
- The dispatcher keeps deployment flexibility: local inference can pack aggressively while frontier APIs can trade some density for latency.
What it does:
askOnce()batches startup questions into a single post-init drain when possible. Returns a promise of the extracted fields (unwrapped). Why: callers get a thenable so they canawaitor.then()without a callback.onDrain(id, opts)registers a section that runs on the next drain for that affinity and returns a promise that resolves with{ fields, meta }(ornullif the section ID was already registered). Why: evaluators can use linearawait+if (result) { ... }instead of a largeonResultcallback; same batching benefits. You can still pass optionalonResultfor fire-and-forget or recurring use (e.g. logging).think()is used by autonomy: whenenableAutonomyis true, the autonomy service registers one recurring section; a BATCHER_DRAIN task in the task system drives when that affinity drains (task system owns WHEN, batcher owns HOW). Why: one register for "what to ask" and the same orchestration path as evaluators and startup, with the same cache and packing benefits. Autonomy keeps usingonResultbecause it is fire-and-forget per drain.askNow()supports blocking audits without creating a second subsystem. Returns a promise of the fields (unwrapped). Why: same thenable style as askOnce; fallback is required so the caller always gets an object.
Result shape and errors:
- Section promises (from
addSection/onDrain) resolve withBatcherResult<T> | null:{ fields: T, meta: DrainMeta }. Why: callers get both the extracted data and drain metadata (e.g.meta.fallbackUsed,meta.durationMs) in one object;nullmeans duplicate section ID so the caller can branch. - When onResult throws or the batcher is disposed, the section promise rejects instead of resolving. Why: callers can
.catch()or try/catch for real failures; fallback-used still resolves (withmeta.fallbackUsed: true) so "soft" failure is not an exception. - Generic
onDrain<T>(...): pass a type param soresult.fieldsis typed (e.g.onDrain<ReflectionFields>(...)). Why: avoids casting at call sites; the runtime still returnsRecord<string, unknown>from the model—the generic is for developer convenience.
Important behavior:
- Sections are idempotent by ID, so developers can register them from handlers without tracking lifecycle manually.
- The promise returned by
onDrain(oraddSection) resolves once—on the first delivery for that registration. Why: per-drain sections run on every drain, but the thenable is for "give me the result of this registration"; subsequent drains do not resolve the same promise again. For recurring delivery (e.g. every drain), use the optionalonResultcallback. - Context is declarative and composable:
providers,contextBuilder, andcontextResolverscan be mixed. - Dispatching is affinity-aware, so unrelated prompt sections are not merged into the same call just because they arrived at the same time.
Relevant runtime knobs (all PROMPT_BATCHER_*, read in src/runtime.ts):
PROMPT_BATCHER_BATCH_SIZEPROMPT_BATCHER_MAX_DRAIN_INTERVAL_MSPROMPT_BATCHER_MAX_SECTIONS_PER_CALLPROMPT_BATCHER_PACKING_DENSITYPROMPT_BATCHER_MAX_TOKENS_PER_CALLPROMPT_BATCHER_MAX_PARALLEL_CALLSPROMPT_BATCHER_MODEL_SEPARATION
The prompt batcher implementation lives in src/utils/prompt-batcher/ (batcher.ts, dispatcher.ts). The lower-level queue primitives (PriorityQueue / BatchProcessor / TaskDrain / BatchQueue) live in src/utils/batch-queue/.
The task system is the single place for when scheduled work runs. Only tasks with tag queue are polled by the scheduler (TaskService); other tasks (e.g. approval, follow-up) are stored and executed only when explicitly triggered (e.g. choice action, or executeTaskById).
Why one scheduler:
- Recurring work (e.g. batcher drains, future cron-like use) uses the same DB, same pause/resume, same visibility (
getTaskStatus,nextRunAt,lastError). Retry and backoff (exponential backoff, auto-pause aftermaxFailures) live in one place so we avoid infinite retry storms.
Why queue + repeat:
- Tasks with
tags: ["queue"]are fetched every tick. Non-repeat tasks run whennow >= dueAt(ormetadata.scheduledAt) then are deleted; repeat tasks useupdateInterval/baseIntervalandmetadata.updatedAtas last-run time. Why: One-shot "run at time X" (e.g. follow-up) usesdueAt; interval-based scheduling covers batcher drains and recurring use.
Why utils/batch-queue’s TaskDrain: several services create the same style of repeat drain task (queue + repeat, maxFailures: -1, interval metadata). Centralizing find/create/update/delete avoids each caller re-implementing JSON/metadata edge cases and keeps worker registration rules explicit (skipRegisterWorker when TaskService already owns the worker name). Implementation in src/utils/batch-queue/.
Cross-runtime scheduling (three modes):
- Local timer (default): One
setIntervalper TaskService; each runtime fetches its own queue tasks every tick. Why: Zero config for single-process apps. - Per-daemon: Host calls
startTaskScheduler(adapter); one shared timer runs, one batchedgetTasks(agentIds)per tick for all registered runtimes, then tasks are dispatched to each runtime’srunTick(tasks). Why: Multi-agent daemons avoid N DB queries per second. - Serverless: Construct runtime with
{ serverless: true }; no timer. Host callstaskService.runDueTasks()from cron or on each request to run due queue tasks once. Why: No long-lived process; host controls when tasks run.
Public API (TaskService): executeTaskById, pauseTask, resumeTask, getTaskStatus, markDirty, runDueTasks() (serverless). Why: Operators and UIs can run, pause, resume, and inspect tasks without touching the DB directly.
The implementation lives in src/services/task.ts and src/services/task-scheduler.ts.
The autonomy service lets the agent "think" and act on a schedule without user messages. It uses the prompt batcher with the task system for scheduling: when enableAutonomy is true, a recurring section is registered with think("autonomy", ...). A BATCHER_DRAIN task for the autonomy affinity determines when the section drains; results are delivered to onResult, which runs the same post-LLM steps as the message pipeline (actions, memory, evaluators) via an execution facade.
Why batcher-only:
- The batcher owns "what to ask"; the task system owns "when" (per-affinity BATCHER_DRAIN tasks). One scheduling surface and one packing path. Evaluators used after autonomy runs are the same as for user messages; as more evaluators move to the batcher, autonomy benefits automatically.
The AgentRuntime (src/runtime.ts, class AgentRuntime implements IAgentRuntime) is the heart of the system. It manages the agent's lifecycle, loads plugins, orchestrates the message loop, and is the central point for actions, providers, and evaluators. It is initialized with a set of Plugins; foundational actions, providers, evaluators, and services ship as the basicCapabilities bundle (src/features/basic-capabilities/index.ts).
Actions define specific tasks or capabilities the agent can perform. Each action typically includes:
- A unique
name. - A
descriptionexplaining its purpose and when it should be triggered. - A
validatefunction to determine if the action is applicable in a given context. - A
handlerfunction that executes the action's logic.
Actions enable the agent to respond intelligently and perform operations based on user input or internal triggers.
Private actions. Set private: true on an action to reserve it for the agent's own autonomous loop. A private action is never exposed to the planner — and is rejected by the executor as a defense-in-depth backstop — on user-driven turns; it can only be selected and run when the triggering message is an autonomous self-prompt (content.metadata.isAutonomous === true, the marker the autonomy service stamps). Use this for self-initiated capabilities the agent should decide to invoke on its own — e.g. minting a coin or opening a position — rather than ones a user can trigger on demand. The gate lives in src/runtime/private-action-gate.ts.
Providers are responsible for supplying data and context to the AgentRuntime and its components. They can:
- Fetch data from external APIs or databases.
- Provide real-time information about the environment.
- Offer access to external services or tools.
This allows the agent to operate with up-to-date and relevant information.
Evaluators analyze conversation data and other inputs to extract meaningful information, build the agent's memory, and maintain contextual awareness. They help the agent:
- Understand user intent.
- Extract facts and relationships.
- Reflect on past interactions to improve future responses.
- Update the agent's knowledge base.
The runtime talks to persistence through the IDatabaseAdapter interface. Adapters (e.g. plugin-sql, plugin-localdb, InMemory) implement this contract so the same runtime code works with different backends.
Why mutation methods return Promise<boolean>: Methods such as updateAgents, deleteAgents, and deleteParticipants return a boolean so callers can tell success from failure. That supports error handling, retries, and UX (e.g. "Agent removed" vs "Failed to remove"). All adapters use this convention for consistency. See packages/core/src/types/database.ts for full JSDoc and design notes.
import { AgentRuntime } from "@elizaos/core";
const runtime = new AgentRuntime({
character, // a Character (type in src/types/agent.ts; helpers in src/character.ts)
plugins: [
// your plugins; each contributes actions/providers/evaluators/services
],
// other AgentRuntime options
});
await runtime.initialize();Foundational actions, providers, evaluators, and services are available as the basicCapabilities bundle and its parts (basicActions, basicProviders, basicEvaluators, basicServices) exported from @elizaos/core. There is no corePlugin singleton — compose the bundles you need or rely on a higher-level package (e.g. @elizaos/agent) to wire them.
A custom action implements the Action type (src/types/):
import type { Action } from "@elizaos/core";
export const customGreet: Action = {
name: "CUSTOM_GREET",
description: "Greets a user in a special way.",
validate: async (runtime, message) => message.content.text?.includes("special hello") ?? false,
handler: async (runtime, message, state, options, callback) => {
await callback?.({ text: "A very special hello to you!" });
return { success: true };
},
examples: [],
};Register it via a Plugin ({ name, actions: [customGreet] }) passed to the runtime. Providers and evaluators follow the same pattern against the Provider / Evaluator types.
The package uses vitest. From the repo root:
bun run --cwd packages/core test # vitest run
bun run --cwd packages/core test:watch # watch mode
bun run --cwd packages/core test:coverage # with v8 coverage
bun run --cwd packages/core typecheck # tsgo --noEmitFor agent-facing notes on layout, the public surface, and how to extend the runtime, see CLAUDE.md / AGENTS.md.