Contributing¶
Tepa is an open-source monorepo. Contributions are welcome — whether you're publishing a community tool or provider package, fixing a bug in the core pipeline, improving documentation, or opening an issue.
Tepa's core repository is intentionally lean. It ships only the essential built-in tools and LLM providers that the majority of users need. Rather than growing the monorepo with every possible integration, the community is encouraged to build and publish tools and providers as independent npm packages. This keeps the core lightweight and fosters a wider ecosystem where anyone can contribute without needing to modify this repository.
Where to Start¶
| What you want to do | Where to go |
|---|---|
| Publish a custom tool as an npm package | How to Create a Custom Tool |
| Publish a custom LLM provider as an npm package | How to Create a Custom LLM Provider |
| Fix a bug, improve the core, update docs | Development Setup → Pull Request Guidelines |
| Report a bug or request a feature | Issue Reporting |
How to Create a Custom Tool¶
The recommended way to extend Tepa's tool set is to publish a standalone npm package. A tool is just an object satisfying the ToolDefinition interface from @tepa/types — no dependency on the core repo is needed.
For the conceptual overview and quick-start, see Tool System. This section is the complete scaffolding walkthrough.
1. Scaffold Your Package¶
mkdir tepa-tool-redis-cache
cd tepa-tool-redis-cache
npm init -y
npm install @tepa/types @tepa/tools
npm install -D typescript tsup vitest
You need @tepa/types for the ToolDefinition interface and @tepa/tools for the defineTool helper that validates your schema at creation time.
Recommended project structure:
tepa-tool-redis-cache/
├── src/
│ └── index.ts # Tool definition(s)
├── tests/
│ └── index.test.ts
├── package.json
├── tsconfig.json
└── tsup.config.ts
2. Define the Tool¶
Create src/index.ts. Write descriptions that tell the LLM exactly what the tool does, what inputs to provide, and what to expect back — the quality of your descriptions directly affects how well the Planner assigns your tool and how reliably the Executor calls it. See Writing Good Tool Descriptions for guidance.
import { defineTool } from "@tepa/tools";
export const redisCacheTool = defineTool({
name: "redis_cache",
description:
"Read and write string values in a Redis cache by key. " +
"Use 'get' to retrieve a cached value and 'set' to store one with an optional TTL. " +
"Returns the stored value on get, or a confirmation on set.",
parameters: {
operation: {
type: "string",
description: "The cache operation to perform: 'get' to read a value, 'set' to write one.",
required: true,
},
key: {
type: "string",
description:
"The cache key to read from or write to. Use descriptive keys like 'user:123:profile'.",
required: true,
},
value: {
type: "string",
description: "The value to store. Required when operation is 'set'. Omit for 'get'.",
},
ttl: {
type: "number",
description:
"Time-to-live in seconds before the key expires. Only applies to 'set'. Default: 3600.",
default: 3600,
},
},
execute: async (params) => {
const operation = params.operation as string;
const key = params.key as string;
const value = params.value as string | undefined;
const ttl = (params.ttl as number) ?? 3600;
// Implement Redis logic here using your preferred client
if (operation === "set") {
await redis.set(key, value, "EX", ttl);
return { status: "ok", key };
}
const result = await redis.get(key);
return { key, value: result };
},
});
Key rules:
- Tool names use
snake_case - Parameter types:
"string","number","boolean","object","array" executereceivesRecord<string, unknown>— cast parameters to their expected typesexecutemust return a serializable value — it becomes part of the pipeline's execution resultdefineToolvalidates the schema at creation time using Zod; errors surface immediately
3. Configure Build¶
tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
tsup.config.ts:
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
});
4. Write Tests¶
import { describe, it, expect, vi } from "vitest";
import { redisCacheTool } from "../src/index.js";
describe("redisCacheTool", () => {
it("has correct metadata", () => {
expect(redisCacheTool.name).toBe("redis_cache");
expect(redisCacheTool.parameters.key.required).toBe(true);
expect(redisCacheTool.parameters.operation.required).toBe(true);
});
it("executes a get operation", async () => {
// Mock your Redis client before testing execute()
const result = await redisCacheTool.execute({
operation: "get",
key: "test-key",
});
expect(result).toBeDefined();
});
});
5. Publish to npm¶
6. Use It with Tepa¶
import { Tepa } from "@tepa/core";
import { AnthropicProvider } from "@tepa/provider-anthropic";
import { fileReadTool, fileWriteTool } from "@tepa/tools";
import { redisCacheTool } from "tepa-tool-redis-cache";
const tepa = new Tepa({
provider: new AnthropicProvider(),
tools: [fileReadTool, fileWriteTool, redisCacheTool],
});
Any ToolDefinition works — Tepa doesn't distinguish between built-in and community tools. The Planner sees your tool in its schema list and assigns it to steps; the Executor invokes it identically to any built-in.
How to Create a Custom LLM Provider¶
Like tools, the recommended way to add LLM provider support is to publish a standalone npm package. Every provider extends BaseLLMProvider from @tepa/provider-core, which gives you retry logic, exponential backoff, rate limit handling, and the full logging system for free.
For the interface contract and key implementation notes, see LLM Providers. This section is the complete scaffolding walkthrough.
1. Scaffold Your Package¶
mkdir tepa-provider-myllm
cd tepa-provider-myllm
npm init -y
npm install @tepa/types @tepa/provider-core myllm-sdk
npm install -D typescript tsup vitest
Recommended project structure:
tepa-provider-myllm/
├── src/
│ ├── myllm.ts # Provider class extending BaseLLMProvider
│ ├── formatting.ts # Message/tool format conversion helpers
│ ├── factory.ts # createProvider() factory function
│ └── index.ts # Public exports
├── tests/
│ └── myllm.test.ts
├── package.json
├── tsconfig.json
└── tsup.config.ts
2. Implement the Provider¶
Extend BaseLLMProvider and implement four methods. You only implement the API call — the framework handles retrying it:
import { BaseLLMProvider, type BaseLLMProviderOptions } from "@tepa/provider-core";
import type { LLMMessage, LLMRequestOptions, LLMResponse } from "@tepa/types";
import {
toMyLLMMessages,
toMyLLMTools,
extractText,
extractToolUse,
mapFinishReason,
} from "./formatting.js";
export interface MyLLMProviderOptions extends BaseLLMProviderOptions {
apiKey?: string;
}
export class MyLLMProvider extends BaseLLMProvider {
protected readonly providerName = "myllm";
private readonly client: MyLLMClient;
constructor(options: MyLLMProviderOptions = {}) {
super(options);
this.client = new MyLLMClient({
apiKey: options.apiKey ?? process.env.MYLLM_API_KEY,
});
}
// Core method — make the API call and return a normalised LLMResponse
protected async doComplete(
messages: LLMMessage[],
options: LLMRequestOptions,
): Promise<LLMResponse> {
const response = await this.client.chat({
model: options.model ?? "myllm-default",
messages: toMyLLMMessages(messages),
tools: options.tools ? toMyLLMTools(options.tools) : undefined,
system: options.systemPrompt,
max_tokens: options.maxTokens,
});
return {
text: extractText(response),
tokensUsed: {
input: response.usage.inputTokens,
output: response.usage.outputTokens,
},
finishReason: mapFinishReason(response.stopReason),
...(hasToolCalls(response) && {
toolUse: extractToolUse(response),
}),
};
}
// Return true for transient errors worth retrying (500s, network errors)
protected isRetryable(error: unknown): boolean {
return error instanceof MyLLMServerError || error instanceof MyLLMConnectionError;
}
// Return true specifically for rate limit errors — gets 30x longer backoff
protected isRateLimitError(error: unknown): boolean {
return error instanceof MyLLMRateLimitError;
}
// Extract Retry-After header value in ms, or return null if not present
protected getRetryAfterMs(error: unknown): number | null {
if (error instanceof MyLLMAPIError) {
const retryAfter = error.headers?.["retry-after"];
if (retryAfter) {
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1000;
}
}
return null;
}
}
3. Add Format Conversion Helpers¶
Create src/formatting.ts to translate between Tepa's normalised types and your provider's API format. Five helpers are needed:
toMyLLMMessages(messages: LLMMessage[]) — Convert Tepa's { role, content } pairs to your SDK's message format. Map "assistant" to whatever your SDK calls the model role.
toMyLLMTools(tools: ToolSchema[]) — Convert Tepa tool schemas to your provider's function/tool format. Tepa's schema uses lowercase types ("string", "object") — some SDKs expect uppercase ("STRING", "OBJECT"). See LLM Providers — Schema Conversion by Provider for the exact format each built-in provider produces — use the closest one as a reference for your own.
extractText(response) — Pull the text content from your SDK's response object.
extractToolUse(response) — Convert tool call blocks from your SDK's format into LLMToolUseBlock[]:
// Target shape:
interface LLMToolUseBlock {
id: string; // Use a synthetic ID if your SDK doesn't provide one: "myllm-call-0"
name: string; // Tool name the LLM wants to call
input: Record<string, unknown>; // Pre-parsed parameters — not a JSON string
}
mapFinishReason(reason) — Map your SDK's stop reason to Tepa's standard enum:
// Valid values: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence"
function mapFinishReason(reason: string): LLMResponse["finishReason"] {
if (reason === "MAX_TOKENS") return "max_tokens";
if (hasToolCalls) return "tool_use"; // Some SDKs don't set a dedicated reason
return "end_turn";
}
Note: the standard values are "end_turn", "tool_use", "max_tokens", and "stop_sequence" — not "stop". Using "stop" will cause the Executor to mishandle the finish reason.
4. Export a Factory¶
Create src/factory.ts:
import { MyLLMProvider, type MyLLMProviderOptions } from "./myllm.js";
export function createProvider(identifier: string, options?: MyLLMProviderOptions) {
if (identifier !== "myllm") {
throw new Error(`Unknown provider: ${identifier}`);
}
return new MyLLMProvider(options);
}
5. Wire Up Exports¶
src/index.ts:
export { MyLLMProvider, type MyLLMProviderOptions } from "./myllm.js";
export { createProvider } from "./factory.js";
6. Write Tests¶
import { describe, it, expect, vi } from "vitest";
import { MyLLMProvider } from "../src/index.js";
describe("MyLLMProvider", () => {
it("returns a normalised response", async () => {
// Mock your SDK client
const provider = new MyLLMProvider({ apiKey: "test-key" });
const response = await provider.complete([{ role: "user", content: "Hello" }], {
model: "myllm-default",
});
expect(response.finishReason).toBe("end_turn");
expect(response.tokensUsed.input).toBeGreaterThan(0);
});
it("maps tool use finish reason correctly", async () => {
// Mock a tool-use response from the SDK
const provider = new MyLLMProvider({ apiKey: "test-key" });
const response = await provider.complete([{ role: "user", content: "Call a tool" }], {
model: "myllm-default",
tools: [
/* mock schema */
],
});
expect(response.finishReason).toBe("tool_use");
expect(response.toolUse).toBeDefined();
expect(response.toolUse?.length).toBeGreaterThan(0);
});
});
7. Publish and Use¶
import { Tepa } from "@tepa/core";
import { MyLLMProvider } from "tepa-provider-myllm";
import { fileReadTool, shellExecuteTool } from "@tepa/tools";
const tepa = new Tepa({
provider: new MyLLMProvider({ apiKey: process.env.MYLLM_API_KEY }),
tools: [fileReadTool, shellExecuteTool],
});
Any class extending BaseLLMProvider or directly implementing LLMProvider works — Tepa treats community providers identically to its own built-ins.
Development Setup¶
This section is for contributors working on the core pipeline, built-in tools, built-in providers, documentation, or bug fixes. If you're publishing an external package, you don't need this — see the sections above.
Prerequisites¶
- Node.js >= 18
- npm >= 9 (ships with Node 18+)
- Git
Fork and Clone¶
- Fork the repository on GitHub — click "Fork" on the repo page
- Clone your fork and set up the upstream remote:
git clone https://github.com/<your-username>/tepa-ai.git
cd tepa-ai
git remote add upstream https://github.com/frandi/tepa-ai.git
npm install
npm install at the root installs dependencies for all packages via npm workspaces. No separate install steps are needed for individual packages.
Before starting work, sync with the latest upstream:
Build¶
# Build all packages (respects dependency order)
npm run build
# Smart build — only rebuilds changed packages and their dependents
./build.sh
# Force rebuild everything
./build.sh --all
Each package uses tsup to produce dual ESM/CJS output with TypeScript declarations. The smart build script detects which packages changed since your last commit and rebuilds only those, plus any downstream dependents.
Test¶
# Run all tests once
npm test
# Watch mode
npm run test:watch
# Run tests for a specific package
npm test -- packages/tools
npm test -- packages/tepa
Tests use Vitest with a workspace configuration covering @tepa/core, @tepa/tools, @tepa/provider-core, @tepa/provider-anthropic, and @tepa/provider-openai.
Lint and Format¶
Monorepo Structure¶
tepa-ai/
├── packages/
│ ├── types/ # @tepa/types — shared interfaces, zero deps
│ ├── tepa/ # @tepa/core — pipeline orchestrator
│ ├── tools/ # @tepa/tools — built-in tool kit
│ ├── provider-core/ # @tepa/provider-core — base provider + logging
│ ├── provider-anthropic/ # @tepa/provider-anthropic
│ ├── provider-openai/ # @tepa/provider-openai
│ └── provider-gemini/ # @tepa/provider-gemini
├── demos/
│ ├── api-client-gen/ # Autonomous code generation demo
│ ├── student-progress/ # Data analysis demo
│ └── study-plan/ # Human-in-the-loop demo
├── build.sh # Smart incremental build script
├── vitest.workspace.ts # Test workspace config
├── tsconfig.base.json # Shared TypeScript config
└── package.json # Root workspace config
Dependency flow:
@tepa/types (zero deps)
↑
├── @tepa/core
├── @tepa/tools
└── @tepa/provider-core
↑
├── @tepa/provider-anthropic
├── @tepa/provider-openai
└── @tepa/provider-gemini
@tepa/types is the foundation. Core, tools, and provider-core are siblings — none depends on the others, only on @tepa/types. This is why you can swap providers and tools without touching the core.
Code Conventions¶
Tepa uses strict TypeScript with ESLint and Prettier enforcement.
TypeScript:
- Target: ES2022, module resolution:
bundler strict: true— no implicit any, strict null checksnoUncheckedIndexedAccess: true— array/object index access returnsT | undefinednoUnusedLocalsandnoUnusedParametersenabled- Use
.jsextensions in import paths (required for ESM resolution)
Style:
- 2-space indentation
- Double quotes (
") - Semicolons required
- Trailing commas in multiline structures
- 100-character line width
- No
anytypes — use proper interfaces from@tepa/types - Prefix intentionally unused parameters with
_(e.g.,_context)
Run npm run lint and npm run format before committing. Both are enforced by the ESLint and Prettier configs at the repo root.
Pull Request Guidelines¶
PRs to this repository should focus on the core pipeline, existing built-in tools and providers, documentation, and bug fixes. For new tools and providers, publish them as external packages — submit a PR here only if you believe the addition is broadly essential to the community.
- Fork the repo and clone your fork (see Fork and Clone above)
- Create a branch from
mainwith a descriptive name:fix/executor-timeout,feat/evaluator-confidence-threshold,docs/event-patterns - One concern per PR — a bug fix, a core enhancement, and a docs update should be separate PRs
- Write tests — bug fixes should include a regression test when practical; pipeline changes need unit tests
- Run the full check before pushing:
- Push and open a PR against
mainon the upstream repo: - Write a clear commit message — summarise the what and why, use the imperative mood:
- Keep the PR description concise — what changed, why, how to test it, linked issues if applicable
See Pull Request Example for a filled-in template.
Issue Reporting¶
When filing a bug report, include:
- What you expected vs. what happened
- Minimal reproduction — a prompt file + entry script that triggers the issue, or a failing test
- Environment — Node.js version, package versions (
npm ls @tepa/core), OS - Logs — set
logging.levelto"verbose"in your config and include the relevant output; if the issue involves LLM responses, check.tepa/logs/for the JSONL request/response log
For feature requests, describe the use case first, then the proposed solution. This helps the maintainers understand whether the feature fits the framework's scope before implementation detail is discussed.
See filled-in examples: