@chamvi/core
The public API of @chamvi/core, taken from packages/core/src/index.ts. Contract interfaces carry an I prefix (IStore, IClock, INotifier, IAgentState); data shapes are unprefixed types.
import { Engine, defineAgent } from '@chamvi/core';Test helpers live in @chamvi/test-kit.
Agents
function defineAgent<S extends StateSchema = StateSchema>(def: AgentDefinition<S>): Agent<S>;
const DEFAULT_MAX_STEPS: 20;
const DEFAULT_TIMEOUT_MS: 900_000; // 15 minutesThrows InvalidAgentDefinitionError for an invalid name, duplicate trigger ids, or a toolsContext entry naming a chamvi tool or no tool of a plain tool set. See Agents.
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type { LanguageModel, OutputInterface, ToolSet } from 'ai';
import type { Budget, RunContext } from '../run/types.ts';
import type { StateSchema } from '../state/types.ts';
import type { ToolContext } from '../tool/types.ts';
import type { TriggerDefinition } from '../trigger/types.ts';
/** The factory's argument: the same ToolContext the run's chamvi tools get. */
export type ToolFactoryContext<S extends StateSchema = StateSchema> = ToolContext<S>;
export type ApprovalPolicy = 'user' | ((input: unknown, ctx: { runtimeContext: RunContext }) => 'user' | 'allow' | 'deny');
/**
* Decides per tool name whether a run may use a tool. A refused tool is not
* offered to the model (it is left out of `activeTools`). A call to it that
* still arrives is a tool error, never a run failure, and its tool step
* records `tool_not_allowed` (ToolNotAllowedError) on both paths:
* - the model calls it anyway: the AI SDK answers the model with its
* unavailable-tool error (NoSuchToolError), the engine records the step
* as ToolNotAllowedError;
* - an approved call from an older history (the policy changed before the
* resume) reaches the engine's per-call re-check, which answers the model
* with ToolNotAllowedError.
* Should depend only on the run snapshot (e.g. `run.trigger.invoker`), so
* the tool set is stable across executions.
*/
export type ToolPolicy = (toolName: string, ctx: { run: RunContext }) => boolean;
export type AgentDefinition<S extends StateSchema = StateSchema> = {
name: string;
model: LanguageModel;
instructions: string;
triggers?: TriggerDefinition[];
/**
* The agent's tools: a plain `ToolSet` (the default), or a factory for
* dynamic sets, called once per execution with the same ToolContext the
* tools get. Tools built with `defineTool`/`defineEffectTool` receive that
* context as `context` in `execute`.
*/
tools?: ToolSet | ((ctx: ToolContext<S>) => ToolSet);
/**
* Per tool name, the context of a tool that is not chamvi's (e.g. a
* third-party tool with its own `contextSchema`), passed through unchanged
* by reference to every call of every run: treat it as immutable. An entry
* naming a chamvi tool or no tool is an InvalidAgentDefinitionError (at
* defineAgent for a plain set, a failed run for a factory).
*/
toolsContext?: Record<string, unknown>;
/** Filters the merged tool set (state tools included) per run, e.g. requireToolPermission(). */
toolPolicy?: ToolPolicy;
/** Record<collection, StandardSchemaV1>. */
state?: S;
budget?: Partial<Budget>;
/**
* Tool name to policy (maps to AI SDK toolApproval). A call needing approval
* pauses the run in `waiting`; one approval per pause: when a step asks for
* several, the run waits on the first and the other calls are dropped from
* the history, so the model requests them again after the resume.
*/
approvals?: Record<string, ApprovalPolicy>;
/** Default 24h; on timeout the call is denied. */
approvalTimeoutMs?: number;
/** Notifier name registered on the engine. */
notify?: string;
/**
* Structured output (AI SDK `Output`, e.g. `Output.object({ schema })`):
* a completed run's `output` is the parsed value instead of the text; a
* final answer that does not parse fails the run with a ValidationError.
*/
output?: OutputInterface;
};
export type Agent<S extends StateSchema = StateSchema> = {
readonly name: string;
readonly definition: Required<Pick<AgentDefinition<S>, 'budget'>> & AgentDefinition<S>;
};Permissions
function matchesPermission(granted: string, wanted: string): boolean;
function hasPermission(permissions: readonly string[], wanted: string): boolean;
function toolPermission(agent: string, tool: string): string; // tool:<agent>:<tool>
function requireToolPermission(opts?: RequireToolPermissionOptions): ToolPolicy;* alone grants everything, a * segment matches exactly one segment, and segment counts must match. requireToolPermission() offers a tool when the run's invoker holds toolPermission(agent, tool); runs without an invoker follow withoutInvoker (default 'deny'). See Restricting tools per invoker.
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
export type RequireToolPermissionOptions = {
/** What a run without an invoker (cron, webhook) may use. Default `'deny'`. */
withoutInvoker?: 'allow' | 'deny';
};Triggers
function cron(expression: string, opts?: CronOptions): TriggerDefinition;
function http(opts?: HttpTriggerOptions): TriggerDefinition;
function webhook(opts: WebhookTriggerOptions): TriggerDefinition;TriggerSource, TriggerDefinition and TriggerEnvelope:
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type { Artifact } from '../artifact/types.ts';
export type TriggerSource = 'cron' | 'http' | 'webhook' | 'github' | 'mcp';
export type TriggerDefinition<P = unknown> = {
source: TriggerSource;
/** Unique within an agent, e.g. 'cron:*\/30 * * * *'. */
id: string;
/** Source-specific (cron: { expression, timezone?, overlap }). */
options: Record<string, unknown>;
// method signatures (bivariant): a TriggerDefinition<P> fits AgentDefinition.triggers
concurrencyKey?(payload: P): string | undefined;
idempotencyKey?(payload: P): string | undefined;
};
/**
* Who started a run (or answered an approval), as an authenticator resolved
* it: a snapshot of identity and grants taken at dispatch, never a token.
* Revoking a grant later does not reach a run that already holds it.
*/
export type Invoker = {
id: string;
/**
* The authenticator's label: `@chamvi/http`'s staticTokens() sets
* 'token' and authup() sets 'authup'; oidc() keeps the kind its
* `principal` mapper returns (the docs use 'oidc'); a custom authenticator
* or a direct dispatch may use its own (e.g. 'user').
*/
kind: string;
name?: string;
permissions: readonly string[];
};
export type TriggerEnvelope<P = unknown> = {
id: string;
agent: string;
trigger: string;
source: TriggerSource;
payload: P;
idempotencyKey?: string;
concurrencyKey?: string;
thread?: string;
/** Files the run was started with, stored as artifacts of the run; the model gets them as file parts. */
attachments?: Artifact[];
/**
* Who started the run; only ever set from an authenticator (never from a
* payload). Absent for cron and webhook runs. With an invoker the
* idempotency key is scoped to it, so only the same invoker's dispatch
* is deduplicated (and gets its own first run).
*/
invoker?: Invoker;
receivedAt: Date;
};CronOptions and OverlapPolicy, HttpTriggerOptions, WebhookTriggerOptions:
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
export type OverlapPolicy = 'skip' | 'queue-one' | 'allow';
export type CronOptions = {
timezone?: string;
overlap?: OverlapPolicy;
payload?: unknown;
};/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type { StandardSchemaV1 } from '@standard-schema/spec';
export type HttpTriggerOptions = {
/** Validates the payload of a REST, UI or MCP start. */
schema?: StandardSchemaV1;
};/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type { StandardSchemaV1 } from '@standard-schema/spec';
export type WebhookTriggerOptions = {
/** Starts with `/`; the trigger id is `webhook:<path>`. */
path: string;
/** Validates the delivered payload. */
schema?: StandardSchemaV1;
idempotencyKey?: (payload: unknown) => string | undefined;
concurrencyKey?: (payload: unknown) => string | undefined;
};Engine
class Engine {
constructor(opts: EngineOptions);
readonly store: IStore;
readonly clock: IClock;
getAgent(name: string): Agent | undefined;
listAgents(): Agent[];
dispatch(envelope: DispatchEnvelope): Promise<{ runId: string; deduplicated: boolean }>;
getRun(id: string): Promise<Run | undefined>;
on(event: RunEvent['type'], listener: (e: RunEvent) => void): () => void;
start(): Promise<void>;
stop(): void;
drain(opts?: { timeoutMs?: number }): Promise<void>;
idle(): Promise<void>;
readonly isDraining: boolean;
cancel(runId: string): Promise<void>;
resume(token: string, decision: ResumeDecision): Promise<void>;
tick(): Promise<void>;
recover(): Promise<number>;
}| Method | Description | Guide |
|---|---|---|
store, clock | The store and clock the engine was built with (clock defaults to systemClock) | |
getAgent, listAgents | One registered agent by name; all of them in registration order (a new array) | |
dispatch | Stores a queued run (and its attachments as artifacts); deduplicates by idempotencyKey | Triggers |
getRun | Reads a run from the store | Run Lifecycle |
on | Subscribes to run.updated, step.appended or run.delta; returns an unsubscribe function | Run Lifecycle |
start | Recovers, starts polling and starts queued runs | Recovery |
stop | Stops polling only | Recovery |
drain | Waits for in-flight runs (default 30 s), then marks the rest interrupted | Recovery |
idle | Resolves when nothing runs and nothing more can start | |
cancel | Cancels locally, in the store, or requests it from the owning replica | Recovery |
resume | Answers the approval a waiting run waits on | Approvals |
tick | Denies expired approvals and re-sends undelivered notifications; runs every poll | Approvals |
recover | Requeues abandoned runs; runs on start and every poll | Recovery |
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type { Agent } from '../agent/types.ts';
import type { ArtifactInput } from '../artifact/types.ts';
import type { IClock } from '../clock/index.ts';
import type { INotifier } from '../notify/types.ts';
import type { Run, Step } from '../run/types.ts';
import type { IStore } from '../store/types.ts';
import type { Invoker, TriggerEnvelope } from '../trigger/types.ts';
/**
* A piece of model output while it streams; never stored. `step` is the
* checkpoint step index the step it belongs to is saved under.
*/
export type RunDelta = {
runId: string;
step: number;
kind: 'text' | 'reasoning';
text: string;
};
export type RunEvent = { type: 'run.updated'; run: Run } |
{ type: 'step.appended'; step: Step } |
{ type: 'run.delta'; delta: RunDelta };
export type EngineOptions = {
store: IStore;
agents: Agent[];
notifiers?: Record<string, INotifier>;
/** Default `${hostname}-${pid}`. */
owner?: string;
/** Default 4. */
maxConcurrentRuns?: number;
/** Default 60_000, renewed every ttl/3 while running. */
claimTtlMs?: number;
/** HMAC secret for resume tokens; must not be empty. */
resumeSecret: string;
clock?: IClock;
/** Receives errors that have no caller to throw to. Default console.error. */
onError?: (error: unknown, run?: Run) => void;
};
export type DispatchEnvelope = Omit<TriggerEnvelope, 'id' | 'receivedAt' | 'attachments'> &
Partial<Pick<TriggerEnvelope, 'id' | 'receivedAt'>> & {
/** Files to start the run with; stored as artifacts of the run before it is created. */
attachments?: ArtifactInput[];
};
export type ResumeDecision = {
approved: boolean;
reason?: string;
/** Who answered; recorded on the wait step (\`output.approver\`). */
approver?: Invoker;
};| Option | Default |
|---|---|
notifiers | {} |
owner | `${hostname}-${pid}` |
maxConcurrentRuns | 4 |
claimTtlMs | 60_000, renewed and polled every claimTtlMs / 3 |
clock | systemClock |
onError | logs to console.error |
ApprovalRequest ({ approvalId, toolName, input }) is the tool call a waiting run waits on.
Runs
Run, RunStatusLiteral, RunContext, Step, Checkpoint, WaitPoint, Budget and Usage:
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type { ModelMessage } from 'ai';
import type { TriggerEnvelope } from '../trigger/types.ts';
import type { RunStatus } from './constants.ts';
export type RunStatusLiteral = `${RunStatus}`;
export type Usage = {
inputTokens: number;
outputTokens: number;
};
export type Budget = {
maxSteps: number;
maxTokens?: number;
/**
* Wall-clock limit per execution (not cumulative across resumes): each
* execution of the run gets the full timeout again.
*/
timeoutMs?: number;
};
export type RunContext = {
runId: string;
agent: string;
trigger: TriggerEnvelope;
/** Aborts on cancel, drain, a lost lease or the run timeout, and once the execution has ended. */
signal: AbortSignal;
};
export type WaitPoint = {
kind: 'approval';
approvalId: string;
toolName: string;
input: unknown;
expiresAt: Date;
tokenId: string;
/** Set once the agent's notifier accepted the approval request; tick() re-sends until then. */
notifiedAt?: Date;
};
export type Run = {
id: string;
agent: string;
status: RunStatusLiteral;
trigger: TriggerEnvelope;
concurrencyKey?: string;
thread?: string;
output?: unknown;
error?: { code: string; message: string };
usage: Usage;
waiting?: WaitPoint;
claim?: { owner: string; expiresAt: Date };
/**
* Set by cancel() on a replica that does not execute the run: the owner
* aborts it on its next poll, and a later execution settles it cancelled.
*/
cancelRequested?: boolean;
createdAt: Date;
updatedAt: Date;
startedAt?: Date;
finishedAt?: Date;
};
export type Step = {
runId: string;
index: number;
kind: 'llm' | 'tool' | 'wait';
name?: string;
input?: unknown;
output?: unknown;
error?: unknown;
startedAt: Date;
finishedAt: Date;
};
export type Checkpoint = {
runId: string;
stepIndex: number;
messages: ModelMessage[];
/** AI SDK finish reason of the step this checkpoint was taken after. */
finishReason?: string;
createdAt: Date;
};The RunStatus enum, whose values RunStatusLiteral is built from:
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
export enum RunStatus {
QUEUED = 'queued',
RUNNING = 'running',
WAITING = 'waiting',
COMPLETED = 'completed',
FAILED = 'failed',
CANCELLED = 'cancelled',
INTERRUPTED = 'interrupted',
}Stores
class MemoryStore implements IStore {
constructor(opts?: { clock?: IClock });
}IStore and its parts (IRunStore, IStepStore, ICheckpointStore, IIdempotencyStore, ILockStore, IEffectStore, IStateStore, IArtifactStore) are listed on the Store Contract page.
Artifacts
Artifact (metadata), ArtifactInput (a file to store) and IRunArtifacts (the tool context's artifacts):
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
/** Metadata of a file stored for a run (an attachment or a tool output); the bytes are read separately. */
export type Artifact = {
id: string;
runId: string;
agent: string;
mediaType: string;
filename?: string;
/** Byte length of the data. */
size: number;
createdAt: Date;
};
/** A file to store: an attachment at dispatch or a tool's `artifacts.put()`. */
export type ArtifactInput = {
/** `type/subtype`, e.g. `image/png`. */
mediaType: string;
filename?: string;
data: Uint8Array;
};
/**
* The artifacts of one run, part of the tool context. Not an effect by
* itself: put `put()` inside a `defineEffectTool` to store a file exactly once.
*/
export interface IRunArtifacts {
/** Stores a file as an artifact of the run. Throws ValidationError for an invalid input. */
put(input: ArtifactInput): Promise<Artifact>;
/** An artifact of this run with its bytes; undefined for an unknown id or another run's artifact. */
get(id: string): Promise<{ artifact: Artifact; data: Uint8Array } | undefined>;
/** The run's artifacts, by createdAt then id. */
list(): Promise<Artifact[]>;
}isArtifactMediaType(value: unknown): value is string is the media type rule dispatch and artifacts.put() apply: type/subtype (RFC 6838 restricted names) without parameters. An intake layer can check attachments with it before dispatching (@chamvi/http does).
See Files and Attachments.
Tools
function defineTool<I, O, S extends StateSchema = StateSchema>(
def: Tool<I, O, ToolContext<S>> & {
execute: ToolExecuteFunction<I, O, ToolContext<S>>;
contextSchema?: never;
},
): Tool<I, O, ToolContext<S>>;
const toolContextSchema: StandardSchemaV1<ToolContext, ToolContext>; // vendor 'chamvi'defineTool() is tool() with contextSchema: toolContextSchema; the engine hands the run's ToolContext to every tool carrying that schema (by reference, so a copy of the tool keeps it). Its validate returns the context unchanged and rejects a missing one, which fails the run (validation_error) with Type validation failed for tool context (<tool>): No chamvi tool context: the tool was built by another copy of @chamvi/core, or runs outside a chamvi agent. (the marker is the schema object itself, so a tool from a second copy of @chamvi/core gets no context).
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type { IRunArtifacts } from '../artifact/types.ts';
import type { RunContext } from '../run/types.ts';
import type { IAgentState, StateSchema } from '../state/types.ts';
/**
* What a chamvi tool (built with `defineTool` or `defineEffectTool`) gets as
* `context` in `execute`: one object per execution, shared by every call of
* it and by the tools factory. Treat it as immutable.
*/
export type ToolContext<S extends StateSchema = StateSchema> = {
run: RunContext;
state: IAgentState<S>;
/**
* The run's artifacts (its attachments and files tools stored). Not an
* effect: put `put()` inside a `defineEffectTool` to store a file exactly once.
*/
artifacts: IRunArtifacts;
};Effect tools
function defineEffectTool<I, O, S extends StateSchema = StateSchema>(def: Tool<I, O, ToolContext<S>> & {
execute: ToolExecuteFunction<I, O, ToolContext<S>>;
contextSchema?: never;
effectKey?: (input: I, ctx: ToolContext<S>, options: ToolExecutionOptions<ToolContext<S>>) => string;
alreadyApplied?: (effectKey: string, input: I, ctx: ToolContext<S>) => Promise<O | undefined>;
}): EffectTool<I, O, S>;
function effectMarker(effectKey: string): string; // '<!-- chamvi:effect=<key> -->'
function wrapToolsForRun(
tools: ToolSet,
ctx: ToolContext,
store: IEffectStore,
allow?: (toolName: string) => boolean,
): ToolSet;
const EFFECT: unique symbol; // marks a tool as an effect toolSee Effect Tools & Reconciliation.
Approvals
function createResumeToken(secret: string, p: ResumeTokenClaims & { expiresAt: Date }): string;
function verifyResumeToken(secret: string, token: string, now: Date): ResumeTokenClaims;
type ResumeTokenClaims = { runId: string; approvalId: string; tokenId: string };verifyResumeToken throws InvalidResumeTokenError for every failure. See Approvals & Resume Tokens.
State
function createAgentState<S extends StateSchema = StateSchema>(
agent: string,
schema: S | undefined,
store: IStateStore,
): IAgentState<S>;
function createStateTools<S extends StateSchema>(schema: S): ToolSet; // the tools read context.state/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type { StandardSchemaV1 } from '@standard-schema/spec';
export type StateSchema = Record<string, StandardSchemaV1>;
export type Infer<T extends StandardSchemaV1> = StandardSchemaV1.InferOutput<T>;
export interface IAgentState<S extends StateSchema = StateSchema> {
get<T = unknown>(key: string): Promise<T | undefined>;
set(key: string, value: unknown): Promise<void>;
collection<K extends keyof S & string>(name: K): {
list(q?: { where?: Partial<Infer<S[K]>>; limit?: number }): Promise<Array<{ id: string } & Infer<S[K]>>>;
/** Validates `doc` via the collection's Standard Schema; throws ValidationError and stores nothing on failure. */
upsert(id: string, doc: Infer<S[K]>): Promise<void>;
remove(id: string): Promise<void>;
};
}See Agent State.
Notifiers
INotifier and Notification:
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
export type Notification = {
kind: 'approval' | 'stuck' | 'result' | 'info';
agent: string;
runId: string;
title: string;
body: string;
/** Signed token resuming the run (kind 'approval'). */
resumeToken?: string;
expiresAt?: Date;
};
export interface INotifier {
readonly name: string;
/**
* Delivers a notification. `idempotencyKey` is stable per logical
* notification (`runId:approvalId` for approvals), so transports can dedupe.
*/
notify(n: Notification, opts: { idempotencyKey: string }): Promise<void>;
}See Notifiers.
Clock
const systemClock: IClock;/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
export type ClockTimerOptions = {
/**
* Keep the process alive until the timer fires (default false: engine
* timers never hold the event loop open). Test clocks ignore it.
*/
keepAlive?: boolean;
};
export interface IClock {
now(): Date;
setTimeout(fn: () => void, ms: number, opts?: ClockTimerOptions): { clear(): void };
}systemClock timers never keep the process alive unless keepAlive is set; only the drain deadline sets it.
Errors
ChamviError, InvalidAgentDefinitionError, InvalidResumeTokenError, InvalidRunStateError, RunNotFoundError, ArtifactNotFoundError, BudgetExceededError, TimeoutError, FatalToolError, IncompleteResponseError, ToolNotAllowedError, LeaseLostError, SandboxError, ValidationError and ErrorCode:
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
export const ErrorCode = {
ARTIFACT_NOT_FOUND: 'artifact_not_found',
BUDGET_EXCEEDED: 'BudgetExceeded',
FATAL_TOOL: 'FatalToolError',
INCOMPLETE_RESPONSE: 'incomplete_response',
INVALID_AGENT_DEFINITION: 'invalid_agent_definition',
INVALID_RESUME_TOKEN: 'invalid_resume_token',
INVALID_RUN_STATE: 'invalid_run_state',
LEASE_LOST: 'lease_lost',
RUN_NOT_FOUND: 'run_not_found',
SANDBOX: 'sandbox_error',
TIMEOUT: 'Timeout',
TOOL_NOT_ALLOWED: 'tool_not_allowed',
VALIDATION: 'validation_error',
} as const;
export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode];See Errors for when each is thrown.