@majkapp/majk-chat-common
v1.0.82
Published
Batteries-included embedding API for Majk Chat — one import, one create() call
Maintainers
Readme
@majkapp/majk-chat-common
Batteries-included embedding API for majk-chat.
One import. One create() call. Full LLM agent — tools, sessions, token tracking — wired and ready.
Table of Contents
- Installation
- Quick Start
- Credential Resolution
- Tool Presets
- Custom Tools
- Permission Gates
- Session Persistence
- Token Tracking & Cost Estimation
- Context Management
- MCP Integration
- Callbacks
- Streaming
- Full Loop History
- Lifecycle Extensions
- Full Config Reference
Installation
npm install @majkapp/majk-chat-commonNode ≥ 18 required. The package is ESM-compatible and ships full TypeScript declarations.
Quick Start
import { MajkChat } from '@majkapp/majk-chat-common';
// Create an agent (read-only tools, context trimming, 10-step tool loop)
const agent = await MajkChat.create({ provider: 'anthropic' });
const { text, toolsUsed, stepsTaken } = await agent.chat(
'List the TypeScript files in src/ and summarise what each does.'
);
console.log(text);
console.log('Tools called:', toolsUsed);
console.log('Steps taken:', stepsTaken);
await agent.destroy();One-shot helper
For a single question that needs no session management:
import { chat } from '@majkapp/majk-chat-common';
const answer = await chat('What is the capital of France?', {
provider: 'anthropic',
tools: 'none',
});
console.log(answer); // "Paris"Credential Resolution
Credentials are resolved once when MajkChat.create() is called.
The first truthy value in the chain wins:
config.apiKey ← HIGHEST PRIORITY
↓ (if absent)
process.env (shell / CI) ← MEDIUM PRIORITY
↓ (if absent)
.env.local file ← LOWEST PRIORITYStandard environment variable names
| Provider | Variable(s) |
|------------------|-------------------------------------------------------------------------|
| anthropic | ANTHROPIC_API_KEY |
| openai | OPENAI_API_KEY · OPENAI_ORG_ID (optional) |
| azure-openai | AZURE_OPENAI_API_KEY · AZURE_OPENAI_ENDPOINT · AZURE_OPENAI_DEPLOYMENT |
| bedrock | AWS_ACCESS_KEY_ID · AWS_SECRET_ACCESS_KEY · AWS_REGION · AWS_SESSION_TOKEN |
Common patterns
Development / testing — put keys in .env.local (git-ignored):
# .env.local
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...CI / Production — inject through your deployment environment:
export ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}Programmatic (multi-tenant, per-user keys) — pass apiKey directly:
const agent = await MajkChat.create({
provider: 'anthropic',
apiKey: await vault.getSecret('anthropic-key'),
});Tool Presets
Pass a ToolPreset string to the tools option for the most common configurations:
| Preset | Description |
|----------------|------------------------------------------------------------------|
| 'read-only' | ls, read, glob, grep, web_fetch, document parsers. Default. |
| 'all' | Everything in read-only plus bash, write, edit, process management |
| 'filesystem' | All filesystem tools (read + write), no web or documents |
| 'web' | web_fetch only |
| 'documents' | Document parsers only (PDF, Excel, Word, CSV) |
| 'planning' | Planning-mode tools (enter_planning_mode, todo_write, etc.) |
| 'none' | No tools — pure LLM conversation |
// Safe codebase analysis — no writes possible
const agent = await MajkChat.create({
provider: 'openai',
tools: 'read-only',
});
// Full agentic automation
const agent = await MajkChat.create({
provider: 'anthropic',
tools: 'all',
});
// Minimal — just chat, no tools
const agent = await MajkChat.create({
provider: 'anthropic',
tools: 'none',
});Custom Tools
Pass FunctionToolDef objects alongside (or instead of) a preset:
import { MajkChat, defineTool } from '@majkapp/majk-chat-common';
// Type-safe definition with defineTool<TArgs>()
const lookupCustomer = defineTool<{ id: string }>({
name: 'lookup_customer',
description: 'Return a customer record by their unique ID.',
parameters: {
type: 'object',
properties: { id: { type: 'string', description: 'Customer UUID' } },
required: ['id'],
},
execute: async ({ id }, context) => {
console.log('conversation:', context.conversationId);
const customer = await db.customers.findById(id);
return customer; // Objects are auto-serialized to JSON for the LLM
},
});
const sendEmail = defineTool<{ to: string; subject: string; body: string }>({
name: 'send_email',
description: 'Send an email.',
parameters: {
type: 'object',
properties: {
to: { type: 'string' },
subject: { type: 'string' },
body: { type: 'string' },
},
required: ['to', 'subject', 'body'],
},
sideEffects: 'external', // Classify side-effect level
execute: async ({ to, subject, body }) => {
await mailer.send({ to, subject, body });
return `Email sent to ${to}`;
},
});
const agent = await MajkChat.create({
provider: 'anthropic',
// Mix a preset with custom tools in one array
tools: [lookupCustomer, sendEmail],
});ToolExecutionContext
The second argument to execute() provides runtime context:
execute: async (args, context) => {
context.conversationId; // string — stable per-agent-instance ID
context.sessionId; // string | undefined — set when sessions are configured
...
}Using ToolCatalog for individual built-in tools
import { MajkChat, ToolCatalog } from '@majkapp/majk-chat-common';
const agent = await MajkChat.create({
provider: 'anthropic',
tools: [
{ executor: ToolCatalog.filesystem.Read.executor, readOnly: true, sideEffects: 'read', pkg: 'basic-tools', description: 'Read files' },
myCustomTool,
],
});
// Or access directly via the static property:
MajkChat.tools.filesystem.Bash
MajkChat.tools.web.WebFetch
MajkChat.tools.documents.UniversalAnalyzerPermission Gates
FunctionToolDef.requiresPermission lets you gate individual tool invocations:
| Value | Behavior |
|--------------------|-----------------------------------------------------------------|
| undefined / false | No gate — always allow execution (default) |
| true | Requires handler — denied when no handler present (safe-side) |
| (args) => boolean \| Promise<boolean> | Async predicate — true = allow, false = deny |
// Always deny — useful for a "stub" tool you want the LLM to see but not run
const restrictedOp = defineTool<{ path: string }>({
name: 'delete_file',
description: 'Delete a file.',
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
requiresPermission: true, // No handler configured → denied automatically
execute: async ({ path }) => { /* never reached */ },
});
// Dynamic gate — inspect the specific args before deciding
const dangerousWrite = defineTool<{ path: string; content: string }>({
name: 'write_config',
description: 'Overwrite a config file.',
parameters: {
type: 'object',
properties: { path: { type: 'string' }, content: { type: 'string' } },
required: ['path', 'content'],
},
requiresPermission: async ({ path }) => {
// Only allow writes inside the project directory
return path.startsWith(process.cwd());
},
execute: async ({ path, content }) => {
await fs.writeFile(path, content, 'utf-8');
return `Written: ${path}`;
},
});Session Persistence
In-memory (default, auto-cleared on restart)
const agent = await MajkChat.create({
provider: 'anthropic',
sessions: 'memory',
sessionId: 'project-onboarding',
autoSave: true, // Default when sessions is configured
});
// History persists across chat() calls within the same process
await agent.chat('My name is Alice.');
await agent.chat('What is my name?'); // → "Your name is Alice."Filesystem sessions (persist across restarts)
const agent = await MajkChat.create({
provider: 'anthropic',
sessions: { type: 'filesystem', path: './sessions' },
sessionId: 'user-alice', // Loads existing data if the ID exists
});
await agent.chat('Remember: my favourite colour is blue.');
await agent.destroy();
// In a later process — same session ID restores history automatically:
const agent2 = await MajkChat.create({
provider: 'anthropic',
sessions: { type: 'filesystem', path: './sessions' },
sessionId: 'user-alice',
});
const { text } = await agent2.chat('What is my favourite colour?');
// → "Your favourite colour is blue."
await agent2.destroy();Custom session store (Redis, DynamoDB, Postgres, …)
Implement the SessionStore interface:
import type { SessionStore, SessionStoreData, SessionStoreSummary } from '@majkapp/majk-chat-common';
import { createClient } from 'redis';
class RedisSessionStore implements SessionStore {
constructor(private redis: ReturnType<typeof createClient>) {}
async save(id: string, data: SessionStoreData): Promise<void> {
await this.redis.set(`session:${id}`, JSON.stringify(data));
}
async load(id: string): Promise<SessionStoreData | null> {
const raw = await this.redis.get(`session:${id}`);
if (!raw) return null;
const parsed = JSON.parse(raw);
parsed.createdAt = new Date(parsed.createdAt);
parsed.updatedAt = new Date(parsed.updatedAt);
return parsed;
}
async list(): Promise<SessionStoreSummary[]> {
const keys = await this.redis.keys('session:*');
// ... load and summarise
return [];
}
async delete(id: string): Promise<void> {
await this.redis.del(`session:${id}`);
}
}
const agent = await MajkChat.create({
provider: 'anthropic',
sessions: new RedisSessionStore(redisClient),
sessionId: 'user-42',
});Manual session management
// Explicit save
const sessionId = await agent.saveSession('my-session-id');
// Load into an existing agent
await agent.loadSession('my-session-id');
// Inspect history
const history = agent.getHistory(); // Message[]
// Clear history (does not delete persisted data)
agent.clearHistory();
// List all sessions
const sessions = await agent.listSessions(); // SessionStoreSummary[]Token Tracking & Cost Estimation
Every chat() call returns token usage for that turn.
You can also register a global callback for streaming events and aggregate stats.
Per-call usage
const { text, tokenUsage } = await agent.chat('Hello!');
if (tokenUsage) {
console.log(tokenUsage.inputTokens); // Prompt tokens
console.log(tokenUsage.outputTokens); // Completion tokens
console.log(tokenUsage.totalTokens); // Sum
console.log(tokenUsage.cacheReadTokens); // Anthropic prompt-cache hits
console.log(tokenUsage.cacheWriteTokens); // Anthropic prompt-cache writes
}Global tracker callback
const agent = await MajkChat.create({
provider: 'anthropic',
tokenTracker: async (event) => {
await analytics.track('llm_token_usage', {
model: event.model,
provider: event.provider,
input: event.inputTokens,
output: event.outputTokens,
total: event.totalTokens,
cost: event.estimatedCostUsd,
sessionId: event.conversationId,
});
},
});Aggregate stats
const stats = agent.getTokenStats();
console.log(stats.totalInputTokens); // Across all chat() calls
console.log(stats.totalOutputTokens);
console.log(stats.requestCount);
console.log(stats.estimatedTotalCostUsd); // Set when pricing is configured
console.log(stats.byModel); // Breakdown per model ID
// Reset counters (e.g. per billing period)
agent.resetTokenStats();Cost estimation
const agent = await MajkChat.create({
provider: 'anthropic',
pricing: {
perMillionInputTokens: 3.00, // USD per 1M prompt tokens
perMillionOutputTokens: 15.00, // USD per 1M completion tokens
perMillionCacheReadTokens: 0.30,
perMillionCacheWriteTokens: 3.75,
// Per-model overrides:
models: {
'claude-haiku-4': {
perMillionInputTokens: 0.80,
perMillionOutputTokens: 4.00,
},
},
},
});
const { tokenUsage } = await agent.chat('Summarise the repo.');
console.log(`This call cost $${tokenUsage?.estimatedCostUsd?.toFixed(6)}`);
console.log(`Running total: $${agent.getTokenStats().estimatedTotalCostUsd?.toFixed(4)}`);Context Management
Automatic prompt-size management is on by default.
Tune or disable it via contextManagement:
const agent = await MajkChat.create({
provider: 'anthropic',
contextManagement: {
enabled: true, // Default: true
maxTotalChars: 180_000, // ~45k tokens. Prune oldest messages when exceeded.
maxToolResultLength: 6_000, // Truncate tool results larger than this.
minMessages: 3, // Always keep at least this many recent messages.
},
});To disable entirely (e.g. when you manage the context window yourself):
contextManagement: { enabled: false }MCP Integration
Connect any Model Context Protocol server:
// From a config file
const agent = await MajkChat.create({
provider: 'anthropic',
mcp: { configPath: './mcp-servers.json' },
});
// Inline JSON string
const agent = await MajkChat.create({
provider: 'anthropic',
mcp: {
configString: JSON.stringify({
mcpServers: {
github: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env: { GITHUB_PERSONAL_ACCESS_TOKEN: process.env.GITHUB_TOKEN! },
},
},
}),
},
});
// Programmatic server definitions
const agent = await MajkChat.create({
provider: 'anthropic',
mcp: {
servers: {
'my-db-server': {
command: 'node',
args: ['./mcp-servers/db.js'],
env: { DB_URL: process.env.DATABASE_URL! },
},
},
},
});
// Always call destroy() to close MCP connections cleanly
await agent.destroy();Callbacks
Observe every tool call and result in real time:
const agent = await MajkChat.create({
provider: 'anthropic',
onToolCall: (toolName, args) => {
console.log(`→ ${toolName}`, args);
},
onToolResult: (toolName, result, success) => {
console.log(`← ${toolName} ${success ? '✓' : '✗'}`, result.slice(0, 200));
},
});Streaming
agent.stream(message, options?) returns an AsyncIterableIterator<StreamEvent> that
yields one event for every meaningful thing that happens in the tool loop — LLM
responses, tool calls, tool results, step boundaries, and a final complete
event when the loop exits.
Basic iteration
import { MajkChat } from '@majkapp/majk-chat-common';
import type { StreamEvent } from '@majkapp/majk-chat-common';
const agent = await MajkChat.create({ provider: 'anthropic' });
for await (const event of agent.stream('Refactor the auth module')) {
switch (event.type) {
case 'step_start':
console.log(`Step ${event.data.step} of ${event.data.maxSteps}`);
break;
case 'tool_calls_detected':
for (const tc of event.data.toolCalls)
console.log(' calling:', tc.function.name);
break;
case 'tool_result':
console.log(' result:', event.data.result);
break;
case 'complete':
console.log('Final answer:', event.data.response.choices[0].message.content);
break;
}
}StreamEvent types
| event.type | Emitted when | Key fields in event.data |
|---|---|---|
| start | Loop begins | maxSteps |
| step_start | A new iteration starts | step, maxSteps |
| message | LLM returns a complete response (default mode) | response |
| content_start | Streaming token block opens | index, contentBlock |
| content_delta | Token delta arrives (streamTokens mode) | index, delta |
| content_end | Streaming token block closes | index |
| tool_calls_detected | LLM requested one or more tools | toolCalls |
| tool_result | A tool finished executing | toolCall, result |
| step_complete | One iteration finished | step, toolCallCount |
| complete | Loop finished, final response ready | response, steps |
| error | Unrecoverable error | error |
Token-delta mode vs complete-message mode
By default stream() waits for the full LLM response before emitting a
message event — clean, no partial text.
Set streamTokens: true to receive incremental content_delta events as the
model generates each token (useful for real-time UI rendering):
// Complete-message mode (DEFAULT) — one 'message' event per LLM call
for await (const ev of agent.stream('Summarise this')) { ... }
// Token-delta mode — 'content_delta' events fire as tokens arrive
for await (const ev of agent.stream('Summarise this', { streamTokens: true })) {
if (ev.type === 'content_delta') process.stdout.write(ev.data.delta.text ?? '');
}The two modes are independent of step events — tool calls, tool results, and step boundaries are emitted in both modes.
Stopping the loop
Break out of the iterator — immediate, no further events consumed:
for await (const ev of agent.stream('Long task')) {
if (shouldStop) break; // loop exits; history unchanged
}AbortSignal — cancel from a different scope (e.g. HTTP request timeout);
the loop exits cleanly at the next step boundary:
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000); // 30-second deadline
for await (const ev of agent.stream('Long task', { signal: controller.signal })) {
// ...
}StreamOptions
interface StreamOptions {
streamTokens?: boolean; // false (default) = complete-message mode
// true = token-delta mode
signal?: AbortSignal; // Cancel the loop from another scope
}History and auto-save
Conversation history and auto-save behave identically to chat(): both the
user turn and the final assistant turn are appended to history only when the
complete event is received. If you break before complete, history is
unchanged.
Full Loop History
By default chat() returns only the final assistant message.
Set includeFullHistory: true to get every message that passed through the
orchestrator — system prompt, user turn, every intermediate assistant message,
every tool call, and every tool result:
const agent = await MajkChat.create({
provider: 'anthropic',
includeFullHistory: true,
});
const { text, completeMessages } = await agent.chat('What files are in src/?');
// completeMessages is Message[] in OpenAI wire format:
// [
// { role: 'system', content: '...' },
// { role: 'user', content: 'What files are in src/?' },
// { role: 'assistant', content: null, tool_calls: [{ id: 'tc-1', ... }] },
// { role: 'tool', content: '["index.ts","types.ts"]', tool_call_id: 'tc-1' },
// { role: 'assistant', content: 'The files in src/ are ...' },
// ]
console.log(completeMessages);The format is normalised to OpenAI message objects regardless of which provider is used — every provider adapter maps its native wire format to this shape before the orchestrator processes messages.
stream() always returns full history in the complete event's
response.complete_messages field without requiring this flag.
Lifecycle Extensions
Extensions add hooks that run at every stage of the orchestrator loop.
Pass an array of OrchestratorExtension objects to MajkChat.create():
import { MajkChat } from '@majkapp/majk-chat-common';
import type { OrchestratorExtension, ExecutionContext } from '@majkapp/majk-chat-common';
const auditExtension: OrchestratorExtension = {
name: 'audit-log',
priority: 10, // Lower number = runs earlier when multiple extensions registered
async beforeStep(ctx: ExecutionContext): Promise<ExecutionContext> {
console.log(`[audit] step ${ctx.step} starting`);
return ctx;
},
async afterChatCompletion(
ctx: ExecutionContext,
response: any
): Promise<ExecutionContext> {
console.log(`[audit] LLM responded at step ${ctx.step}`);
return ctx;
},
async afterToolExecution(
ctx: ExecutionContext,
toolCall: any,
result: string
): Promise<ExecutionContext> {
await db.audit.insert({ step: ctx.step, tool: toolCall.function.name, result });
return ctx;
},
};
const agent = await MajkChat.create({
provider: 'anthropic',
extensions: [auditExtension],
});All 8 hooks
| Hook | Called | Arguments beyond ctx |
|---|---|---|
| beforeExecution | Once before the entire loop starts | — |
| beforeStep | Before each iteration | — |
| beforeChatCompletion | Before each LLM API call | request |
| afterChatCompletion | After each LLM API call | response |
| beforeToolExecution | Before each tool is run | toolCall |
| afterToolExecution | After each tool finishes | toolCall, result |
| afterStep | After each iteration completes | — |
| afterExecution | Once after the entire loop finishes | response |
Every hook receives an ExecutionContext (carrying step, messages,
request, and arbitrary metadata) and must return it (optionally mutated).
Mutations accumulate — later hooks see changes made by earlier ones.
Common patterns
// Token budget guard — abort if too many steps
const stepGuard: OrchestratorExtension = {
name: 'step-guard',
priority: 1,
async beforeStep(ctx) {
if (ctx.step >= 5) throw new Error('Step limit exceeded');
return ctx;
},
};
// Request mutation — inject dynamic context into every LLM call
const contextInjector: OrchestratorExtension = {
name: 'context-injector',
priority: 5,
async beforeChatCompletion(ctx, request) {
request.messages = [
...request.messages,
{ role: 'system', content: `Current user: ${getCurrentUser()}` },
];
return ctx;
},
};
// Multiple extensions — all run in priority order
const agent = await MajkChat.create({
provider: 'anthropic',
extensions: [stepGuard, contextInjector, auditExtension],
});Full Config Reference
interface MajkChatConfig {
// ── Required ────────────────────────────────────────────────────────────────
provider: 'anthropic' | 'openai' | 'azure-openai' | 'bedrock' | 'anthropic-bedrock';
// ── Credentials ─────────────────────────────────────────────────────────────
apiKey?: string; // Explicit key — highest priority (see Credential Resolution)
model?: string; // Defaults: anthropic→'claude-sonnet-4-5', openai→'gpt-4o'
// ── Agent identity ───────────────────────────────────────────────────────────
systemPrompt?: string; // Replaces default system prompt entirely
appendSystemPrompt?: string; // Appended to default (or custom) system prompt
// ── Tools ───────────────────────────────────────────────────────────────────
tools?: ToolPreset // 'read-only' (default)
| Array<ToolCatalogEntry | FunctionToolDef | ToolExecutor>;
// ── MCP ─────────────────────────────────────────────────────────────────────
mcp?: {
configPath?: string; // Path to JSON config file
configString?: string; // Inline JSON string
servers?: Record<string, { command: string; args?: string[]; env?: Record<string, string> }>;
};
// ── Sessions ─────────────────────────────────────────────────────────────────
sessions?: 'memory'
| { type: 'filesystem'; path: string }
| SessionStore; // Custom (Redis, DynamoDB, etc.)
sessionId?: string; // Resume existing or name new session
autoSave?: boolean; // Default: true when sessions is set
// ── Context management ───────────────────────────────────────────────────────
contextManagement?: {
enabled?: boolean; // Default: true
maxTotalChars?: number; // Default: 180,000
maxToolResultLength?: number; // Default: 6,000
minMessages?: number; // Default: 3
};
// ── Execution ────────────────────────────────────────────────────────────────
maxSteps?: number; // Max tool-use iterations per chat(). Default: 10
// ── Streaming & history ──────────────────────────────────────────────────────
includeFullHistory?: boolean; // Populate completeMessages in chat() response. Default: false
// ── Lifecycle extensions ─────────────────────────────────────────────────────
extensions?: OrchestratorExtension[]; // Custom hooks run at each loop stage
// ── Callbacks ────────────────────────────────────────────────────────────────
onToolCall?: (toolName: string, args: Record<string, unknown>) => void | Promise<void>;
onToolResult?: (toolName: string, result: string, success: boolean) => void | Promise<void>;
// ── Token tracking ───────────────────────────────────────────────────────────
tokenTracker?: TokenTracker | ((event: TokenUsageEvent) => void | Promise<void>);
pricing?: {
perMillionInputTokens?: number;
perMillionOutputTokens?: number;
perMillionCacheReadTokens?: number;
perMillionCacheWriteTokens?: number;
models?: Record<string, { perMillionInputTokens?: number; perMillionOutputTokens?: number }>;
};
}MajkChatResponse
interface MajkChatResponse {
text: string; // Final assistant text
stepsTaken: number; // Number of tool-use iterations
toolsUsed: string[]; // Tool names called this turn
tokenUsage?: TokenUsageEvent; // Token counts (undefined if provider omits them)
completeMessages?: Message[]; // Full wire-level history (set when includeFullHistory: true)
}MajkChat instance methods
| Method | Description |
|--------|-------------|
| chat(message) | Send a message; returns MajkChatResponse |
| stream(message, opts?) | Yield StreamEvent objects in real time; see Streaming |
| getHistory() | Current conversation as Message[] |
| clearHistory() | Reset in-memory history (does not delete persisted session) |
| getSessionId() | Active session ID, if any |
| saveSession(id?) | Explicitly persist; returns session ID used |
| loadSession(id) | Replace history from a stored session |
| listSessions() | All sessions in the configured store |
| getTokenStats() | Aggregate TokenStats across all chat() calls |
| resetTokenStats() | Zero out aggregate counters |
| destroy() | Tear down MCP connections and cleanup |
License
MIT
