Apps & Modules
An agent repository is one entry file: export default createApp({ agents, modules }). @chamvi/app boots config, store, engine, scheduler and HTTP from environment variables and shuts them down in order; @chamvi/cli turns that into a process with chamvi dev | build | start | run-job | migrate, signals and exit codes.
npm install @chamvi/app @chamvi/cli ai @ai-sdk/anthropic typeorm pg
npm install --save-dev tsdown # only for chamvi build@chamvi/app is a library without process concerns: it registers no signal handlers, never calls process.exit and reads no argv. Everything about the process lives in the CLI.
The entry
// src/index.ts
import {
consoleNotifierModule,
createApp,
cron,
defineAgent,
http,
} from '@chamvi/app';
const digest = defineAgent({
name: 'digest',
// resolved through @ai-sdk/anthropic and ANTHROPIC_API_KEY, never the Vercel Gateway
model: 'anthropic:claude-sonnet-5-5',
instructions: 'Summarize what happened since the last digest.',
triggers: [
// POST /agents/digest/runs, the dashboard and MCP
http(),
// weekdays at 08:00, Berlin time
cron('0 8 * * 1-5', { timezone: 'Europe/Berlin' }),
],
notify: 'console',
});
// synchronous and side-effect free: chamvi start | run-job | migrate load this default export
export default createApp({
agents: [digest],
modules: [consoleNotifierModule()],
});- The entry is
src/index.ts.dev,buildandstart --devtake--entry, else thepackage.jsonfield"chamvi": { "entry": "<path>" }, elsesrc/index.ts. The built commands (start,run-job,migrate) default todist/index.mjsand never read that field. createApp()is synchronous and side-effect free: no env read, no I/O, no provider import. Everything happens instart(),runJob()andmigrate(), which the CLI calls.- An entry whose default export is not an app (checked with
isApp(), which also recognizes an app of another installed copy) makes the CLI exit 2. @chamvi/appre-exports@chamvi/coreand the common part of@chamvi/http(agentPermission,Permissionand theAuthenticator,Principal,HttpRoute*,StaticTokentypes), so an agent repository imports one package.
dev runs the source through Node's type stripping and build bundles it with tsdown, so the code has to suit both: relative imports carry .ts and there is no enum. The template tsconfig.json:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"noEmit": true
},
"include": ["src", "test"]
}Model strings
An agent's model may be a string '<provider>:<model>'. It resolves through an AI SDK provider registry of every installed provider package (@ai-sdk/anthropic, @ai-sdk/openai, @ai-sdk/google, @ai-sdk/mistral, @ai-sdk/xai) with its key from env (ANTHROPIC_API_KEY, ...), overridden per provider by createApp({ models }). start and runJob check every string model first: a provider package that is not installed or a key that is not set throws InvalidAgentDefinitionError naming the package or the variable, and the CLI exits 2. A string never reaches the Vercel Gateway. A LanguageModel object is used as is.
Modules
Modules are orkos IModules on an eldin container, unwrapped. Six built-in modules are always there; listing a module with a built-in's name (ModuleName) replaces it, which is also how you configure one (storeModule({ dataSource }), httpModule({ cors })). Two modules of one name, a built-in's included, throw InvalidAgentDefinitionError from createApp.
| Module | Does |
|---|---|
configModule() | Reads every variable once (environment) and registers the config, the clock and the JSON line logger |
storeModule() | A TypeormStore over DATABASE_URL, migrated unless CHAMVI_MIGRATE=false, plus the hourly purgeExpired(); without DATABASE_URL a MemoryStore (warned outside dev) |
staticTokensModule() | The staticTokens() authenticator from CHAMVI_TOKENS |
engineModule() | Checks the model strings, builds the Engine with every contributed notifier and starts it |
schedulerModule() | The in-process cron scheduler; CHAMVI_SCHEDULER=false leaves cron to Kubernetes CronJobs and chamvi run-job |
httpModule() | createHttpApp() with every contributed route and authenticator, the graceful shutdown, serve() on HOST:PORT |
The optional modules ship with the package they configure, on its /module subpath (@chamvi/app is an optional peer there, and the package's main entry never imports it), so install the feature package of each one you use. Each factory reads its variables when it is called, that is when the entry is imported, and exposes its instance at once (.app, .manager). Each module also provides its instance on the engine before it starts, so a tools factory injects it per execution with the package's inject*Context(ctx) (see Context):
// src/index.ts
import { createApp, defineAgent } from '@chamvi/app';
import { githubModule } from '@chamvi/github/module';
import { openclawModule } from '@chamvi/notify-openclaw/module';
import { sandboxModule } from '@chamvi/sandbox/module';
import { uiModule } from '@chamvi/ui/module';
import { codingTools } from '@chamvi/coding';
import { githubWebhook, injectGithubContext } from '@chamvi/github';
import { injectSandboxContext } from '@chamvi/sandbox';
// every factory reads its env when it is called (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, ...)
const github = githubModule();
// CHAMVI_SANDBOX_PROVIDER, when set, wins over the option
const sandbox = sandboxModule({
provider: 'docker',
allowRepo: (url) => url.startsWith('https://github.com/acme/'),
});
const repro = defineAgent({
name: 'repro',
model: 'anthropic:claude-sonnet-5-5',
instructions: 'Reproduce the reported bug with a failing test and comment the result.',
triggers: [githubWebhook({ events: ['issues.labeled'] })],
// each module provides its instance on the engine; a factory injects it per execution
// (github.app and sandbox.manager still exist at import time, for definition-time use)
tools: (ctx) => {
const manager = injectSandboxContext(ctx);
return {
...injectGithubContext(ctx).readTools(),
...manager.tools(),
...codingTools(manager),
};
},
approvals: { bash: 'user' },
notify: 'openclaw',
});
export default createApp({
agents: [repro],
modules: [
// POST /webhooks/github
github,
// attached to the engine on start, closed after the engine drained
sandbox,
// the notifier named 'openclaw', approval links from CHAMVI_PUBLIC_URL
openclawModule(),
// the dashboard at /ui
uiModule(),
],
});| Factory | Entry | Contributes | Context |
|---|---|---|---|
githubModule() | @chamvi/github/module | .app (IGithubApp), the signed webhook route /webhooks/github | injectGithubContext(ctx) |
githubNotifierModule({ app }) | @chamvi/notify-github/module | the githubNotifier() named github | injectGithubNotifierContext(ctx, name?) |
sandboxModule() | @chamvi/sandbox/module | .manager (IWorkspaceManager), attached on start, closed after the engine drained | injectSandboxContext(ctx) |
openclawModule() | @chamvi/notify-openclaw/module | the openclaw() notifier named openclaw | injectOpenclawContext(ctx, name?) |
uiModule() | @chamvi/ui/module | the dashboard at <basePath>/ui | |
authupModule(), oidcModule(options) | @chamvi/app/authup | the authup() and oidc() authenticators, after the static tokens | |
consoleNotifierModule() | @chamvi/app | the consoleNotifier() named console |
The notifier contexts are keyed per notifier name, so openclawModule() beside openclawModule({ name: 'ops' }) provides both (injectOpenclawContext(ctx, 'ops')). @chamvi/app/authup stays in the app package, since @chamvi/http cannot depend on @chamvi/app.
With CHAMVI_PUBLIC_URL set, the notifier modules link approvals to the dashboard: <CHAMVI_PUBLIC_URL><basePath>/ui/approvals?run=<runId>&approval=<approvalId>, without a token. A factory missing a required variable throws InvalidAgentDefinitionError naming it; no message ever echoes a value.
Setup contributes, onReady consumes
orkos sets every module up in dependency order, then calls every onReady. chamvi splits the work along that line:
- In
setup, a module registers what it contributes on the container's collection tokens:AppToken.HTTP_ROUTES,NOTIFIERS,AUTHENTICATORS(with anorder, lowest first) andLIFECYCLE_HOOKS. - In
onReady, the consumers build: the engine module collects the notifiers, the http module the routes and authenticators.
So a contribution never depends on module order. A module that resolves ENGINE, SCHEDULER or SERVER in its own onReady lists that module in dependencies. Nothing starts in onReady: the engine, the scheduler and the http module register start steps, which the app runs (engine, scheduler, then the started hooks and listening) only once every module's onReady ran, user modules included. So a configuration error any of them finds starts nothing: no queued run of the shared store executes on a replica that then exits 2.
Start and stop
app.start() resolves once the server listens, so /readyz turns green only after everything runs:
- config
- store (and the migrations, advisory-locked, so every replica may run them)
- every
onReady: the engine, the scheduler and the HTTP app are built, nothing runs yet engine.start()scheduler.start()- the purge timer
serve()onHOST:PORT, listening last
app.stop() is memoized and also stops a start still setting up (which then never listens):
/readyzanswers 503, thenCHAMVI_READINESS_DELAY_MS(0)scheduler.stop()and the purge timer stop- the app's closers (SSE streams, MCP waits)
server.close()engine.drain()for up toCHAMVI_DRAIN_TIMEOUT_MS(30 s)- up to
CHAMVI_CLOSE_TIMEOUT_MS(5 s) for the close, thenserver.close(true) - the workspace manager closes (with
sandboxModule, up to 30 s) - the store's data source is destroyed
orkos swallows teardown errors; chamvi does not: every teardown error is logged, and stop() then rejects with app_stop_failed (the CLI exits 1). An app runs one lifecycle: a second start(), runJob() or migrate() rejects with app_in_use.
runJob({ agent, trigger? }) builds config, store and engine, no scheduler and no HTTP, fires the cron trigger once through runJob() and tears down. migrate() only migrates the store; it fails without DATABASE_URL.
Writing a module
The module contract comes from the main entry of @chamvi/app: AppToken, ModuleName, AppContext, ILifecycleHook, the env readers and the orkos IModule and eldin IContainer types, so a module needs no direct orkos or eldin import. A module that shares an instance with tools provides it in the engineCreated hook and exports an inject* function next to it, keeping its key internal:
export interface IAuditLog {
write(line: string): void;
}
// the key stays inside the package; the pair below is its public face
const AUDIT_CONTEXT = defineContextKey<IAuditLog>('audit');
export function injectAuditContext<S extends StateSchema = StateSchema>(ctx: ToolContext<S>): IAuditLog {
return inject(ctx, AUDIT_CONTEXT);
}
export function auditModule(): IModule {
let unsubscribe: (() => void) | undefined;
return {
name: 'audit',
// onReady resolves ENGINE, so the engine module sets up first
dependencies: [ModuleName.ENGINE],
// setup contributes: every consumer (engine, http) builds in onReady
async setup(container: IContainer) {
container.registerMany(AppToken.HTTP_ROUTES, {
useValue: {
method: 'GET',
path: '/audit',
// extra routes check their own credentials
handler: async () => ({ status: 200, body: { ok: true } }),
},
});
const logger = container.resolve(AppToken.LOGGER);
const log: IAuditLog = { write: (line) => logger.info(line) };
container.registerMany(AppToken.LIFECYCLE_HOOKS, {
useValue: {
name: 'audit',
// before engine.start(): tools reach the log through injectAuditContext(ctx)
engineCreated: (engine) => engine.provide(AUDIT_CONTEXT, log),
started: () => logger.info('audit ready'),
drained: () => logger.info('audit: every run drained'),
},
});
},
// onReady consumes: the engine exists but has not started, the server is not listening yet
async onReady(container: IContainer) {
// migrate builds config and store only: no ENGINE to resolve
if (container.resolve(AppToken.CONTEXT).mode === 'migrate') {
return;
}
const logger = container.resolve(AppToken.LOGGER);
unsubscribe = container.resolve(AppToken.ENGINE).on('run.updated', ({ run }) => {
logger.info('run updated', { run: run.id, status: run.status });
});
},
// also called after an onReady that threw half-way
async teardown() {
unsubscribe?.();
},
};
}- Hooks (
LIFECYCLE_HOOKS):engineCreated(engine)beforeengine.start()(the place forengine.provide(); a throwing hook, such as a second value for one key, is logged throughcontext.report()and the app still starts),started()before the server listens,stopping()after the scheduler stopped,drained()after the engine drained and before the store closes. - Failures: orkos never tears down a module whose
setupthrew, so such asetupcloses what it opened before rethrowing. A module whoseonReadythrew is torn down, soteardowntolerates state thatonReadynever built. - Stopping:
AppToken.CONTEXTholds the lifecycle'ssignal, aborted oncestop()is called;throwIfStopped(context)before a long step keeps a stopped start from going on.context.report(error)logs an error and, duringstop(), makes it reject. - Modes:
AppToken.CONTEXTholds themode:startbuilds everything,job(runJob) config, store and engine,migrateonly config and store. A module that resolvesENGINEinonReadyreturns early inmigratemode, as the sample does, orchamvi migratefails. - Testing:
createModuleHarness({ modules, env?, agents?, clock? })from@chamvi/app/testingruns the real module lifecycle (setup,onReady, the start steps) of the modules you list, built-ins included, withoutstart()and a port:harness.containerresolves what a module contributed,harness.reportedholds everycontext.report()call,harness.stop()tears down. The feature packages test their own modules with it. - Dependencies:
dependenciesname modules the app registers ({ name, optional: true }for one it may lack). A missing one, a cycle or a version mismatch throwsInvalidAgentDefinitionErrorfromstart,runJobandmigratebefore any module set up (the CLI exits 2).
Testing an app
start({ env }) takes the environment instead of process.env for the built-in modules, and models replaces a provider, so an app runs in a test without keys or network:
const app = createApp({
agents,
// 'anthropic:claude-sonnet-5-5' resolves to the mock: no package, no key, no request
models: { anthropic: customProvider({ languageModels: { 'claude-sonnet-5-5': model } }) },
});
// the env replaces process.env for the built-in modules
await app.start({
env: {
PORT: '3101',
HOST: '127.0.0.1',
RESUME_SECRET: 'a-test-secret-of-at-least-32-chars',
CHAMVI_TOKENS: JSON.stringify([{
value: 'test-token-0123456789',
id: 'test',
permissions: ['*'],
}]),
CHAMVI_LOG_LEVEL: 'error',
},
});
try {
const response = await fetch('http://127.0.0.1:3101/agents', { headers: { authorization: 'Bearer test-token-0123456789' } });
return await response.json();
} finally {
// drains the engine, closes the server and the store
await app.stop();
}The optional module factories read options.env ?? process.env when they are called, never start({ env }): configure them in a test through their own env option, for example githubModule({ env: { GITHUB_APP_ID: '1', ... } }).
Read next
@chamvi/appreference: every factory, option, token and variable@chamvi/clireference: commands, exit codes, signals anddev- Deployment: the image, chart and template built on the CLI