@cogitator-ai/core
v0.78.1
Published
Core runtime for Cogitator AI agents
Maintainers
Readme
@cogitator-ai/core
Core runtime for Cogitator AI agents. Build and run LLM-powered agents with tool calling, streaming, reflection, Tree-of-Thought reasoning, learning optimization, and time-travel debugging.
Installation
pnpm add @cogitator-ai/core zodOptional peer dependencies, installed only for the features that use them:
| Package | Needed for |
| --------------------------------- | --------------------------------------------------------------- |
| @aws-sdk/client-bedrock-runtime | BedrockBackend (bedrock/... models) |
| @cogitator-ai/sandbox | Tools with sandbox: { type: 'docker' \| 'wasm' } |
| pg | sqlQuery / vectorSearch on PostgreSQL, PostgresTraceStore |
| better-sqlite3 | sqlQuery on SQLite |
| nodemailer | sendEmail over SMTP |
| langfuse | LangfuseExporter |
Full documentation: cogitator.app/docs, start with Agents and Cogitator.
The package also ships the documentation of its own version as Markdown in node_modules/@cogitator-ai/core/docs/, starting at docs/index.md, so a coding agent reads the API of the version you installed instead of what it remembers. cogitator mcp from @cogitator-ai/cli serves it to Claude Code, Cursor and Codex.
Quick Start
import { Cogitator, Agent, tool } from '@cogitator-ai/core';
import { z } from 'zod';
const calculator = tool({
name: 'calculator',
description: 'Evaluate a math expression',
parameters: z.object({
expression: z.string(),
}),
execute: async ({ expression }) => {
return { result: eval(expression) };
},
});
const agent = new Agent({
name: 'math-assistant',
instructions: 'You are a helpful math assistant',
model: 'openai/gpt-6.1-sol',
tools: [calculator],
});
const cog = new Cogitator({
llm: {
providers: {
openai: { apiKey: process.env.OPENAI_API_KEY! },
},
},
});
const result = await cog.run(agent, {
input: 'What is 25 * 4?',
});
console.log(result.output);Features
- Multi-Provider LLM Support - Ollama, OpenAI, Anthropic, Google, Azure OpenAI, Bedrock, vLLM, Mistral, Groq, Together, DeepSeek
- Type-Safe Tools - Zod-validated tool definitions,
toolset()for typed tool tuples - Tool Execution - Concurrency-safe calls run together, lock keys keep writes of one file apart,
toolOutput()with details and presentation for the UI, progress withcontext.update(), tool input streamed as the model writes it, stage timeouts - Streaming Responses - Real-time token and reasoning streaming
- Structured Output -
responseFormatwith a Zod schema, validated and repaired once on mismatch - Handoffs & Approvals - Pass a conversation to another agent; pause runs for human approval and resume them later
- Permissions & Budgets - Claude Code compatible allow, ask and deny rules, modes from plan to bypass, an audit log, and session budgets that pause instead of failing
- Subagents & Commands - Claude Code compatible
.claude/agents/*.mdsubagents behind oneagenttool, approvals that bubble up through nested checkpoints, background subagents continued by id, git worktree isolation, and.claude/commands/*.mdtemplates expanded from/name args - Steering & Interrupts -
cog.start()handles tosteer,followUp,injectandaborta run going on, double-texting strategies per thread, tools that pause a run to ask a person (context.interrupt()), cancellation that keeps what was shown and done - Prompt Versions & A/B Tests - Versioned instructions per agent with
cog.prompts - Memory Integration - In-memory, Redis, PostgreSQL, SQLite, MongoDB and Qdrant adapters
- Agent Memory Tools -
search_memory,open_turnsandlist_entitieswith a citation lock, and memory files under/memoriesthrough thememorytool, Anthropic's ownmemory_20250818on Claude 4 and later through the Anthropic backend, a tool with the same commands everywhere else - Memory Between Runs - A pre-turn brief of cards quoting their sources (
memory.brief), observations of entities (memory.observations), and a background pass that marks repeats stale and folds lessons into a playbook (memory.consolidation,cog.consolidateMemory()) - Forget, Redact, Rewind -
cog.forget(),cog.redactEntries()andcog.rewind()take turns, threads or a whole user out of memory with every summary, item, fact, vector and memory file derived from them - File Checkpoints -
checkpoints: truesnapshots the workspace before every user message (content-addressed, unchanged files never read again), andcog.rewind(thread, entry, { files: true })brings back conversation, memory and files in one operation,cog.forkThread()forks with the files - 26 Built-in Tools - Web search, SQL, email, GitHub, filesystem, and more
- Reflection Engine - Self-improvement through tool call analysis
- Tree-of-Thought - Advanced reasoning with branch exploration
- Agent Optimizer - DSPy-style learning from traces
- Time Travel - Checkpoint, replay, fork, and compare executions
- Causal Reasoning - Pearl's do-calculus, counterfactuals, d-separation
- Resilience - Retry, circuit breaker, and fallback patterns
- PII Masking - Personal data and secrets replaced with placeholders before they reach the provider
- Run Middleware - Hooks around every model and tool call, tripwire guards that stop a run
- Agent Skills -
SKILL.mdskills from the host or the workspace, activated on demand withuse_skill,write_todosplanning kept in the thread, deferred tools found withtool_search - Harness Agent -
createHarnessAgent()assembles a coding or assistant agent from all of the above in one call: presetscodingandassistant, every piece overridable, the result plain values - Workspace - Files and commands confined to one directory (
LocalWorkspace,DockerWorkspace),workspaceTools(), long tool results saved there for the model to page through - Observability - OpenTelemetry GenAI spans, one trace across runs, workflows and swarms, OTLP and Langfuse exporters
Harness Agent
createHarnessAgent() puts the pieces of the runtime together into an agent ready to work in a project, so you do not wire a workspace, tools, todos, skills, subagents, permissions and compaction by hand:
import { Cogitator, createHarnessAgent } from '@cogitator-ai/core';
const harness = createHarnessAgent({ preset: 'coding', workspace: './my-app' });
const cog = new Cogitator(
harness.mergeConfig({ llm: { defaultModel: 'anthropic/claude-sonnet-5-5' } })
);
const result = await cog.run(harness.agent, {
input: 'The add() test fails, fix it',
...harness.runOptions,
onApproval: (request) => askTheUser(request),
});
await harness.dispose();| Piece | coding | assistant |
| --------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------- |
| Workspace (workspace) | the working directory | ./workspace |
| File tools, glob, grep | yes | yes |
| run_command (shell) | yes | no |
| write_todos (todos) | yes | yes |
| SKILL.md skills of the workspace (skills) | yes | yes |
| Subagents (subagents) | Markdown files and general-purpose, approvals bubble up | no |
| Commands (commands) | Markdown files | no |
| AGENTS.md and CLAUDE.md (projectInstructions) | yes | no |
| Permission engine (permissions) | default mode: reads run, edits and commands ask | the same |
| Memory files (memoryFiles) | no | when the runtime has a memory adapter |
| Compaction (compaction) | old tool outputs cleared, conversation compressed near the window | the same |
| Long tool results (toolResults) | past 30 000 characters saved to the workspace | the same |
| tool_search (toolSearch) | your tools deferred once the model would see more than 20 tools | the same |
| Parallel read-only calls (parallelToolCalls) | yes | yes |
| maxIterations | 100 | 30 |
Every row is an option: false turns a piece off (also in the config mergeConfig() merges into), a value changes it, agent sets any other field of the agent. The project's own subagents, commands, hooks and allow rules apply only with trusted: true, settings files are read only with settings: true, both from projectRoot on this machine (the root of a LocalWorkspace by default, none for a remote workspace). The system prompt is your instructions (or the preset's) and a short section on the workspace, todos, subagents, deferred tools and approvals, only for what the agent has (harnessPrompt: false drops it). The runtime then adds the project's instructions and the skills.
The result is plain values, nothing is hidden: agent (an Agent), config (the Cogitator fields the pieces need: project instructions, subagents, commands, hooks, settings, memory, compaction, limits), runOptions (parallelToolCalls, set per run), workspace, mergeConfig(base) (merges config deep into your config: the preset's defaults under yours, your createHarnessAgent() options over it) and dispose() (disposes a workspace the harness made from a path). Agents, runtimes and runs each hold the settings they read, which is why there are three. See Harness Agent.
LLM Backends
Supported Providers
const agent = new Agent({
name: 'assistant',
instructions: 'You are helpful.',
// Ollama (local, default backend when no provider prefix is present)
model: 'ollama/llama3.1:8b',
// Cloud providers use the same provider/model format:
// model: 'openai/gpt-6.1-sol'
// model: 'anthropic/claude-sonnet-5-5'
// model: 'google/gemini-3.8-flash'
// model: 'openrouter/deepseek/deepseek-v4-pro'
// model: 'vllm/mistral-7b'
});Backend Configuration
import { Cogitator } from '@cogitator-ai/core';
const cog = new Cogitator({
llm: {
defaultProvider: 'openai',
providers: {
ollama: {
baseUrl: 'http://localhost:11434',
},
openai: {
apiKey: process.env.OPENAI_API_KEY!,
},
anthropic: {
apiKey: process.env.ANTHROPIC_API_KEY!,
},
google: {
apiKey: process.env.GOOGLE_API_KEY!,
},
openrouter: {
apiKey: process.env.OPENROUTER_API_KEY!, // chat models of every provider, and decision models
},
vllm: {
baseUrl: 'http://localhost:8000/v1',
},
},
},
});The runtime does not read provider keys from the environment: pass them under providers (or build the config with loadConfig() from @cogitator-ai/config, which reads OPENAI_API_KEY, ANTHROPIC_API_KEY and the rest). Ollama defaults to http://localhost:11434. A model string without a known provider prefix runs on llm.defaultProvider (Ollama when unset). An agent without model uses llm.defaultModel. llm.backends registers backends of your own by name (model: 'my-backend/some-model'), and llm.retry (2 retries with exponential backoff by default, false to disable) applies to every backend the runtime creates. Azure (endpoint, apiKey, apiVersion, deployment), Bedrock (region, credentials, endpoint for a VPC endpoint or a proxy) and Mistral / Groq / Together / DeepSeek (apiKey) are configured the same way under providers. Anthropic and Google take a baseUrl for a proxy or gateway (Google's includes the API version, default https://generativelanguage.googleapis.com/v1beta), and anthropic, google, openai, openrouter and vllm take headers sent with every request. Ollama takes headers for non-streaming and streaming chat requests to /api/chat. It preserves the JSON content type and lets apiKey override custom authorization headers regardless of casing. Invalid HTTP headers are rejected when constructing the backend.
See LLM Backends for every provider's options.
Provider Notes
- OpenAI - the official backend uses the Responses API and defaults to
gpt-6.1-sol. Requests are stateless (store: false), reasoning items are round-tripped between tool-call turns viaToolCall.replay. Reasoning models (o-series, GPT-5+) get notemperature/top_pand, on Chat Completions,max_completion_tokens. Requests with stop sequences fall back to Chat Completions (the Responses API has no stop parameter). OpenAI-compatible providers (Azure, Mistral, Groq, Together, DeepSeek, vLLM, custombaseUrl) stay on Chat Completions. Force either path withproviders.openai.api: 'responses' | 'chat-completions'. Usage includescachedInputTokensandreasoningTokenswhen reported. - Azure - a deployment serving a reasoning model gets no
temperature/top_pandmax_completion_tokens, told by its name (azure/gpt-5) or byproviders.azure.modelwhen the name is your own.apiVersiondefaults to2025-04-01-preview. - Anthropic — defaults to
claude-sonnet-5-5. Sampling params are omitted for Claude 4.7+, 5.x and Fable (they reject non-default values); Claude 4.0 – 4.6 get at most one oftemperature/top_p(temperaturewins).json_schemauses native structured outputs on Claude 4.5+. Forced tool choice falls back toautowith a system-prompt instruction and a one-time warning on Opus/Sonnet 5.5 and Fable. - Bedrock — Claude models follow the same sampling and tool-choice rules;
json_objectandjson_schemaresponse formats are supported (schema enforced viaoutputConfig.textFormaton Claude 4.5 – 4.6, system-prompt instruction otherwise). - Google - Gemini has no
nullschema type, so.nullable()fields in response schemas and tool parameters are sent asnullable: true. A prompt Gemini blocks fails withLLM_CONTENT_FILTERED. - OpenAI-compatible servers -
profileonproviders.openaiandproviders.vllmsays what the server takes: a preset (openai,generic,vllm,ollama,lmstudio,llamacpp,groq,together,deepseek,mistral,openrouter) or{ server?, maxTokensField?, streamUsage?, reasoningEffort? }over one. It decidesmax_tokensormax_completion_tokens, whether streams sendstream_options.include_usage, and whichreasoning_effortvalues go out (the nearest one the server takes, ties go higher). Mistral, Groq, Together and DeepSeek use their own preset, any other servergeneric, the request as before. See Server Profiles.
providers: {
openai: { apiKey: 'lm-studio', baseUrl: 'http://localhost:1234/v1', profile: 'lmstudio' },
vllm: { baseUrl: 'http://gpu-box:8000/v1', profile: { server: 'vllm', reasoningEffort: false } },
}Pricing
A call whose provider reports no cost is priced from @cogitator-ai/models, which knows the LiteLLM catalogue offline too. llm.pricing sets prices of your own in USD per million tokens, for a fine-tune, a model the catalogue lacks or a negotiated rate, and wins over the registry in run cost, cost-routing budgets, cog.estimateCost(), Tree of Thoughts and cog.decide():
const cog = new Cogitator({
llm: {
pricing: {
'openai/ft:gpt-4o-mini:acme': { input: 0.3, output: 1.2 }, // this model string and those ending in it
'llama3.3:70b': { input: 0.6, output: 0.6, inputCached: 0.06 }, // at any provider
},
},
});
cog.pricer.pricing('vllm/llama3.3:70b'); // { input: 0.6, output: 0.6, inputCached: 0.06 }A key matches the model string or its tail after any /, case-insensitively. A call with no reported cost and no known price counts as $0, and the runtime warns once per such model, naming the llm.pricing key to set. Ollama and vLLM calls are free unless llm.pricing prices them, even when a reseller lists a model of the same name, and never warn. cog.pricer is a CallPricer.
Direct Backend Usage
import { createLLMBackend, parseModel } from '@cogitator-ai/core';
const { provider, model } = parseModel('openai/gpt-6.1-sol'); // { provider: 'openai', model: 'gpt-6.1-sol' }
const backend = createLLMBackend(provider ?? 'ollama', {
providers: { openai: { apiKey: process.env.OPENAI_API_KEY! } },
});
const response = await backend.chat({
model,
messages: [
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'Hello!' },
],
});response.finishReason is the same for every provider: stop, tool_calls, length, content_filter, refusal or error. Only a tool_calls turn runs tools: a finished turn with complete tool calls is one even when the provider reported stop (vLLM, LM Studio and other OpenAI-compatible servers do for forced tools), and a turn cut at the token limit, filtered or refused carries no tool calls, so a call cut off mid-arguments never runs with partial or empty ones. A finished call whose arguments are not valid JSON, or not a JSON object, never runs either: it keeps the reason in argumentsError, and the model gets Invalid arguments: ... as its result and can call again. The built-in backends settle each turn with normalizeTurn(), and the runtime applies it to every turn, backends of your own included.
LLM Plugin System
Register custom LLM backends:
import { registerLLMBackend, defineBackend, createLLMBackendFromPlugin } from '@cogitator-ai/core';
const myPlugin = defineBackend({
provider: 'my-provider',
metadata: {
name: 'My Custom Provider',
version: '1.0.0',
description: 'Custom LLM backend',
},
create: (config) => new MyBackend(config),
});
const dispose = registerLLMBackend(myPlugin);
const backend = createLLMBackendFromPlugin('my-provider', { apiKey: '...' });
dispose();registerLLMBackend returns its disposer. An LLMPlugin is also one kind of plugin contribution (context.registerLLMBackend), for one runtime, and the built-in providers are registered that way by the core's own plugin cogitator@builtin.
A backend's provider field is an LLMBackendProvider: a built-in provider name or one of your own, such as the plugin's provider or its key in llm.backends.
cog.route(model) returns the backend and model name a run would use, and cog.knowsProvider(name) tells whether a name/... model prefix routes to that provider (a backend in llm.backends, a built-in provider or a registered plugin) or stays part of the model name on llm.defaultProvider.
LLM Debug Wrapper
Wrap any backend for request/response logging:
import { withDebug } from '@cogitator-ai/core';
const debugBackend = withDebug(backend, {
logStream: true,
maxContentLength: 500,
});LLM Error Handling
import { LLMError } from '@cogitator-ai/core';
try {
await backend.chat(request);
} catch (error) {
if (error instanceof LLMError) {
console.log('Provider:', error.provider, error.model);
console.log('Code:', error.code); // ErrorCode, e.g. LLM_RATE_LIMITED
console.log('Kind:', error.kind); // why it failed, e.g. 'rate_limit' or 'quota'
console.log('HTTP status from the provider:', error.details?.statusCode);
console.log('Retryable:', error.retryable, 'after', error.retryAfter, 'ms');
}
}error.kind (also details.kind) is one of rate_limit, quota, authentication, permission, not_found, invalid_request, context_overflow, content_filter, overloaded, server_error, timeout, network and invalid_response (LLMErrorKind in @cogitator-ai/types). It is read from the provider's own error codes first and the HTTP status second: OpenAI-compatible servers by error.code / error.type, Anthropic by error.type, Gemini by status and its QuotaFailure / ErrorInfo details, Bedrock by the AWS exception name, also for an error inside a stream that already began. An exhausted quota is LLM_QUOTA_EXCEEDED and never retried, even as a 429: OpenAI insufficient_quota, credit_balance_exhausted and spend or usage limits, Anthropic billing_error and an invalid_request_error or rate_limit_error about a credit balance or a usage limit, a 402 from OpenRouter, Gemini RESOURCE_EXHAUSTED at 402 or on a per-day quota, Bedrock ServiceQuotaExceededException. A 504 is LLM_TIMEOUT. classifyProviderError(provider, status, body) returns the kind for backends of your own.
error.message always carries what the provider said ([openai] Bad request: Unsupported parameter: ...). LLM_CONTEXT_LENGTH_EXCEEDED is reported only for the providers' own context overflow errors, not for any bad request that mentions tokens.
A call the caller aborts fails with the abort reason (signal.reason) instead of a retryable LLM error, on every backend and also for a stream that already began, including the OpenAI and Bedrock streams whose SDKs used to end quietly. abortReason(signal) returns that error.
Fallback Models
An agent with fallbackModels keeps answering when its model cannot. A turn moves to the next model, in order, when model is down, overloaded or timing out after its retries, out of quota, rejects the key or no longer exists. content_filter and invalid_request failures and an aborted run never move:
const agent = new Agent({
name: 'support',
model: 'anthropic/claude-sonnet-5-5',
fallbackModels: [
'openai/gpt-6.1-sol',
{ model: 'deepseek/deepseek-v4-pro', provider: 'openrouter' },
],
instructions: 'Answer support questions.',
});
const result = await cog.run(agent, {
input,
onModelFallback: ({ from, to, error }) => console.warn(`${from} -> ${to}: ${error.message}`),
});
result.modelUsed; // 'openai/gpt-6.1-sol' when a fallback gave the final answerEvery turn starts from model again. Each model has a circuit breaker shared by the runtime's runs (llm.modelCircuitBreaker, default 5 failures in a row, 30 s), which opens at once on a lasting failure such as an exhausted quota. A stream moves only before its first token, unless the run has onStepReset: then a model that fails mid-answer moves too, and the run tells the callback to take back what the step streamed ({ reason: 'fallback', model }, servers send it as reset-step). A fallback turn is priced at the model that answered it and leaves an llm.fallback span, and when every model fails the run ends with LLM_UNAVAILABLE (or the error code every model failed with), the failures in details.attempts. Agents without fallbackModels behave as before.
llmUnavailable, llmTimeout, llmInvalidResponse, llmConfigError and wrapSDKError build these errors in your own backends, and providerErrorIn reads the error a router such as OpenRouter puts in the body of a successful response; withLLMRetry(backend, options) / RetryingBackend add retries with Retry-After support to any backend.
Agent Configuration
import { Agent, calculator, webSearch } from '@cogitator-ai/core';
const agent = new Agent({
id: 'custom-id',
name: 'research-assistant',
instructions: 'You research topics thoroughly',
model: 'openai/gpt-6.1-sol',
fallbackModels: ['anthropic/claude-sonnet-5-5'], // see Fallback Models
tools: [webSearch, calculator],
temperature: 0.7,
topP: 0.9,
maxTokens: 4096,
maxIterations: 15,
onIterationLimit: 'answer', // when tools use up maxIterations: one more turn without tools
timeout: 120_000,
stopSequences: ['DONE'],
// responseFormat, reasoning, handoffs, skills, description are covered below
});
// Clone with modifications
const variant = agent.clone({
name: 'fast-assistant',
temperature: 0.3,
maxTokens: 1024,
});Skills and Serialization
A skill bundles tools with the instructions for using them; skills merges them into the agent:
import { Agent, defineSkill, httpRequest, ToolRegistry } from '@cogitator-ai/core';
const apiSkill = defineSkill({
name: 'http-api',
version: '1.0.0',
description: 'Call JSON APIs',
tools: [httpRequest],
instructions: 'Prefer GET requests and summarize responses.',
env: ['API_TOKEN'], // checked by validateSkill()
});
const agent = new Agent({
name: 'integrator',
model: 'openai/gpt-5.5',
instructions: 'Answer with data from the API.',
skills: [apiSkill],
});
const snapshot = agent.serialize(); // plain JSON; tools are stored by name
const registry = new ToolRegistry();
registry.register(httpRequest);
const restored = Agent.deserialize(snapshot, { toolRegistry: registry });To run an agent in another process (queue jobs, workflow jobs, distributed swarm turns), send it in the agent wire format instead: toAgentWire(agent) keeps every setting (stop sequences, timeout, handoffs, the response schema as JSON Schema, ...) and fromAgentWire(payload, { cogitator, tools }) rebuilds it, refusing unknown keys and routing the model exactly as the sender would. toAgentWireRunResult / fromAgentWireRunResult do the same for a run's outcome, cost included.
See Agents.
Agent Skills from SKILL.md
Skills in the Agent Skills format are folders with a SKILL.md (YAML frontmatter with name and description, then instructions) and the files they need. A SkillRegistry finds them in dirs, in a project folder, with user in the home folder, and in the run's workspace (.agents/skills, .cogitator/skills, .claude/skills), the first of a name winning (precedence: 'workspace' puts the workspace before the host folders). Skills are trusted content: their instructions reach the model as they are, so load them only from places you trust. The model sees each skill's name and description in an <available_skills> section of the system prompt and activates one with the built-in use_skill({ skill }), which returns the body, the skill folder and its files. Host skills' files are read with read_skill_file({ skill, path }), confined to the skill folder, workspace skills' with read_file:
import { Agent, SkillRegistry } from '@cogitator-ai/core';
const agent = new Agent({
name: 'maintainer',
model: 'anthropic/claude-sonnet-5-5',
instructions: 'You maintain this project.',
skills: [new SkillRegistry({ project: process.cwd(), user: true })],
});Frontmatter is read leniently: a skill without a description is skipped, other problems (a name that breaks the rules or differs from its folder, wrong types) load with a warning in the catalog's diagnostics. allowed-tools is kept on the skill as allowedTools, not enforced. Instructions past maxInstructionChars (20 000) are cut with a note, a workspace skill never follows a symbolic link, a repeated use_skill returns a short note, and the newest use_skill results (up to 40 000 characters) survive tool output clearing and compaction. See Skills.
Todo Lists and Tool Search
todos: true gives the agent write_todos({ todos: [{ id, content, status }] }): each call replaces the list, at most one item is in_progress. The list is read back from the conversation, copied to the thread's metadata (todos, with the entry it was written at, so other branches do not take it) for later runs, reported with onTodos and returned as result.todos.
A tool marked deferred: true keeps its schema out of the requests. The run offers tool_search({ query, limit }) instead, which ranks the deferred tools with BM25 over names, descriptions and parameters and loads the best ones from the next turn on. The loaded set is read from the tool_search results in the history, so resumed and forked runs keep it. A call to a deferred tool that was never loaded still runs. Loading a tool changes the tool list and so the prompt cache prefix. deferredTools: ['name', ...] on the agent defers tools by name without changing them, for a tool other agents share.
const agent = new Agent({
name: 'back-office',
model: 'openai/gpt-6.1-sol',
instructions: 'You run the back office. Plan multi-step work with write_todos.',
tools: [lookupCustomer, tool({ ...invoiceConfig, deferred: true })],
todos: true,
});See Todo Lists and Tool Search.
Structured Output
responseFormat asks the model for JSON. With a Zod schema the answer is validated and parsed into result.structured:
import { Agent, Cogitator } from '@cogitator-ai/core';
import { z } from 'zod';
const Weather = z.object({ city: z.string(), celsius: z.number() });
const extractor = new Agent({
name: 'extractor',
model: 'openai/gpt-5.5',
instructions: 'Extract the weather report.',
responseFormat: { type: 'json_schema', schema: Weather }, // or { type: 'json' } for any JSON
});
const cog = new Cogitator({
llm: { providers: { openai: { apiKey: process.env.OPENAI_API_KEY! } } },
});
const result = await cog.run(extractor, { input: 'Paris, 21 degrees' });
const weather = Weather.parse(result.structured);When the final answer does not fit the schema, the run asks the model once more with the validation problem (for example celsius: expected number, received string) and does not save the rejected answer to the thread; if the retry fails too, structured is undefined and structuredError says why. Streamed runs keep the first answer, since the client has already seen it. JSON wrapped in prose or code fences is still read. See Structured Outputs.
Reasoning and Prompt Caching
reasoning sets how hard a reasoning model thinks, in one vocabulary for every provider (Anthropic adaptive thinking and effort, OpenAI reasoning.effort, Gemini thinking levels or budgets, Ollama think), and can ask for a readable summary:
const analyst = new Agent({
name: 'analyst',
model: 'anthropic/claude-opus-5-5',
instructions: 'Explain the numbers.',
reasoning: { effort: 'high', summary: true }, // none | minimal | low | medium | high | xhigh | max
});
const result = await cog.run(analyst, {
input,
stream: true,
onReasoning: (d) => process.stdout.write(d),
});
result.reasoning; // the summary
result.usage.reasoningTokens; // billed as outputReasoning that has to travel with tool calls (Claude thinking blocks, OpenAI reasoning items, Gemini thought signatures) is sent back while the agent works through a tool loop.
Runs cache their prompt by default: Anthropic requests, and Claude through OpenRouter (OpenAIBackend with provider: 'openrouter'), mark the end of the agent's instructions, so runs with different input share them, the end of the history a memory context was built from and of the turn before it (Message.cacheBreakpoint), so the next turn on a thread reads the history from the cache, up to four marks in all, and the end of the conversation when another turn is likely (tools or earlier turns, promptCache.conversation overrides it), Bedrock requests mark their stable prefix, OpenAI and Gemini cache on their own, and usage.cachedInputTokens / cacheWriteTokens are priced at the model's cache prices (1-hour writes, cacheWrite1hTokens, at their own price). llm.promptCache: { ttl: '1h' } or false changes it.
Tools
Creating Tools
import { tool } from '@cogitator-ai/core';
import { z } from 'zod';
const weatherTool = tool({
name: 'get_weather',
description: 'Get current weather for a location',
parameters: z.object({
city: z.string().describe('City name'),
units: z.enum(['celsius', 'fahrenheit']).optional(),
}),
execute: async ({ city, units = 'celsius' }, context) => {
console.log(`Run ID: ${context.runId}`);
return { temperature: 22, units, city };
},
});tool() also takes category, tags, sideEffects, requiresApproval, timeout, sandbox and maxResultLength, plus title, outputSchema and MCP annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), which an MCP server sends to its clients. providerTypes names the tool's type in a provider's own catalog, such as { anthropic: 'memory_20250818' }: the Anthropic backend sends the tool as that type instead of its schema (the memory tool only to the Claude models that take it, Claude 4 and later), every other backend sends the schema. withParsedArguments(tool) keeps a tool's approval, sandbox, timeout and limits with a pass-through schema, for cogitator.invokeTool() calls whose arguments were already parsed with the tool's schema (by the MCP SDK or the AI SDK).
The name must be one every provider accepts: letters, digits, _ and -, starting with a letter or _, at most 64 characters (the strictest of the OpenAI, Anthropic, Gemini and Bedrock rules). tool() throws a TypeError for any other name, with a name that fits, instead of the run failing later with a provider error. normalizeToolName('weather.get') gives weather_get for names built from data, normalizeToolNames(names) keeps a whole set distinct, isProviderSafeToolName(name) checks one, and assertToolName(name) throws the same error for tools you build without tool(). A parameter with .default() is optional in the JSON Schema the model sees, and execute receives the default when the model leaves it out. Tools passed to new Agent({ tools }) lose their individual parameter types in a plain array, while toolset(...tools) keeps them as a typed tuple that agents still accept:
import { tool, toolset } from '@cogitator-ai/core';
import { z } from 'zod';
function createSearchTools() {
return toolset(
tool({
name: 'search',
description: 'Search the catalog',
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => ({ hits: [query] }),
}),
tool({
name: 'fetch_item',
description: 'Fetch one item',
parameters: z.object({ id: z.number() }),
execute: async ({ id }) => ({ id }),
})
);
}
const [search, fetchItem] = createSearchTools();
await search.execute({ query: 'lamp' }, ctx); // typed as { query: string }A result object with a base64 image in image or imageBase64 (PNG, JPEG, GIF or WebP, plain or as a data: URL) reaches the model as an image, with the rest of the result as JSON, so a vision model sees a screenshot instead of its base64 text. Anthropic, Bedrock and the OpenAI Responses API get the image inside the tool result, Google after the turn's function responses, Ollama in the tool message's images, and Chat Completions backends (OpenAI-compatible, Azure) in a user message after the turn's tool messages:
const screenshot = tool({
name: 'screenshot',
description: 'Capture the dashboard as a PNG',
parameters: z.object({}),
execute: async () => ({ page: 'dashboard', image: (await capture()).toString('base64') }),
});For results with several parts or other media, return toolContent(...): text and images reach the model as content, file parts (audio, PDFs) stay with the result for the application (onToolResult) and the model sees only a one-line description of each:
import { toolContent } from '@cogitator-ai/core';
execute: async ({ text }) =>
toolContent(
{ type: 'text', text: 'Speech synthesized' },
{ type: 'file', data: mp3.toString('base64'), mediaType: 'audio/mpeg', filename: 'speech.mp3' }
);A json part carries structured data: the application gets the value as it is, the model reads it as JSON. A resource part names a file, document or page by its uri, with an optional name, description and mediaType. With text the model reads the contents under a line naming the resource, with data (base64) it reads a description and the application gets the bytes, with neither it reads just the line, as a link:
execute: async ({ id }) =>
toolContent(
{ type: 'json', value: { id, status: 'shipped', carrier: 'DHL' } },
{ type: 'resource', uri: `file:///orders/${id}.md`, mediaType: 'text/markdown', text: notes }
);The model reads at most limits.maxToolResultLength characters of a result (default 100 000, about 25 000 tokens), so one huge output cannot fill the context. A longer result keeps its start and its end, with a note in the middle of how much was left out, and a tool's own maxResultLength overrides the limit. onToolResult still gets the whole result, while RunResult.messages and the thread history keep what the model read.
Tool parameter schemas reach every provider self-contained: definitions recursive z.lazy() schemas refer to travel in parameters.$defs, other refs are inlined (toToolParameters does this for any JSON Schema).
See Tools and Custom Tools.
Handoffs
handoffs lets an agent pass the conversation to another one: each target becomes a transfer_to_<name> tool, and the rest of the run goes on as the target — its instructions, tools and model — with the whole conversation:
const triage = new Agent({
name: 'triage',
model: 'openai/gpt-5.5',
instructions: 'Hand the customer to the right specialist.',
handoffs: [billing, techSupport],
});
const result = await cog.run(triage, { input: 'How much do I owe on INV-204?' });
result.handoffs; // [{ from: 'triage', to: 'billing', reason }]
result.finalAgent; // 'billing'A handoff can also be { agent, toolName, description } to name the tool or describe when to use it. onHandoff on the run options reports each switch as it happens.
Approvals
A tool with requiresApproval (true or a function of its arguments) never runs without a person's decision. Decide inline with onApproval, or let the run pause and resume it later:
const result = await cog.run(agent, { input: 'Refund order A-1', threadId, userId });
if (result.status === 'paused') {
// result.pendingApprovals: [{ toolCallId, toolName, arguments, description }]
const done = await cog.resume(agent, threadId, {
userId,
decisions: { [result.pendingApprovals![0].toolCallId]: { approved: true } },
});
}Nothing of the paused turn runs until every call in it is decided. Paused runs live in the thread's memory (or process memory, or your runCheckpoints store), so a resume can come after a restart; a new message on the thread instead declines the waiting calls.
cog.resume() takes the thread id or the returned result.checkpoint; defaultDecision answers every call decisions leaves out. To decide while the run waits, pass onApproval: (request) => ({ approved: true }) (or return 'pause') to cog.run(). See Tool Approvals.
A paused run's output is not the agent's answer. isPausedRun(result) tells the two apart, the run result wire format (toAgentWireRunResult) keeps status, pendingApprovals and checkpoint across processes, and AgentRunPausedError (code RUN_PAUSED) reports a pause where the caller cannot wait for a decision.
To run a single tool call outside a run with the same checks (schema, approval, guardrails, sandbox, tool.timeout, the Cogitator's wrapToolCall middleware), use cog.invokeTool(tool, args, { onApproval }). A call nobody approves is refused with pendingApproval set, since there is no run to pause. Pass { agent } for the gates a run of that agent applies: the guardrails are built with its model unless guardrails.model is set, as for a run of it, and its own wrapToolCall middleware wraps the call after the Cogitator's.
onMemoryEntry(entry) hears each entry a run saves to its thread (the input, each model turn, tool results, session events such as an interrupt), also for a run that is aborted or fails, before its promise rejects. An assistant entry with metadata.interrupted: true, as a voice turn the user talked over leaves it, reaches the model with [interrupted by the user] after its text (historyMessage() from @cogitator-ai/types), so the model does not take the part that was said as a finished reply.
Steering, Interrupts and Cancellation
cog.start() runs an agent like run() and hands back what steers it while it goes on:
const handle = cog.start(agent, { input: 'Plan the release', threadId });
handle.steer('Skip the changelog'); // after the current tool calls, before the next model call
handle.followUp('Then draft the announcement'); // once the agent would stop
handle.inject('The release moved to Friday'); // context for the next model call, never wakes the agent
const result = await handle.result; // or handle.abort(), which returns what was not deliveredqueue: { steering, followUp } takes queued messages 'one-at-a-time' (default) or 'all' at once. Each delivered message is saved to the thread as a user message with metadata.queued, and a run that pauses returns the rest in result.undelivered. Queued messages pass the same beforeRun input checks as the input (PII block, prompt injection, input guardrails): a refused one is dropped and listed in handle.refused (onRefusedMessage), the run goes on.
multitaskStrategy (per run, or for every run in the config) says what a run does when its thread has a run going on: enqueue, reject (THREAD_BUSY), interrupt (the running one fails with RUN_INTERRUPTED) or rollback (interrupt, then branch from where the interrupted run started). Unset, runs of one thread go on side by side as before.
A tool pauses the run to ask a person with context.interrupt({ reason, message, responseSchema, expiresAt }). The run returns status: 'paused' with the question in result.interrupts, and cog.resume(agent, threadId, { responses: { [interrupt.id]: answer } }) checks the answer against the schema (or fails with INTERRUPT_ANSWER_INVALID, INTERRUPT_EXPIRED after expiresAt) and runs the tool again, where interrupt() now returns the answer. Pending approvals are listed there too, as kind: 'approval'. parseAnswer(text, schema) and answerFormat(schema) from @cogitator-ai/types read a typed or spoken answer by the question's schema, cog.pausedRun(threadId) lists what a thread waits on and cog.cancelPause(threadId, { userId, reason }) ends the pause with declined results.
Durable Runs
With durable set, a run keeps a checkpoint after every model turn and every batch of tool calls, so when its process crashes or is stopped, another process (or the same one restarted) continues it from its last step:
const cog = new Cogitator({ memory, durable: true });
// at startup
const { resumed } = await cog.recoverRuns({ agents: [reporter] });
for (const { handle } of resumed) handle.result.then(deliver);A recovered run gets its conversation back from the checkpoint and the thread's log, keeps the results of tool calls that returned before the stop (they never run again), gives a call that was running OUTCOME_UNKNOWN ("outcome unknown, verify before retrying") and delivers the messages queued on its handle. Each step writes only what is new: the conversation's start once, messages the log holds as references. Runs are held under a lease (leaseMs, default 60 s, renewed at every step) of the runtime's owner and instance, writes are fenced by it, so two processes (or two runtimes of one owner) never continue one run, and a run aborted with RUN_SUSPENDED hands over at once. A store that keeps failing after storeRetries suspends the run with its last checkpoint kept (onStoreError: 'continue' goes on without checkpoints). Background subagents lost with the process are reported to the recovered run as failed with an unknown outcome. durable on an agent or a run wins over the runtime's, recoverRuns({ resume: false }) only reports the runs, recoverRun(agent, runId) and discardRun(runId) act on one, durableRuns() lists them. Stores: InMemoryDurableRunStore (default without memory), ThreadDurableRunStore (default with a memory adapter, one process at a time) and RedisDurableRunStore (atomic leases, one script per checkpoint, keys that expire, any number of processes). See Durable Runs and examples/core/30-durable-runs.ts.
Permissions
Rules decide tool calls before anyone is asked, in the grammar of Claude Code's permissions settings: allow, ask and deny lists of Tool or Tool(specifier) rules. Claude Code's names map onto the workspace tools (Bash to run_command, Read to read_file, Edit to edit_file, Write, Glob, Grep) and a tool's own name works too. A deny of any layer wins, then ask, then allow:
const cog = new Cogitator({
permissions: {
allow: ['Bash(npm test *)', 'Read'],
ask: ['Bash(git push *)'],
deny: ['Bash(rm -rf *)', 'Read(.env)'],
settings: true, // also read .claude/settings.json, .cogitator/settings*.json, ~/.claude, managed files
},
});
const coder = new Agent({
name: 'coder',
instructions,
tools: workspaceTools(),
workspace,
permissions: { mode: 'acceptEdits' },
});Command lines are tokenized as a shell reads them and matched on their argument vector: an allow needs every command fully understood and covered (no substitutions, wrappers that run other code, writes outside the workspace or unsafe variables), a deny or ask matches every command the line may run, wrappers, sh -c and git global options taken off. Path rules use gitignore patterns checked with .. resolved and symlinks followed, and Read/Edit rules also cover the files shell commands name, a command that touches a whole directory (rm -r, grep -r, find, cp -r...) everything inside it. The grammar is Claude Code's, the differences are listed in the docs. A tool joins a family by declaring access: { kind: 'read' | 'edit' | 'command', paths?, command? }, MCP tools carry mcp: { server, tool } for mcp__server__tool rules. Modes: default (edits ask unless allowed), acceptEdits (edits inside the workspace run), dontAsk (what would ask is refused), plan (read-only until the model's exit_plan_mode plan is approved through an interrupt of kind plan), auto (a classifier model, permissions.classifier, decides what would ask) and bypassPermissions (everything but deny rules, only for isolated environments). cog.start() handles switch the mode with setPermissionMode(). An approval answers with updatedArguments (checked against the schema and the rules again) or remember ("always allow", or "always reject" on a rejection, saved to the session or a settings file). resolvePermission(tool, args, context) is the one decision behind the run loop, invokeTool() and agentAsTool(), and every decision is logged as an approval entry and reported to onPermissionDecision. While a skill is active, its allowed-tools are allow rules of the run. Nothing changes without permissions. See Permissions.
Session Budgets
budget: { session: { maxCostUsd, maxTokens, maxToolCalls }, agent: {...}, warnAt, onWarning } (and AgentConfig.budget) limit what a thread spends across its runs and what each agent spends in it. A run that reaches a limit pauses with an interrupt of kind budget before its next model call or tool turn: resume with { action: 'stop' } to end the run (result.budgetStop), or with a higher budget of the host's own to go on. budget.allowRaise: true lets an answer raise a limit ({ action: 'continue', limit }) or go past it ({ action: 'continue' }). The spending is kept in the thread's metadata (result.sessionSpend). limits.onExceeded: 'interrupt' makes maxTokensPerRun and the cost routing budgets pause the same way instead of failing. See Session Budgets.
An aborted run keeps the text it streamed as an assistant message with metadata.interrupted: true (unless saveInterruptedReply: false), answers the tool calls it never started with ABORTED_BEFORE_DISPATCH, and never writes the late result of a tool that ignored the signal. History read from memory answers tool calls a crash left without a result with OUTCOME_UNKNOWN ("outcome unknown, verify before retrying"), without rewriting the log. See Steering, Interrupts and Cancellation.
Hooks
Commands, HTTP endpoints, model prompts and MCP tools that run at points of a run, in the format of Claude Code's settings.json hooks: SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure, PostToolBatch, SubagentStart, SubagentStop, PreCompact, PostCompact, PreModelSwitch, Stop and Notification:
const cog = new Cogitator({
hooks: {
settings: { root: '/srv/repo', trusted: true }, // also .claude/settings*.json of that root, ~/.claude, managed files
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: 'node',
args: ['${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.js'],
if: 'Bash(rm *)',
},
],
},
],
Stop: [{ hooks: [{ type: 'prompt', prompt: 'Are all tasks done? $ARGUMENTS' }] }],
},
},
});A command gets the event's JSON on stdin (never on its command line) and answers with its exit code (2 blocks, with stderr as the reason) and JSON on stdout (permissionDecision, updatedInput, additionalContext, decision, continue, updatedToolOutput...), in its own process group killed at its timeout, with CLAUDE_PROJECT_DIR and COGITATOR_PROJECT_DIR set. HTTP hooks post the JSON to their URL only (no redirects, allowedHttpHookUrls), prompt hooks ask a model for { ok, reason }, mcp_tool hooks call a tool through hooks.mcp. PreToolUse runs before the permission rules (allow skips an approval only the mode or the tool would ask for, an ask rule still asks, defer pauses the run), PermissionRequest answers an approval before onApproval. PreToolUse runs for each call right before it starts, and a hook that matches Claude Code's tool names (Bash, Read, Edit...) gets Claude Code's input shape. A project's own hooks run only for a trusted root (trusted with root, or trustedRoots), hooks.env: { allow } narrows the environment of commands, every handler run is logged as a custom entry and reported to hooks.onHookRun. The hooks are the run middleware cogitator:hooks, outermost. Nothing runs without hooks. See Hooks.
Project Instructions
instructions: { projectFiles: true } reads the AGENTS.md, CLAUDE.md, .claude/CLAUDE.md and CLAUDE.local.md of the run's workspace, from the root down to its cwd, into the agent's system prompt, with @path imports (relative, at most 4 hops, cycles stopped, inside the workspace unless allowImportsFrom allows a directory), the rules of .claude/rules/ (rules with paths frontmatter and the files of deeper directories join the conversation once a tool reads or edits a file they cover) and a size limit (maxChars, 32 000) with a notice of what was cut. result.instructions lists what was read, and the thread's log gets a custom entry. Off by default. See Project Instructions.
Subagents
subagents gives runs agents to delegate to, in the format of Claude Code's subagents: Markdown files with YAML frontmatter in agents/ folders, and definitions in code shaped like Claude Code's --agents JSON. Runs get an agent tool that takes description, prompt, subagent_type, model, run_in_background and name, like Claude Code's Agent tool:
---
name: reviewer
description: Reviews code changes. Use it after edits.
tools: Read, Grep, Glob
model: sonnet
permissionMode: plan
maxTurns: 8
---
You are a senior reviewer. Report concrete problems with file and line.const cog = new Cogitator({
llm: { modelAliases: { sonnet: 'anthropic/claude-sonnet-5' } },
subagents: {
files: { root: '/srv/repo', trusted: true }, // .agents/agents, .cogitator/agents, .claude/agents
agents: { researcher: { description: 'Researches a topic', prompt: 'You research.' } },
approvalMode: 'bubble',
},
});The frontmatter fields are Claude Code's: name and description (required), tools and disallowedTools (permission rule names, Agent lets the subagent delegate further), model (inherit, an alias of llm.modelAliases, or provider/model), permissionMode (Claude Code's rules, never wider than the delegating run's mode), maxTurns (its maxIterations), skills (full instructions preloaded), mcpServers (names of servers the run has, or inline ones connected through subagents.mcp), hooks (Stop runs as SubagentStop), memory (user, project or local, a notes folder with an agent_memory tool and MEMORY.md in the prompt), background, isolation: worktree (a git worktree under .cogitator/worktrees/, removed when the subagent changed nothing), color and effort. A file without frontmatter or name is documentation, a broken one is skipped with a diagnostic (subagents.onDiagnostic). Precedence is managed, code, the host's dirs, the project (only once trusted, as for hooks), then the user's folders. A subagent gets the delegating run's mode and session rules, only the tools every subagent list above it allows (plus its own memory and MCP tools), its tool calls are charged to the session of the run that delegated, an Agent(name) deny rule refuses it, AgentConfig.subagents limits which ones an agent and the subagents below it may use, the call's model takes only inherit and aliases, an unpriced model is refused under a cost budget, and maxDepth (3) caps nesting. A background subagent runs on its own run handle: send_message steers it or continues it once it finished (in its own mode, worktree and MCP servers), task_output reads its result, task_stop stops it, and a run that would stop waits for the ones it started and reads their results as a notice (a paused or failed run stops them). cog.listSubagents() lists them. See Subagents.
Commands
commands expands an input that names a command (/review src/app.ts) before the run starts, in the format of Claude Code's custom commands: Markdown files in commands/ folders (frontend/component.md is /frontend:component) and templates in code. $ARGUMENTS is everything after the name, $ARGUMENTS[N] and $N the argument at 0-based position N (quotes keep words together), $name a named argument of arguments, \$ escapes, ${CLAUDE_PROJECT_DIR} is the project root, and arguments no placeholder takes are added as ARGUMENTS: .... allowed-tools become allow rules of that run when permissions are set up, model runs it on another model, disallowed-tools and effort apply to that run, description and argument-hint are for a client's menu (cog.listCommands()). The project's files count once it is trusted, result.command reports what was expanded. See Commands.
Plugins (experimental)
experimental_plugins loads plugins in the format of Claude Code's plugins: a folder with .claude-plugin/plugin.json and skills/, commands/, agents/, hooks/hooks.json and .mcp.json loads unchanged, and .cogitator-plugin/plugin.json adds main (code that runs in a process of its own), permissions, ui.panels and engines.cogitator. Plugins in code use the same PluginContext the core's own plugin uses, and every registration returns its disposer.
import { Cogitator, experimental_definePlugin } from '@cogitator-ai/core';
import { experimental_mcpConnector } from '@cogitator-ai/mcp';
const helpers = experimental_definePlugin({
name: 'helpers',
activate(context) {
context.registerTool({
name: 'now',
description: 'The current time',
parameters: { type: 'object', properties: {} },
execute: async () => new Date().toISOString(),
});
},
});
const cog = new Cogitator({
experimental_plugins: {
plugins: [helpers], // in code, id helpers@code
dirs: ['./plugins/deploy-tools'], // folders loaded in place, id deploy-tools@inline
mcp: experimental_mcpConnector(), // connects the plugins' MCP servers
}, // plus what `cogitator plugin install` installed (~/.cogitator/plugins)
});
await cog.experimental_plugins.reload('deploy-tools@inline'); // a new generation, the old one stays when it fails
cog.experimental_plugins.list(); // id, status, generation, contributions, diagnostics- Components are named as in Claude Code: skills
plugin:skill, subagentsplugin:folder:name, commands/plugin:name, MCP toolsmcp__plugin_<plugin>_<server>__<tool>.${CLAUDE_PLUGIN_ROOT},${CLAUDE_PLUGIN_DATA}and${user_config.KEY}are put into hooks, MCP configs and text, hooks getCLAUDE_PLUGIN_OPTION_<KEY>. userConfigoptions come frompluginConfigsof the settings,sensitiveones from the operating system's credential store (experimental_keychainSecretStore:security,secret-tool, PowerShell), never from a file,SECRET_STORE_UNAVAILABLEwithout one.enabledPluginsof the settings layers decides what loads (managed, codeenabled, local, project once trusted, user), elsedefaultEnabled.- Generations: registrations unload in reverse order,
reload(id)swaps only a generation that loaded in full (its MCP servers started),watch: truereloads on file changes. Plugins load in the order of theirdependencies(semver ranges) and unload in reverse, a missing, failed or wrong dependency blocks withPLUGIN_DEPENDENCY_UNMET, a cycle withPLUGIN_DEPENDENCY_CYCLE. - Isolation: code from a folder runs in a child process under Node's permission model (reads its folder, writes its data folder, no processes, workers, addons, network or DNS, no link out of its folder).
context.files,fetchandexecgo through the permission engine against the manifest'spermissions(Read(...),Edit(...),Bash(...),WebFetch(domain:...), andRoutinefor routines and triggers), unallowed requests fail withPLUGIN_PERMISSION_DENIED. A plugin with code and nopermissionsregisters no hooks or MCP servers, the host'sdenyrules apply to every plugin, and a version that dropspermissionskeeps the ones granted. A plugin in the host's code gets the same checks and no more. experimental_PluginInstallerinstalls from marketplaces (Claude Code'smarketplace.json), npm and git, pinned to the commit, npm version and integrity or archive digest, the plugin's permissions agreed to at install.experimental_validatePluginchecks a folder asclaude plugin validatedoes.
See Plugins.
Triggers, routines and the inbox (experimental)
experimental_triggers gives the runtime routines: a saved task (prompt) on an agent or a workflow, and what starts it, a cron schedule, an API call with its own bearer token, a webhook signed with HMAC, GitHub deliveries, inbound mail (Mailgun, Postmark, SendGrid) or Slack events. Each firing starts a session with the routine's prompt as the task and what the event carried after it in a <trigger-payload untrusted="true"> block, as data. Results, failures and runs waiting for a person land in an inbox, and in the Notification hooks as routine_result and routine_failed.
import { Agent, Cogitator, experimental_fileTriggerStores } from '@cogitator-ai/core';
const assistant = new Agent({ name: 'assistant', model: 'ollama/qwen2.5:7b', instructions: '...' });
const cog = new Cogitator({
durable: true, // a firing's run survives a restart
experimental_triggers: {
...experimental_fileTriggerStores(), // .cogitator/triggers, shared with the CLI
agents: { assistant },
routines: [
{
id: 'digest',
prompt: 'Summarize what happened in the repository today.',
target: { agent: 'assistant' },
triggers: [
{ kind: 'cron', cron: '0 18 * * 1-5', timezone: 'Europe/Moscow' },
{
kind: 'github',
secret: { env: 'GITHUB_WEBHOOK_SECRET' },
events: ['pull_request.opened'],
},
{ kind: 'api' }, // POST /triggers/digest.api/fire with a token from issueToken()
],
},
],
},
});
const triggers = cog.experimental_triggers!;
await triggers.start(); // schedules from the routines, polling, durable runs continued
const { token } = await triggers.issueToken('digest.api'); // shown once, kept as its SHA-256
await triggers.inbox.list({ status: 'unread' });- One scheduler core:
experimental_createCronEngineovercron-parser(an optional peer, loaded on first use: 5 or 6 fields,@dailyand the other presets, time zones),experimental_TimerClaimsandexperimental_TimerPollerfor the claim, renew and release logic over anyTimerStore,experimental_Scheduleron top.HeartbeatSchedulerof channels andTimerManager,JobSchedulerandCronTriggerExecutorof workflows run on them.InMemoryTimerStoreandFileTimerStorelive here now. - A firing starts one run: deliveries are deduplicated by their id (a digest of a signed GitHub body, Slack's
event_id,Idempotency-Key) fordedupTtlMs, each Mailgun signature starts one run at most, the run id (trg_<routine>.<trigger>.<key>) is made from the trigger and the occurrence, both claimed in the routine store, and a durable run with that id is held by its process and taken up after a restart from the runs the store keeps going on.overlap(skip,queue,parallel,replace) andmaxConcurrentdecide what a firing does while a run goes on,catchUp(latest,none,all,catchUpWindowMs) what a schedule does about occurrences it missed. - Stores: routines made at run time, trigger state (changed in one atomic
patchTriggerStatestep), history, runs going on and claims inexperimental_InMemoryRoutineStore,experimental_FileRoutineStore,experimental_RedisRoutineStoreorexperimental_PostgresRoutineStore, the inbox in the same four kinds (an answer claimed once withclaimAnswer, pages by time and id). The Redis and Postgres stores start each firing once across processes. triggers.schedulerTools('assistant')gives an agentschedule_task,list_tasksandcancel_taskwhose tasks this manager runs on it.createSchedulerToolstakestimezone, presets and 6 fields, first fires at the real occurrence, and takesmaxTasks(default 20) andrequiresApproval. A task is for the user of the run that scheduled it, runs at most once a minute, skips an occur
