npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cogitator-ai/core

v0.78.1

Published

Core runtime for Cogitator AI agents

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 zod

Optional 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 with context.update(), tool input streamed as the model writes it, stage timeouts
  • Streaming Responses - Real-time token and reasoning streaming
  • Structured Output - responseFormat with 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/*.md subagents behind one agent tool, approvals that bubble up through nested checkpoints, background subagents continued by id, git worktree isolation, and .claude/commands/*.md templates expanded from /name args
  • Steering & Interrupts - cog.start() handles to steer, followUp, inject and abort a 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_turns and list_entities with a citation lock, and memory files under /memories through the memory tool, Anthropic's own memory_20250818 on 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() and cog.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: true snapshots the workspace before every user message (content-addressed, unchanged files never read again), and cog.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.md skills from the host or the workspace, activated on demand with use_skill, write_todos planning kept in the thread, deferred tools found with tool_search
  • Harness Agent - createHarnessAgent() assembles a coding or assistant agent from all of the above in one call: presets coding and assistant, 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 via ToolCall.replay. Reasoning models (o-series, GPT-5+) get no temperature / top_p and, 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, custom baseUrl) stay on Chat Completions. Force either path with providers.openai.api: 'responses' | 'chat-completions'. Usage includes cachedInputTokens and reasoningTokens when reported.
  • Azure - a deployment serving a reasoning model gets no temperature / top_p and max_completion_tokens, told by its name (azure/gpt-5) or by providers.azure.model when the name is your own. apiVersion defaults to 2025-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 of temperature / top_p (temperature wins). json_schema uses native structured outputs on Claude 4.5+. Forced tool choice falls back to auto with 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_object and json_schema response formats are supported (schema enforced via outputConfig.textFormat on Claude 4.5 – 4.6, system-prompt instruction otherwise).
  • Google - Gemini has no null schema type, so .nullable() fields in response schemas and tool parameters are sent as nullable: true. A prompt Gemini blocks fails with LLM_CONTENT_FILTERED.
  • OpenAI-compatible servers - profile on providers.openai and providers.vllm says 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 decides max_tokens or max_completion_tokens, whether streams send stream_options.include_usage, and which reasoning_effort values go out (the nearest one the server takes, ties go higher). Mistral, Groq, Together and DeepSeek use their own preset, any other server generic, 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 answer

Every 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 output

Reasoning 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 delivered

queue: { 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, subagents plugin:folder:name, commands /plugin:name, MCP tools mcp__plugin_<plugin>_<server>__<tool>. ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA} and ${user_config.KEY} are put into hooks, MCP configs and text, hooks get CLAUDE_PLUGIN_OPTION_<KEY>.
  • userConfig options come from pluginConfigs of the settings, sensitive ones from the operating system's credential store (experimental_keychainSecretStore: security, secret-tool, PowerShell), never from a file, SECRET_STORE_UNAVAILABLE without one.
  • enabledPlugins of the settings layers decides what loads (managed, code enabled, local, project once trusted, user), else defaultEnabled.
  • Generations: registrations unload in reverse order, reload(id) swaps only a generation that loaded in full (its MCP servers started), watch: true reloads on file changes. Plugins load in the order of their dependencies (semver ranges) and unload in reverse, a missing, failed or wrong dependency blocks with PLUGIN_DEPENDENCY_UNMET, a cycle with PLUGIN_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, fetch and exec go through the permission engine against the manifest's permissions (Read(...), Edit(...), Bash(...), WebFetch(domain:...), and Routine for routines and triggers), unallowed requests fail with PLUGIN_PERMISSION_DENIED. A plugin with code and no permissions registers no hooks or MCP servers, the host's deny rules apply to every plugin, and a version that drops permissions keeps the ones granted. A plugin in the host's code gets the same checks and no more.
  • experimental_PluginInstaller installs from marketplaces (Claude Code's marketplace.json), npm and git, pinned to the commit, npm version and integrity or archive digest, the plugin's permissions agreed to at install. experimental_validatePlugin checks a folder as claude plugin validate does.

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_createCronEngine over cron-parser (an optional peer, loaded on first use: 5 or 6 fields, @daily and the other presets, time zones), experimental_TimerClaims and experimental_TimerPoller for the claim, renew and release logic over any TimerStore, experimental_Scheduler on top. HeartbeatScheduler of channels and TimerManager, JobScheduler and CronTriggerExecutor of workflows run on them. InMemoryTimerStore and FileTimerStore live 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) for dedupTtlMs, 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) and maxConcurrent decide 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 patchTriggerState step), history, runs going on and claims in experimental_InMemoryRoutineStore, experimental_FileRoutineStore, experimental_RedisRoutineStore or experimental_PostgresRoutineStore, the inbox in the same four kinds (an answer claimed once with claimAnswer, pages by time and id). The Redis and Postgres stores start each firing once across processes.
  • triggers.schedulerTools('assistant') gives an agent schedule_task, list_tasks and cancel_task whose tasks this manager runs on it. createSchedulerTools takes timezone, presets and 6 fields, first fires at the real occurrence, and takes maxTasks (default 20) and requiresApproval. A task is for the user of the run that scheduled it, runs at most once a minute, skips an occur