Skip to content

Composition & custom runtimes

This page covers the three programmatic entry points that the mono-agent CLI itself is built on — startMonoAgentApp (full host with channels), createConfiguredAgentResponder (bare responder, no transports), and the lower-level @mono-agent/agent-harness — plus how to inject a custom runtime, add a channel driver, and scope runtime options per request. Reach for these only when mono-agent.config.json cannot express your host; the config covers nearly everything (see feature coverage). This whole surface is code-coverage: it is not reachable from config or the CLI.

Entry pointPackageYou getUse when
startMonoAgentApp@mono-agent/agent-appConfig load + responder + every configured channel + traceability + exportersYou want a CLI-equivalent host, optionally with extra channel drivers or a shared runtime
createConfiguredAgentResponder@mono-agent/agent-appA transport-free AgentResponder with config-driven runtime, harness, memory, per-run JSONL recording, and configured exportersYou embed the responder in your own server, test harness, or custom transport
createAgentHarness / createAgentResponder@mono-agent/agent-harnessFull manual control of identity, skills, memory, history, recorder, runtimeConfig-driven composition is not enough and you assemble every dependency yourself

The layering is strict: agent-app owns config-driven composition and delegates turn execution to agent-harness. Drop down only one level at a time. See package map and the programmatic index for the broader package set.

startMonoAgentApp is what the CLI’s mono-agent start runs. It loads mono-agent.config.json from cwd, builds the responder through app-owned configured composition, and starts traceability, observability exporters, every configured channel, and memory consolidation.

import { startMonoAgentApp } from "@mono-agent/agent-app";
const app = await startMonoAgentApp({ cwd: process.cwd() });
// ... later
await app.stop();

MonoAgentAppOptions fields:

OptionTypeDefaultNotes
cwdstringprocess.cwd()Folder the config and relative paths resolve against
configPathstring<cwd>/mono-agent.config.jsonPath to the config file
envRecord<string, string | undefined>process.envSource for MONO_AGENT_* overrides
driversreadonly ChannelDriver[]resolveChannelDrivers(...)Which channels to run (see below); defaults to core built-ins plus configured channels.plugins[] packages
runtimeMonoRuntimeLikebuilt from configInject a shared/custom runtime (see below)
loggerMonoAgentAppLoggerconsole-backedStructured host logging

The host runs headless and does not watch the config file. After an edit, either restart the host or call app.applyConfigChange(reason) explicitly to stop and rebuild its current services and driver set. Adding or removing a plugin package still requires a restart because external drivers are resolved at startup.

defaultChannelDrivers() returns the core built-in channel drivers (Telegram, Slack, webhook, OpenAI API, cron, and TUI) in startup/status order. The CLI-equivalent default uses resolveChannelDrivers(...), which appends external packages declared under channels.plugins[] such as WhatsApp or A2A. Spread defaultChannelDrivers() and append your own driver for a code-only host — or expose a package-level createChannelDriver() and load it from config.

import { startMonoAgentApp, defaultChannelDrivers } from "@mono-agent/agent-app";
import { myCustomDriver } from "./my-driver.js";
const app = await startMonoAgentApp({
cwd: process.cwd(),
drivers: [...defaultChannelDrivers(), myCustomDriver],
});

For building the driver itself, see Write your own channel adapter.

The bare responder: createConfiguredAgentResponder

Section titled “The bare responder: createConfiguredAgentResponder”

When you do not want any built-in transport — you are embedding the agent in your own HTTP server, queue worker, or test — combine @mono-agent/config with @mono-agent/agent-app. createConfiguredAgentResponder turns a loaded MonoAgentConfig into a ready AgentResponder. It starts no channel, trace-registry, service, retention, or consolidation-scheduler lifecycle, but each turn still uses the configured JSONL recorder and per-run exporters. It is async (as is createConfiguredAgentHarness/createConfiguredMemory): memory backends are imported lazily, so a config without a memory section never loads the SQLite/BuJo stack and a Supermemory config never loads it either.

