Custom Tools
To add a capability Protege doesn't ship with, create a custom tool.
Execution Flow
You implement tool.execute(...). You do not implement context.runtime.invoke(...) — that's provided by the harness at runtime and routes actions to gateway/runtime handlers.
LLM decides to call tool
|
v
Harness tool registry resolves tool by name
|
v
tool.execute({ input, context })
|
+--> (optional) context.runtime.invoke({ action, payload })
|
v
Gateway/runtime action handler
(file.read, file.write, email.send, web.fetch, shell.exec, ...)
|
v
Action result (Record<string, unknown>)
|
v
tool.execute returns result to harness
|
v
Harness passes tool result back to LLMExample: GitHub Issue Creator
1. Create the directory
extensions/tools/github-issue/
├── index.ts
├── config.json
└── README.md2. Implement the tool contract
Your index.ts must export a tool object matching the HarnessToolDefinition type. This primary example calls GitHub directly and returns the final tool result object:
import type {
HarnessToolDefinition,
HarnessToolExecutionContext,
} from 'protege-toolkit';
import { request } from 'node:https';
export const tool: HarnessToolDefinition = {
name: 'create_github_issue',
description: 'Create a new GitHub issue in the specified repository.',
inputSchema: {
type: 'object',
required: ['repo', 'title', 'body'],
additionalProperties: false,
properties: {
repo: { type: 'string', description: 'Repository in owner/name format' },
title: { type: 'string', description: 'Issue title' },
body: { type: 'string', description: 'Issue body in markdown' },
labels: { type: 'array', items: { type: 'string' } },
},
},
execute: async (args: {
input: Record<string, unknown>;
context: HarnessToolExecutionContext;
}): Promise<Record<string, unknown>> => {
const repo = String(args.input.repo);
const title = String(args.input.title);
const body = String(args.input.body);
const labels = Array(args.input.labels)
// Secrets get added to process.env[ENV_VAR_NAME]
const githubToken = process.env.GITHUB_TOKEN;
if (!githubToken || githubToken.trim().length === 0) {
throw new Error('Missing GITHUB_TOKEN. Set it in .secrets or your shell env.');
}
args.context.logger?.info({
event: 'tool.github_issue.create.started',
context: {
repo,
title,
labelCount: labels.length,
},
});
const issue = await createGithubIssue({
repo,
title,
body,
labels,
token: githubToken,
});
args.context.logger?.info({
event: 'tool.github_issue.create.completed',
context: {
repo,
issueId: issue.id,
},
});
return {
ok: true,
repo,
issueId: issue.id,
url: issue.html_url,
};
},
};
/**
* Creates one GitHub issue via REST API.
*/
async function createGithubIssue(
args: {
repo: string;
title: string;
body: string;
labels: string[];
token: string;
},
): Promise<{
id: number;
html_url: string;
}> {
/**
* Call the github API...
*/
return {
id,
html_url
}
}2.1 Optional: delegate to runtime actions with context.runtime.invoke(...)
Use runtime.invoke(...) when you want to reuse existing runtime capabilities (email/file/shell/web actions) instead of calling external APIs directly inside the tool.
const result = await args.context.runtime.invoke({
action: 'file.write',
payload: {
path: '/tmp/issue-summary.txt',
content: `Created issue ${issue.id}: ${issue.html_url}`,
},
});runtime.invoke(...) only works for actions your runtime/gateway actually implements.
3. Add default configuration
config.json:
{
"defaultLabels": ["agent-created"],
"apiTokenEnv": "GITHUB_TOKEN"
}4. Register in the manifest
{
"tools": [
"send-email",
{
"name": "github-issue",
"config": {
"defaultLabels": ["agent-created", "needs-triage"]
}
}
]
}Key Points
- Input validation — always validate and type-narrow
args.inputbefore using it. The LLM may send unexpected shapes. - Secrets access — read API keys from
process.env(for exampleprocess.env.GITHUB_TOKEN). In Protege workflows these are typically loaded from.secrets/.secrets.localor exported in the shell environment. - Runtime actions — use
args.context.runtime.invoke()only for implemented runtime actions. For custom external APIs, call them directly inside your tool unless you also add a runtime action handler. - Tool logging — tool-call start/completion is logged by harness automatically. Use
args.context.logger?.info/error(...)only for tool-specific internal milestones you want surfaced. - Deterministic output — return a consistent JSON shape so the LLM can reliably parse results.
- Error handling — if your tool throws, the error is wrapped as a structured
{ ok: false, error: ... }result and fed back to the LLM. The model gets a chance to retry or adjust. Only certain errors (like "tool not found") are terminal.
Tool Return Type
Every tool returns Promise<Record<string, unknown>>. This object is fed back to the model as tool result context.
Recommended pattern:
- Return a stable object shape on success (for example
{ ok: true, issueId, url }). - Include machine-readable fields the model can use in the next step.
- Throw for hard failures (missing required auth, invalid input after validation, runtime-action failure) so the harness can pass structured error context back to the model.
The Tool Contract
For reference, here's the full type interface:
type HarnessToolDefinition = {
name: string; // snake_case name the LLM sees
description: string; // Natural language description for the LLM
inputSchema: Record<string, unknown>; // JSON Schema for input validation
execute: (args: {
input: Record<string, unknown>; // Parsed input from the LLM
context: HarnessToolExecutionContext; // Runtime action invoker + logger
}) => Promise<Record<string, unknown>>; // Result fed back to the LLM
};
type HarnessToolExecutionContext = {
runtime: {
invoke: (args: {
action: string; // e.g., "email.send", "file.read"
payload: Record<string, unknown>;
}) => Promise<Record<string, unknown>>;
};
logger?: GatewayLogger;
};
