runcor Documentation
Complete reference for the runcor AI runtime engine.
Overview
runcor is a runtime engine that sits between your application and foundation models. It handles model routing, retries, memory, cost tracking, policy enforcement, quality evaluation, external system connections, and business value analysis — so your code stays focused on business logic.
Your AI Tools (thin — just workflow logic)
|
runcor (routing, memory, cost, policy, agents, eval, scheduling, telemetry)
|
Foundation Models (Anthropic, OpenAI, Google)
External Systems (Gmail, Slack, databases, APIs)
What runcor provides
- Model routing with fallbacks and circuit breakers across Anthropic, OpenAI, and Google
- Durable execution with a managed state machine, concurrency control, and graceful shutdown
- Cost tracking with per-request, per-user, per-flow, and global budgets
- Scoped memory (tool, user, session) with pluggable backends
- Policy enforcement with rules, guardrails, rate limiting, and access control
- Agent execution with tool calling, iteration budgets, and stop conditions
- Scheduled flows with cron triggers and timezone support
- Quality evaluation with scoring, confidence levels, and human review flags
- External connections via MCP adapters (Gmail, Slack, Calendar, custom)
- Discernment analysis that tells you whether your AI workflows deliver business value
- OpenTelemetry native traces, metrics, and structured logs
- HTTP API with 16 REST endpoints and SSE event streaming
- CLI for project scaffolding and runtime management
- Built-in dashboard for monitoring executions, costs, and provider health
Getting Started
Installation
bashnpm install runcor
Minimal example
typescriptimport { createEngine } from 'runcor';
const engine = await createEngine();
engine.register('hello', async (ctx) => {
const response = await ctx.model.complete({
prompt: 'Say hello in one sentence.',
});
return response.text;
});
const exec = await engine.trigger('hello', {
idempotencyKey: 'hello-001',
});
With configuration
Create a runcor.yaml in your project root:
yamlproviders:
- type: anthropic
apiKey: ${ANTHROPIC_API_KEY}
models: [claude-sonnet-4-20250514]
typescriptimport { createEngine } from 'runcor';
const engine = await createEngine(); // auto-loads runcor.yaml
engine.register('morning-brief', async (ctx) => {
const brief = await ctx.model.complete({
prompt: 'Summarize today\'s priorities.',
});
await ctx.memory.user.set('last-brief', brief.text);
return brief.text;
});
await engine.trigger('morning-brief', {
idempotencyKey: 'brief-2026-03-07',
userId: 'alice',
});
Core Concepts
Executions
Every flow trigger creates an execution that moves through a state machine:
queued → running → complete
→ failed
running → waiting → running (resumed)
running → retrying → running
queued/running/waiting → failed (via cancel)
Each execution has:
- A unique ID
- An idempotency key (prevents duplicate triggers)
- State transition timestamps
- Input, result, and error data
- Optional userId, sessionId, and tenantId
Concurrency control
The engine limits how many executions run simultaneously. Excess triggers are queued in FIFO order and dispatched as slots open.
typescriptconst engine = await createEngine({
concurrency: 10, // max 10 running at once (default: 100)
drainTimeout: 15000, // wait 15s for in-flight work on shutdown
});
Idempotency
Every trigger requires an idempotencyKey. If you trigger the same key twice, the second call returns the existing execution instead of creating a duplicate.
typescript// First call creates the execution
await engine.trigger('my-flow', { idempotencyKey: 'order-123' });
// Second call returns the same execution
await engine.trigger('my-flow', { idempotencyKey: 'order-123' });
Engine API
Creating an engine
typescriptimport { createEngine } from 'runcor';
// Minimal (in-memory, no providers)
const engine = await createEngine();
// With config object
const engine = await createEngine({
model: { providers: [{ provider: myProvider, priority: 1 }] },
concurrency: 50,
drainTimeout: 10000,
});
// From runcor.yaml (auto-detected)
const engine = await createEngine();
Flow management
typescript// Register a flow
engine.register('my-flow', handler, {
timeout: 60000, // 60s max execution time
maxRetries: 3, // retry on transient failures
description: 'Does X', // shown in MCP server and HTTP API
inputSchema: { ... }, // JSON Schema for input validation
});
// Unregister
engine.unregister('my-flow');
// List registered flows
const flows = engine.listFlows();
// [{ name: 'my-flow', config: { timeout: 60000, ... } }]
Execution control
typescript// Trigger
const exec = await engine.trigger('my-flow', {
idempotencyKey: 'unique-key',
input: { data: 'value' },
userId: 'alice',
sessionId: 'session-1',
tenantId: 'tenant-a',
metadata: { source: 'api' },
});
// Get execution status
const status = await engine.getExecution(exec.id);
// List executions
const all = await engine.list();
const running = await engine.list({ state: 'running' });
const byFlow = await engine.list({ flowName: 'my-flow' });
// Cancel
await engine.cancel(exec.id, 'No longer needed');
// Resume a waiting execution
await engine.resume(exec.id, { approved: true });
// Replay a completed execution
const replay = await engine.replay(exec.id);
// Delete a terminal execution
await engine.deleteExecution(exec.id);
// List waiting executions
const waiting = await engine.listWaiting('my-flow', { userId: 'alice' });
Events
The engine extends EventEmitter. Subscribe to lifecycle events:
typescriptengine.on('execution:state_change', (event) => {
console.log(`${event.executionId}: ${event.from} → ${event.to}`);
});
engine.on('execution:complete', (event) => {
console.log(`Done: ${event.result}`);
});
engine.on('cost:budget_exceeded', (event) => {
console.log(`Budget exceeded: ${event.scope}`);
});
Available events:
| Event | Payload |
|---|---|
ready | {} |
shutdown | {} |
execution:state_change | { executionId, from, to } |
execution:complete | { executionId, result } |
provider:health_change | { provider, state } |
cost:request | { executionId, provider, model, cost, tokens } |
cost:budget_warning | { scope, current, limit } |
cost:budget_exceeded | { scope, current, limit } |
policy:violation | { rule, flowName, reason } |
policy:warning | { guardrail, flowName, reason } |
policy:rate_limited | { name, scope } |
eval:score | { executionId, evaluator, scores } |
eval:complete | { executionId, overallScore, confidence } |
eval:flagged | { executionId, reason } |
adapter:connected | { name } |
adapter:disconnected | { name } |
adapter:error | { name, error } |
adapter:tools_discovered | { name, tools } |
adapter:tool_call | { adapter, tool, result } |
flow:registered | { name } |
flow:unregistered | { name } |
scheduler:trigger | { flowName } |
scheduler:skip | { flowName, reason } |
scheduler:registered | { flowName, cron } |
scheduler:removed | { flowName } |
model:validation_retry | { executionId, error } |
discernment:signal | { signal } |
discernment:recommendation | { recommendation } |
discernment:cycle | { report } |
Shutdown
typescriptawait engine.shutdown(); // waits for drainTimeout, then force-fails stragglers
Status and capabilities
typescriptengine.getStatus();
// 'initializing' | 'ready' | 'shutting_down' | 'stopped'
engine.getCapabilities();
// { cost: true, evaluation: true, adapters: false, discernment: true, scheduler: true }
engine.getProviderHealth();
// [{ name: 'anthropic', healthState: 'healthy', priority: 1, costPerToken: { ... } }]
Flow Handlers
A flow handler is an async function that receives an ExecutionContext:
typescriptasync function myHandler(ctx: ExecutionContext): Promise<unknown> {
// ctx.executionId — unique execution ID
// ctx.input — data passed via trigger
// ctx.model — model completion API
// ctx.memory — scoped memory (tool, user, session)
// ctx.cost — cost tracking for this execution
// ctx.telemetry — OpenTelemetry integration
// ctx.tools — MCP adapter tools (if configured)
// ctx.resumeData — data from resume() (if resumed from waiting)
const result = await ctx.model.complete({ prompt: 'Hello' });
return result.text;
}
engine.register('my-flow', myHandler);
Flow configuration
typescriptengine.register('my-flow', handler, {
timeout: 60000, // max execution time (ms), default: 30000
maxRetries: 5, // retry attempts on transient failure, default: 3
baseRetryDelay: 2000, // initial backoff (ms), default: 1000
maxRetryDelay: 60000, // backoff cap (ms), default: 30000
waitTimeout: 3600000, // max time in waiting state (ms), default: 0 (indefinite)
description: 'My flow', // for MCP server and HTTP API
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
schedule: '0 8 * * *', // cron expression
timezone: 'America/New_York', // IANA timezone for schedule
objective: 'customer-support', // primary business objective
secondaryObjectives: ['cost-optimization'],
expectedCadence: 'daily',
purpose: 'Automated customer support triage',
budget: { limit: 1.00, enforcement: 'hard' },
});
Model Routing
Providers
runcor supports four built-in providers:
typescriptimport { AnthropicProvider, OpenAIProvider, GoogleProvider, MockProvider } from 'runcor';
const engine = await createEngine({
model: {
providers: [
{ provider: new AnthropicProvider({ apiKey: '...' }), priority: 1 },
{ provider: new OpenAIProvider({ apiKey: '...' }), priority: 2 },
{ provider: new GoogleProvider({ apiKey: '...' }), priority: 3 },
],
strategy: 'priority',
},
});
Or configure via runcor.yaml:
yamlproviders:
- type: anthropic
apiKey: ${ANTHROPIC_API_KEY}
priority: 1
models: [claude-sonnet-4-20250514]
- type: openai
apiKey: ${OPENAI_API_KEY}
priority: 2
models: [gpt-4o]
- type: google
apiKey: ${GOOGLE_API_KEY}
priority: 3
models: [gemini-2.0-flash]
Routing strategies
| Strategy | Behavior |
|---|---|
priority | Use highest-priority provider first, fall back on failure (default) |
round-robin | Distribute requests across providers evenly |
lowest-cost | Route to the cheapest provider by token cost |
Circuit breaker
Each provider has a circuit breaker that tracks failures:
- Healthy — requests flow normally
- Unhealthy — after
failureThresholdconsecutive failures, provider is skipped - Half-open — after
cooldownMs, one test request is allowed through
yamlrouting:
strategy: priority
maxFallbackAttempts: 3
failureThreshold: 5 # failures before circuit opens
cooldownMs: 30000 # time before retry (ms)
Model requests
typescriptconst response = await ctx.model.complete({
prompt: 'Summarize this document.',
model: 'claude-sonnet-4-20250514', // optional: specific model
maxTokens: 1000, // optional: max response tokens
temperature: 0.7, // optional: 0-1
provider: 'anthropic', // optional: pin to specific provider
systemPrompt: 'You are a helpful assistant.',
messages: [ // optional: multi-turn conversation
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi!' },
{ role: 'user', content: 'Tell me more.' },
],
tools: [ // optional: tool definitions
{ name: 'search', description: '...', inputSchema: { ... } },
],
responseFormat: { type: 'object', properties: { ... } }, // optional: JSON Schema
});
// Response
response.text; // completion text
response.model; // model used
response.provider; // provider used
response.usage; // { promptTokens, completionTokens }
response.toolCalls; // tool call requests (if any)
response.parsed; // parsed JSON (if responseFormat was a schema)
Streaming
typescriptconst stream = ctx.model.stream({
prompt: 'Write a story.',
});
for await (const chunk of stream) {
if (chunk.type === 'text_delta') {
process.stdout.write(chunk.text);
}
}
Cost Tracking
Budget configuration
typescriptconst engine = await createEngine({
cost: {
budgets: {
perRequest: { limit: 0.50, enforcement: 'hard' },
perUser: { limit: 10.00, enforcement: 'hard', window: { type: 'daily' } },
perFlow: { limit: 50.00, enforcement: 'soft' },
global: { limit: 1000.00, enforcement: 'hard', window: { type: 'monthly' } },
},
warningThreshold: 0.8, // emit warning at 80% of budget
},
});
Enforcement modes:
hard— block the request when budget is exceededsoft— emit a warning event but allow the requestdisabled— no enforcement
Window types: hourly, daily, monthly, custom (with durationMs), none (cumulative)
Accessing cost in flows
typescriptengine.register('my-flow', async (ctx) => {
const result = await ctx.model.complete({ prompt: '...' });
console.log(ctx.cost.executionTotal); // cumulative cost of this execution
console.log(ctx.cost.requestCount); // number of model requests made
});
Querying the ledger
typescriptconst ledger = engine.getCostLedger();
const total = ledger.getTotal({ userId: 'alice' });
const entries = ledger.query({
flowName: 'morning-brief',
startTime: new Date('2026-03-01'),
endTime: new Date('2026-03-07'),
});
Scoped Memory
Three memory scopes with different lifetimes:
| Scope | Lifetime | Namespace |
|---|---|---|
tool | Persistent per flow | Shared across all executions of the same flow |
user | Persistent per user | Shared across all flows for the same userId |
session | Ephemeral per execution | Scoped to a single execution (gone after completion) |
Usage
typescriptengine.register('my-flow', async (ctx) => {
// Tool memory — persistent per flow
const counter = (await ctx.memory.tool.get<number>('call-count')) ?? 0;
await ctx.memory.tool.set('call-count', counter + 1);
// User memory — persistent per user
await ctx.memory.user.set('preferences', { theme: 'dark' });
const prefs = await ctx.memory.user.get('preferences');
// Session memory — ephemeral
await ctx.memory.session.set('temp-token', 'abc123');
// TTL support (seconds)
await ctx.memory.tool.set('cache-key', data, 300); // expires in 5 minutes
// List keys
const keys = await ctx.memory.tool.list();
// Delete
await ctx.memory.tool.delete('old-key');
});
Wait / Resume / Replay
Pausing for external input
typescriptimport { createWaitSignal } from 'runcor';
engine.register('approval-flow', async (ctx) => {
if (!ctx.resumeData) {
// First run: pause and wait for approval
return createWaitSignal({
reason: 'Awaiting manager approval',
expectedResumeBy: new Date(Date.now() + 86400000), // 24h
waitData: { requestId: 'req-123' },
});
}
// Resumed with approval data
const { approved, comments } = ctx.resumeData as { approved: boolean; comments: string };
if (approved) {
return 'Request approved: ' + comments;
}
return 'Request denied';
});
// Trigger — execution pauses in "waiting" state
const exec = await engine.trigger('approval-flow', { idempotencyKey: 'approval-001' });
// Later — resume with data
await engine.resume(exec.id, { approved: true, comments: 'Looks good' });
Replaying executions
Replay creates a new execution from a completed one, using the same input:
typescriptconst original = await engine.trigger('my-flow', { idempotencyKey: 'key-1', input: { x: 1 } });
// ... execution completes ...
const replay = await engine.replay(original.id);
// New execution with same input, new ID
Agent Execution
Create autonomous agents that loop through model calls and tool invocations:
typescriptimport { createAgentHandler } from 'runcor';
engine.register('research-agent', createAgentHandler({
systemPrompt: 'You are a research assistant. Use tools to find information.',
tools: ['gmail.search', 'slack.search'], // qualified tool names
maxIterations: 10,
timeoutMs: 120000,
iterationBudget: 0.50, // max cost per iteration
outputSchema: { // optional structured final answer
type: 'object',
properties: {
summary: { type: 'string' },
sources: { type: 'array', items: { type: 'string' } },
},
required: ['summary'],
},
}));
const exec = await engine.trigger('research-agent', {
idempotencyKey: 'research-001',
input: { query: 'What were last quarter revenue numbers?' },
});
Agent result
typescriptinterface AgentResult {
answer: unknown; // final model response
stopReason: 'completed' | 'max_iterations' | 'budget_exhausted' | 'timeout' | 'context_overflow';
iterations: Array<{
model: string;
toolCalls: ToolCallRequest[];
toolResults: ToolCallResult[];
cost: number;
}>;
totalCost: number;
totalTokens: { input: number; output: number };
conversationLength: number;
}
Structured Output
Request JSON-conformant model responses with automatic validation and retry:
typescriptengine.register('extract-data', async (ctx) => {
const response = await ctx.model.complete({
prompt: 'Extract the person\'s name, age, and status from: "Alice is 30 and active."',
responseFormat: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'integer' },
active: { type: 'boolean' },
},
required: ['name', 'age', 'active'],
},
});
const data = response.parsed;
// { name: 'Alice', age: 30, active: true }
});
If the model response fails schema validation, runcor automatically retries once with a hint. If the retry also fails, a ValidationError is thrown.
Policy & Guardrails
Policy rules
Gate operations based on flow name, user, tenant, or custom logic:
typescriptengine.addPolicy({
name: 'block-expensive-flows',
operations: ['trigger'],
evaluate: (context) => {
if (context.flowName === 'bulk-export' && context.userId !== 'admin') {
return { action: 'deny', reason: 'Only admins can run bulk exports' };
}
return { action: 'allow', reason: null };
},
});
Content guardrails
Inspect and filter input/output content:
typescriptengine.addGuardrail({
name: 'no-pii',
phase: 'output',
mode: 'block', // 'block' | 'warn' | 'transform'
handler: async (content, context) => {
if (containsPII(content)) {
return { action: 'block', reason: 'Output contains PII' };
}
return { action: 'pass', reason: null };
},
});
Rate limiting
typescriptengine.addRateLimit({
name: 'per-user-limit',
scope: 'user', // 'user' | 'flow' | 'global'
limit: 100,
windowMs: 3600000, // 1 hour
behavior: 'reject', // 'reject' | 'queue'
});
Access control
typescriptengine.setAccessPolicy({
identity: '[email protected]',
allowedOperations: ['trigger'], // can only trigger, not cancel/resume/replay
deniedFlows: ['admin-panel'],
});
Multi-tenant configuration
typescriptengine.setTenantConfig({
tenantId: 'customer-a',
allowedFlows: ['customer-a-report'],
rateLimits: [{ name: 'tenant-a-limit', scope: 'global', limit: 50, windowMs: 86400000 }],
});
Evaluation & Quality
Registering evaluators
typescriptengine.addEvaluator({
name: 'quality-check',
flowNames: ['morning-brief'], // null = all flows
evaluate: (context) => {
const output = String(context.output);
return {
scores: {
length: Math.min(output.length / 500, 1.0),
hasActionItems: output.includes('action') ? 1.0 : 0.0,
},
labels: output.length > 200 ? ['detailed'] : ['brief'],
feedback: output.length < 50 ? 'Response too short' : null,
};
},
});
Built-in evaluators
typescriptimport { createLengthEvaluator, createFormatEvaluator, createKeywordEvaluator } from 'runcor';
engine.addEvaluator(createLengthEvaluator({ minLength: 50, maxLength: 2000 }));
engine.addEvaluator(createFormatEvaluator({ expectedFormat: 'json' }));
engine.addEvaluator(createKeywordEvaluator({ required: ['summary', 'recommendation'] }));
Evaluation results
typescriptconst evaluation = engine.getEvaluation(executionId);
// {
// overallScore: 0.85,
// confidence: 'high', // 'high' | 'medium' | 'low'
// aggregateScores: { length: 0.9, hasActionItems: 0.8 },
// labels: ['detailed'],
// }
Human review flags
typescript// Auto-flagging (via config)
const engine = await createEngine({
evaluation: { autoFlagScoreThreshold: 0.3 }, // flag scores below 0.3
});
// Manual flagging
await engine.flagExecution(executionId, 'Needs review — unusual output');
// Managing flags
const flags = engine.listFlags({ status: 'pending' });
engine.updateFlag(executionId, 'reviewed');
engine.updateFlag(executionId, 'resolved');
MCP Adapters
Connect to external systems via the Model Context Protocol:
Configuration
yamlconnections:
- name: gmail
preset: gmail
url: ${GMAIL_MCP_URL}
- name: slack
preset: slack
url: ${SLACK_MCP_URL}
- name: calendar
preset: calendar
url: ${CALENDAR_MCP_URL}
- name: custom-api
transport: sse
url: https://api.example.com/mcp
headers:
Authorization: "Bearer ${API_TOKEN}"
Programmatic usage
typescriptawait engine.addAdapter({
name: 'my-api',
transport: 'sse',
url: 'https://api.example.com/mcp',
timeoutMs: 30000,
retryAttempts: 3,
});
// List available tools
const tools = engine.listAdapterTools();
// [{ qualifiedName: 'my-api.searchUsers', description: '...', inputSchema: { ... } }]
// Call a tool directly
const result = await engine.callAdapterTool('my-api.searchUsers', { query: 'alice' });
Using tools in flows
typescriptengine.register('email-summary', async (ctx) => {
// Discover available tools
const tools = ctx.tools.listTools({ adapter: 'gmail' });
// Call a tool
const emails = await ctx.tools.callTool('gmail.search', {
query: 'is:unread after:today',
maxResults: 10,
});
const response = await ctx.model.complete({
prompt: `Summarize these emails: ${JSON.stringify(emails)}`,
});
return response.text;
});
MCP Server
Expose registered flows as MCP tools for other AI agents to discover and invoke:
typescriptconst engine = await createEngine({
server: { name: 'my-app', version: '1.0.0' },
});
engine.register('my-flow', handler, {
description: 'Does something useful',
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
});
await engine.startServer();
// Other MCP clients can now discover and call 'my-flow'
await engine.stopServer();
Scheduled Flows
Trigger flows on a cron schedule:
typescriptengine.register('daily-report', handler, {
schedule: '0 8 * * *', // 8 AM daily
timezone: 'America/New_York',
});
Or via configuration:
yamlscheduler:
defaultTimezone: America/New_York
flows:
daily-report:
cron: "0 8 * * *"
timezone: America/New_York
Features:
- IANA timezone support
- Overlap prevention (skips if previous execution is still running)
- Automatic idempotency keys per scheduled trigger
Discernment
Portfolio-level analysis that determines whether your AI workflows deliver business value.
Configuration
yamldiscernment:
enabled: true
autonomy: recommend # observe | recommend | advise | enforce
schedule: daily
objectives:
- name: customer-support
description: "Reduce support ticket volume through automated responses"
- name: operational-visibility
description: "Leadership has daily visibility into business metrics"
Tagging flows to objectives
typescriptengine.register('support-triage', handler, {
objective: 'customer-support',
expectedCadence: 'hourly',
purpose: 'Automated support ticket classification',
});
Running analysis
typescript// Manual cycle
const report = await engine.runDiscernmentCycle();
// Report includes:
// - System profile (flow stats, cost, quality, adapter health)
// - Signals (idle flows, cost outliers, quality decline, agent hard stops)
// - Recommendations (keep, optimize, merge, retire, investigate, escalate)
// - Objective summaries
// Get recommendations
const recs = engine.getRecommendations({ action: 'retire' });
engine.acknowledgeRecommendation(recs[0].id);
Autonomy levels
| Level | Behavior |
|---|---|
observe | Collect signals only |
recommend | Signals + model-generated recommendations |
advise | Recommendations require acknowledgment |
enforce | Auto-execute recommendations after grace period |
Built-in heuristic checks
- Idle flow — no executions in N days
- Disproportionate cost — flow uses > X% of total budget
- Quality decline — evaluation scores dropping
- Agent hard stops — agents hitting max iterations/budget/timeout
- Untagged flows — flows without business objectives
- Overlap issues — scheduled flows running concurrently
Custom heuristics
typescriptengine.addHeuristic({
name: 'low-usage',
check: (profile) => {
if (profile.executionCount < 5 && profile.totalCost > 10) {
return {
id: crypto.randomUUID(),
checkName: 'low-usage',
target: profile.flowName,
targetType: 'flow',
severity: 'warning',
evidence: { executions: profile.executionCount, cost: profile.totalCost },
timestamp: new Date(),
};
}
return null;
},
});
HTTP Server
Starting the server
typescriptimport { createEngine, createServer } from 'runcor';
const engine = await createEngine();
engine.register('my-flow', handler);
const server = await createServer(engine, {
port: 3000,
hostname: '127.0.0.1',
cors: { origin: 'https://myapp.com' },
});
// Or via CLI: runcor dev
Authentication
Set AUTH_TOKEN environment variable to enable Bearer token authentication on mutating endpoints (POST, DELETE, PUT, PATCH).
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /v1/health | Engine status, uptime, capabilities |
| GET | /v1/flows | List registered flows |
| POST | /v1/flows/:name/trigger | Trigger a flow |
| GET | /v1/executions | List executions (with state, flowName, limit, offset query params) |
| GET | /v1/executions/:id | Get execution by ID |
| GET | /v1/executions/:id/detail | Get execution with cost entries and evaluation |
| POST | /v1/executions/:id/resume | Resume a waiting execution |
| POST | /v1/executions/:id/replay | Replay a completed execution |
| POST | /v1/executions/:id/cancel | Cancel execution |
| DELETE | /v1/executions/:id | Delete terminal execution |
| GET | /v1/adapters | List adapters and tools |
| POST | /v1/adapters/:name/tools/:tool | Call adapter tool |
| GET | /v1/events | SSE event stream (with type, executionId filters) |
| GET | /v1/providers | Provider health and circuit breaker states |
| GET | /v1/cost/summary | Cost totals by flow, user, and provider |
| GET | /v1/discernment | Objectives, latest report, recommendations |
| GET | /v1/dashboard | Built-in monitoring dashboard |
Trigger example
bashcurl -X POST http://localhost:3000/v1/flows/morning-brief/trigger \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-d '{"idempotencyKey": "brief-001", "userId": "alice"}'
SSE event stream
bashcurl -N http://localhost:3000/v1/events?type=execution,cost
CLI
bash# Scaffold a new project
runcor init my-project
# Start dev server with auto-reload
runcor dev
runcor dev --config path/to/runcor.yaml
# Trigger a flow
runcor trigger morning-brief --input '{"date": "2026-03-07"}'
# Check execution status
runcor status
runcor status <execution-id>
# Resume a waiting execution
runcor resume <execution-id>
Project scaffolding
runcor init [project-name] creates:
my-project/
runcor.yaml # configuration (project name in header)
flows/
hello.mjs # example flow
.env.example # environment template
package.json
tsconfig.json
The project name can be passed as a positional argument or with --name. If omitted, the current directory name is used.
SQLite Persistence
Use SQLite for durable execution state that survives process restarts:
yamlstate:
type: sqlite
path: ./data/runcor.db
Or programmatically:
typescriptimport { SQLiteStateStore } from 'runcor';
const engine = await createEngine({
state: {
type: 'sqlite',
path: './data/runcor.db',
onOrphanedExecution: (exec) => 'fail', // 'fail' | 'requeue' | 'ignore'
},
});
Features:
- WAL mode for concurrent reads during writes
- Automatic schema creation on first use
- Orphaned execution recovery on startup (executions left
runningfrom a crash) - Same
StateStoreinterface as in-memory — drop-in replacement
Observability
OpenTelemetry integration
typescriptimport { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
const tracerProvider = new NodeTracerProvider();
tracerProvider.register();
const engine = await createEngine({
telemetry: {
tracerProvider,
serviceName: 'my-ai-app',
serviceVersion: '1.0.0',
},
});
Every execution produces:
- Spans — one per execution, model call, retry, wait/resume
- Metrics — execution count, latency, error rate, token usage
- Logs — structured JSON with execution context
Custom instrumentation in flows
typescriptengine.register('my-flow', async (ctx) => {
ctx.telemetry.setAttribute('custom.input_length', String(ctx.input).length);
ctx.telemetry.addEvent('processing_started', { step: 'analysis' });
const result = await ctx.telemetry.startSpan('heavy-computation', async () => {
return doHeavyWork();
});
return result;
});
Log handler
typescriptconst engine = await createEngine({
telemetry: {
logHandler: (entry) => {
console.log(`[${entry.severityText}] ${entry.body}`, entry.attributes);
},
},
});
Dashboard
The HTTP server includes a built-in monitoring dashboard at /v1/dashboard:
- Execution feed — real-time SSE-driven cards showing state transitions
- Execution detail — click any card for state timeline, cost breakdown, evaluation scores
- Adapter status — connection state, tool count, health check timestamps
- Provider health — circuit breaker states, priority, per-provider metrics
- Cost summary — total spend with breakdowns by flow, user, and provider
- Discernment panel — business objectives, latest cycle report, active recommendations
The dashboard is a single self-contained HTML page with embedded CSS and JavaScript — no React, no bundler, no new dependencies. Panels gracefully hide when subsystems are disabled.
Configuration Reference
Complete runcor.yaml schema:
yaml# Engine settings
engine:
concurrency: 100 # max concurrent executions
drainTimeout: 10000 # shutdown drain time (ms)
retentionPeriod: 3600 # keep terminal executions (seconds)
# Model providers
providers:
- type: anthropic # anthropic | openai | google | mock
apiKey: ${ANTHROPIC_API_KEY}
baseUrl: https://api.anthropic.com # optional override
priority: 1 # lower = higher priority
models: [claude-sonnet-4-20250514]
costPerToken:
input: 0.003
output: 0.015
# Routing strategy
routing:
strategy: priority # priority | round-robin | lowest-cost
maxFallbackAttempts: 2
failureThreshold: 5 # circuit breaker threshold
cooldownMs: 30000 # circuit breaker cooldown
# External connections (MCP adapters)
connections:
- name: gmail
preset: gmail # gmail | slack | calendar
url: ${GMAIL_MCP_URL}
- name: custom-api
transport: sse # stdio | sse
url: https://api.example.com/mcp
headers:
Authorization: "Bearer ${API_TOKEN}"
# Cost tracking
costs:
warningThreshold: 0.8
defaultTokenEstimate: 1000
maxLedgerEntries: 100000
budgets:
perRequest:
limit: 0.50
enforcement: hard # hard | soft | disabled
perUser:
limit: 10.00
enforcement: hard
window:
type: daily # hourly | daily | monthly | custom | none
perFlow:
limit: 50.00
enforcement: soft
global:
limit: 1000.00
enforcement: hard
# Telemetry
telemetry:
serviceName: my-app
serviceVersion: 1.0.0
memorySpans: false
# Policy engine
policy:
rateLimits:
- name: per-user
scope: user # user | flow | global
limit: 100
windowMs: 3600000
behavior: reject # reject | queue
accessPolicies:
- identity: "*"
allowedOperations: [trigger, resume]
tenants:
- tenantId: customer-a
allowedFlows: [customer-a-flow]
# Evaluation
evaluation:
evaluators:
- type: length
config:
minLength: 50
maxLength: 2000
autoFlagScoreThreshold: 0.3
# MCP server
server:
enabled: true
name: my-app
version: 1.0.0
# HTTP server
httpServer:
enabled: true
port: 3000
hostname: 127.0.0.1
# Scheduler
scheduler:
defaultTimezone: America/New_York
flows:
daily-report:
cron: "0 8 * * *"
timezone: America/New_York
# State persistence
state:
type: sqlite # memory | sqlite
path: ./data/runcor.db
# Discernment (business value analysis)
discernment:
enabled: true
autonomy: recommend # observe | recommend | advise | enforce
schedule: daily
lookbackPeriod: 604800 # 7 days (seconds)
gracePeriod: 86400 # 24 hours (seconds)
# Business objectives
objectives:
- name: customer-support
description: "Reduce support ticket volume"
- name: operational-visibility
description: "Daily business metric visibility"
Environment variable interpolation
yamlapiKey: ${ANTHROPIC_API_KEY} # required — throws if not set
baseUrl: ${BASE_URL:-https://default.com} # optional — uses default value
Error Handling
All engine errors throw EngineError with a code property:
typescriptimport { EngineError } from 'runcor';
try {
await engine.trigger('unknown-flow', { idempotencyKey: 'key' });
} catch (err) {
if (err instanceof EngineError) {
console.log(err.code); // 'FLOW_NOT_FOUND'
console.log(err.message); // 'Flow "unknown-flow" not found'
}
}
Error codes
| Code | When |
|---|---|
ENGINE_NOT_READY | Engine not initialized or already stopped |
ENGINE_SHUTTING_DOWN | Operation attempted during shutdown |
FLOW_NOT_FOUND | Trigger/unregister with unknown flow name |
DUPLICATE_FLOW | Registering a flow name that already exists |
EXECUTION_NOT_FOUND | Get/resume/cancel/replay with unknown ID |
INVALID_STATE | Resume non-waiting or cancel completed execution |
MISSING_IDEMPOTENCY_KEY | Trigger without idempotencyKey |
POLICY_DENIED | Policy rule blocked the operation |
RATE_LIMITED | Rate limit exceeded |
BUDGET_EXCEEDED | Hard budget limit hit |
VALIDATION_FAILED | Structured output failed schema validation |
CONFIG_INVALID | Invalid runcor.yaml |
CONFIG_NOT_FOUND | runcor.yaml not found |
INVALID_SCHEDULE | Bad cron expression |
ADAPTER_NOT_FOUND | Unknown adapter name |
ADAPTER_NOT_CONNECTED | Adapter not in connected state |
TOOL_NOT_FOUND | Unknown tool qualified name |
Specialized errors
typescriptimport { BudgetExceededError, ValidationError, AllProvidersFailedError } from 'runcor';
// BudgetExceededError — thrown when hard budget is exceeded
// ValidationError — thrown when structured output fails schema
// AllProvidersFailedError — thrown when all fallback providers fail
// RetryableError — thrown by providers for transient failures (auto-retried)
Testing with MockProvider
The MockProvider lets you test flows without real API keys:
typescriptimport { createEngine, MockProvider } from 'runcor';
// Simple mock — returns a template string
const engine = await createEngine({
model: { provider: new MockProvider('This is a mock response.') },
});
// Queue multiple responses
const mock = new MockProvider();
mock.queueResponses([
{ text: 'First response' },
{ text: 'Second response' },
{ toolCalls: [{ id: 'tc-1', name: 'search', arguments: { q: 'test' } }] },
]);
// Structured output — MockProvider generates schema-conformant data
const response = await ctx.model.complete({
prompt: 'Extract data',
responseFormat: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'integer' },
},
},
});
// response.parsed = { name: 'mock-string', age: 42 }
Project Structure
src/
├── index.ts # Public API exports
├── engine.ts # Core Runcor class
├── types.ts # Shared type definitions
├── execution.ts # Execution state machine
├── execution-context.ts # ExecutionContext builder
├── state-store.ts # InMemoryStateStore
├── sqlite-state-store.ts # SQLiteStateStore
├── wait-signal.ts # Wait/resume primitives
├── model/
│ ├── router.ts # Model routing engine
│ ├── strategies.ts # Priority, round-robin, lowest-cost
│ ├── anthropic.ts # Anthropic provider
│ ├── openai.ts # OpenAI provider
│ ├── google.ts # Google provider
│ ├── mock.ts # Mock provider (testing)
│ └── validation.ts # JSON Schema validation
├── memory/
│ ├── store.ts # InMemoryStore
│ └── scoped.ts # ScopedMemory (tool/user/session)
├── cost/
│ ├── tracker.ts # CostTracker
│ ├── budget.ts # Budget enforcement
│ └── ledger.ts # InMemoryCostLedger
├── policy/
│ └── policy-engine.ts # Rules, guardrails, rate limits, access
├── evaluation/
│ ├── evaluation-engine.ts # Scoring and human review
│ └── built-in/ # Length, format, keyword evaluators
├── adapter/
│ ├── adapter-manager.ts # MCP adapter lifecycle
│ └── reference/ # Gmail, Slack, Calendar presets
├── agent/
│ ├── handler.ts # createAgentHandler()
│ └── types.ts # AgentConfig, AgentResult
├── discernment/
│ ├── engine.ts # DiscernmentEngine
│ ├── collector.ts # Signal collection
│ ├── analyzer.ts # Heuristic analysis
│ └── objectives.ts # ObjectiveRegistry
├── scheduler/
│ └── cron-scheduler.ts # Cron-based triggers
├── config/
│ ├── loader.ts # YAML loading
│ ├── validator.ts # Schema validation
│ ├── interpolator.ts # Env var interpolation
│ └── mapper.ts # Config → EngineConfig mapping
├── http/
│ ├── server.ts # Hono HTTP server
│ ├── sse.ts # SSE event manager
│ ├── dashboard-html.ts # Dashboard UI
│ └── routes/ # REST endpoints
├── server/
│ └── mcp-server-adapter.ts # MCP server interface
├── cli/
│ ├── index.ts # CLI dispatcher
│ └── commands/ # init, dev, trigger, status, resume
└── telemetry/
└── instrumentation.ts # OpenTelemetry integration
Requirements
- Node.js 20+
- TypeScript 5.x (recommended)
License
MIT