import { loadMonoAgentConfigWithSources } from "@mono-agent/config";
import { createConfiguredAgentResponder } from "@mono-agent/agent-app";
const config = await loadMonoAgentConfigWithSources({
env: process.env,
cwd: process.cwd(),
jsonPath: "./mono-agent.config.json",
});
const responder = await createConfiguredAgentResponder({
config,
cwd: process.cwd(),
});

For memory.backend: "supermemory", install the exact matching @mono-agent/memory-supermemory plugin first. The app imports it only when that backend is selected and reports the exact matching-version install command when it is absent; other configurations keep it outside the app dependency closure.

ConfiguredAgentResponderOptions (a superset of ConfiguredAgentHarnessOptions) lets you override the dependencies the config would otherwise build:

OptionTypePurpose
configMonoAgentConfigRequired. The loaded config
cwdstringAgent-root authority and folder used to resolve agent-local optional plugins (configured harness/responders default to process.cwd())
runtimeMonoRuntimeLikeInject a custom or shared runtime instead of building one from runtime.model
modelRuntimeModelReferenceOverride the config’s primary model
memoryMemoryStoreSupply a memory store instead of provisioning from config.memory
historyStoreConversationHistoryStoreReplace the configured app’s owner-only disk-backed 64-message history store with a custom implementation
turnHistoryEnricherAgentHarnessTurnHistoryEnricherApp-owned hook for adding run-scoped interaction evidence only to replay history; outward responses and memory capture keep the original assistant text
runtimeOptionsstatic run optionsExtra runtime options merged for every run (no model/messages/abortSignal/onEvent)
runtimeOptionsForRequest(input) => extensionPer-request run options (see below)

createConfiguredAgentHarness(options) is also exported if you want the harness without the responder wrapper. For a raw runtime, use createConfiguredAgentRuntime({ config, cwd: agentRoot }). Its owner and registry are acquired lazily on the first run() and retained until disposeAllSessions(). The legacy config-only construction cannot prove which agent root it owns, so its first run fails closed instead of invoking a provider. Direct createConfiguredMemory callers whose selected tier uses an LLM or embedding provider should likewise pass { cwd: agentRoot }.

A custom historyStore keeps provider sessions process-local unless it implements the crash-safe beginProviderSessionTurn transaction and advertises providerSessionRetirement: "fail-closed". That marker is a promise that epoch rotation, dirty-fence recovery, and retention can durably retire every exact provider id before canonical history makes it unreachable. The harness withholds piSessionsRoot when either half is missing.

Injecting a custom runtime (MonoRuntimeLike)

Section titled “Injecting a custom runtime (MonoRuntimeLike)”

Both startMonoAgentApp and the configured responder factories accept a runtime?: MonoRuntimeLike from @mono-agent/runtime-adapter. Pass one to share a single runtime across hosts or stub the provider in tests. When omitted, the runtime is built from config.runtime.model plus canonical runtime.fallbacks.

import { startMonoAgentApp } from "@mono-agent/agent-app";
import type { MonoRuntimeLike } from "@mono-agent/runtime-adapter";
const myRuntime: MonoRuntimeLike = createMyRuntime();
const app = await startMonoAgentApp({ cwd: process.cwd(), runtime: myRuntime });

Notes:

  • The configured fallback chain is applied by the built-in runtime’s router. An injected runtime bypasses that wiring, so your runtime owns retry and failover behavior.
  • The BuJo memory LLM is separate from the channel runtime. createConfiguredMemory(config, { memoryRuntime }) is the seam for tests or custom memory LLM execution; otherwise memory builds its own fallback-free runtime from memory.llm.
  • Model overrides use a cached runtimeForModel(model) owner when supplied; without it, the injected runtime receives the effective per-run model directly. Provide the factory when separate runtimes are needed, such as routers with a fixed primary. Session cleanup resolves the same owner, including after a restart.
  • createSessionRuntimeResolver from @mono-agent/agent-harness caches runtime identity by canonical model key. Durable-history retirement callbacks receive (providerSessionId, modelKey?); absent keys identify legacy unbound records. The configured app wires retirement to the owning runtime.
  • A custom history coordinator opts into durable alternate-model sessions with providerSessionModelBinding: "v1". Its beginProviderSessionTurn(conversationId, runId, { modelKey }) must persist and acknowledge the binding, rotate on changes, and return previousModelKey for a known change. Legacy coordinators retain their default-model path; alternate-model sessions remain process-local until the coordinator supports binding.

The built-in memory tiers are config-driven through memory.mode: "lite" | "journal" | "bujo" for local storage. The optional Supermemory plugin retains its config-first route at memory.backend: "supermemory" after its matching package is installed. Anything else is a code capability: implement the structural MemoryStore contract from @mono-agent/agent-contracts and inject it into the configured composition layer.

import type { MemoryStore } from "@mono-agent/agent-contracts";
import { createConfiguredAgentResponder } from "@mono-agent/agent-app";
const memory: MemoryStore = createMyMemoryStore();
const responder = await createConfiguredAgentResponder({
config,
memory,
});

The injected store wins over anything config.memory would otherwise build, and config.memory can be omitted entirely. Recall happens before each turn and capture happens after the reply according to memory.writeMode; slow or failing memory degrades the memory path rather than faking a successful model turn.

Per-request runtime options (runtimeOptionsForRequest)

Section titled “Per-request runtime options (runtimeOptionsForRequest)”

runtimeOptionsForRequest is a callback invoked once per turn to compute run options scoped to that request. A configured memory backend attaches the per-turn MemoryRecall endpoint at the shared configured-harness boundary and composes it with your callback; supplying custom tools does not replace the default recall tool. The full app uses the same composition path for adapter send tools and request overrides.

import { createConfiguredAgentResponder } from "@mono-agent/agent-app";
import type {
AgentHarnessRuntimeOptionsInput,
AgentHarnessRuntimeOptionsExtension,
} from "@mono-agent/agent-harness";
const responder = await createConfiguredAgentResponder({
config,
runtimeOptionsForRequest: async (
input: AgentHarnessRuntimeOptionsInput,
): Promise<AgentHarnessRuntimeOptionsExtension> => {
// input: { request, runId, context }
return {
runtimeOptions: { /* per-run options, e.g. extra MCP servers */ },
cleanup: async () => { /* release per-request resources */ },
};
},
});

The callback receives { request, runId, context } (the inbound request, the run id, and the already-built BuiltAgentContext). It returns a partial runtimeOptions object plus an optional cleanup hook. messages, abortSignal, onEvent, provider-session ids, keep-alive fields, and piSessionsRoot remain harness-owned. model and effort are accepted for the configured cron/webhook/TUI override path. A request extension may set piTransport only when the host left it unset; an explicit host/config value remains authoritative.

createConfiguredAgentResponder will not cover hosts that need a custom recorder, a non-config identity/skill loading scheme, or hand-assembled memory and history. In those cases call @mono-agent/agent-harness directly. The harness owns loading identity/SOUL and selected skill bodies, reading memory blocks, invoking the runtime, recording run events, appending conversation history, and returning explicit failure objects instead of fake success.

Selected skills are never auto-selected by description — the host passes selectedSkills (or config.context.selectedSkills) and the harness loads exactly those bodies. For tool/MCP policy, build a policy with @mono-agent/agent-harness (createToolPolicy) and pass exactly the surface you want — ["*"] for all, a specific list to narrow, or [] for none; failClosedToolPolicy() is the no-policy safety net (the config loader’s allow-all default lives in @mono-agent/config, not here). See tool policy and MCP.

For multi-agent orchestration on top of these primitives, see multi-agent; for consuming a remote agent over A2A, see A2A consumer.