Writing custom tools
A custom tool is a function in your process that the model can call. This is the mechanism behind everything else in this section: connecting a browser agent, reaching an internal API, querying your database, or delegating to another agent are all the same primitive with different bodies.
The execution model#
When the model decides to call your tool, Coresource sends the call over the bridge (the connection your SDK process holds open to the platform), runs your handler in your process, and returns the value to the run.
Credentials used internally by the handler can remain in your process. The agent can query your production database because your handler holds the connection, not because the agent definition contains a database password. The tool metadata, arguments, and returned value cross the bridge, so do not include secrets in those values.
The trade-off is that a run using your tools depends on your process being alive to service the calls. The run itself stays durable, but it cannot make progress through a tool whose handler is unreachable.
Anatomy#
import { createAgent } from '@coresourceai/sdk';
const agent = createAgent({
type: 'agent',
tools: [
{
name: 'lookup_invoice',
description: 'Look up an invoice by ID. Returns vendor, amount, and status.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Invoice ID, e.g. INV-123' }
},
required: ['id'],
additionalProperties: false
},
annotations: { title: 'Look up invoice', readOnlyHint: true },
handler: async (input, context) => {
context.signal.throwIfAborted();
return lookupInvoice(String(input.id));
}
}
]
});
await agent.close();
declare function lookupInvoice(id: string): Promise<unknown>;| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$ |
handler | function | Yes | Receives (input, context) |
description | string | No | The model reads this |
inputSchema | JSON Schema | No | The model reads this |
outputSchema | JSON Schema | No | Describes the return shape |
annotations | object | No | Hints: title, readOnlyHint, destructiveHint, idempotentHint |
Schemas are prompt engineering#
description and inputSchema are the model-visible specification for the tool. Make them precise enough that the model can select the tool and form its arguments correctly.
import type { AgentTool } from '@coresourceai/sdk';
// Vague: the model has to guess formats, and will guess wrong.
const weak: AgentTool = {
name: 'search_orders',
description: 'Search orders.',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' }, limit: { type: 'number' } }
},
handler: () => []
};
// Specific: formats, bounds, and enumerated values are stated.
const strong: AgentTool = {
name: 'search_orders',
description:
'Search customer orders by date range and status. Returns at most `limit` ' +
'orders, newest first. Use this instead of guessing order details.',
inputSchema: {
type: 'object',
properties: {
from: { type: 'string', format: 'date', description: 'Inclusive start, YYYY-MM-DD' },
to: { type: 'string', format: 'date', description: 'Inclusive end, YYYY-MM-DD' },
status: {
type: 'string',
enum: ['pending', 'shipped', 'cancelled'],
description: 'Omit to include every status'
},
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
},
required: ['from', 'to'],
additionalProperties: false
},
handler: () => []
};Three rules that account for most of the difference:
- Set
additionalProperties: false. It tells the model that fields outside the declared schema are not accepted. - Use
enumfor closed sets. Put the allowed values in the schema instead of relying only on prose. - Say when to use the tool in the
description, not just what it does. That sentence is what stops the model answering from memory instead of calling it.
Validate the input anyway#
input is typed Readonly<Record<string, unknown>> because the handler must not rely on the model-visible schema as its only validation boundary. Validate every value before using it.
import type { AgentTool } from '@coresourceai/sdk';
const tool: AgentTool = {
name: 'search_orders',
description: 'Search orders by date range.',
inputSchema: {
type: 'object',
properties: {
from: { type: 'string' },
limit: { type: 'integer', minimum: 1, maximum: 100 }
},
required: ['from'],
additionalProperties: false
},
handler: async (input) => {
const from = typeof input.from === 'string' ? input.from : null;
if (!from || !/^\d{4}-\d{2}-\d{2}$/.test(from)) {
// A structured refusal the model can read and correct.
return { error: 'invalid_from', message: 'Expected a date as YYYY-MM-DD.' };
}
const rawLimit = typeof input.limit === 'number' ? input.limit : 20;
const limit = Math.min(Math.max(Math.trunc(rawLimit), 1), 100);
return searchOrders({ from, limit });
}
};
declare function searchOrders(options: { from: string; limit: number }): Promise<unknown>;Never interpolate input into SQL, a shell command, or a file path without validating it. The values originate from a model that read your data; treat them as untrusted.
The context argument#
import type { AgentToolContext } from '@coresourceai/sdk';
function inspect(context: AgentToolContext) {
return {
callId: context.callId, // unique per call - good log correlation key
signal: context.signal, // aborts when the run is cancelled
deadlineAt: context.deadlineAt // epoch ms; work past this is wasted
};
}Honour the signal#
Pass context.signal into everything downstream. A tool that ignores it keeps working after the run is cancelled. Burning your resources on a result nobody will read.
import type { AgentTool } from '@coresourceai/sdk';
const tool: AgentTool = {
name: 'fetch_report',
description: 'Fetch a report from the internal reporting service.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
additionalProperties: false
},
handler: async (input, { signal }) => {
signal.throwIfAborted();
const response = await fetch(`https://reports.internal/${String(input.id)}`, {
signal
});
return response.json();
}
};Respect the deadline#
deadlineAt is an epoch-millisecond timestamp. Starting a two-minute operation thirty seconds before it is pointless.
import type { AgentTool } from '@coresourceai/sdk';
const tool: AgentTool = {
name: 'run_analysis',
description: 'Run the full statistical analysis. Takes about a minute.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
handler: async (_input, { signal, deadlineAt }) => {
const remainingMs = deadlineAt - Date.now();
if (remainingMs < 60_000) {
return {
error: 'insufficient_time',
message: `Needs about 60s, ${Math.round(remainingMs / 1000)}s left. Try a narrower query.`
};
}
return analyse({ signal });
}
};
declare function analyse(options: { signal: AbortSignal }): Promise<unknown>;Note the shape of the refusal: it tells the model what to do instead. A bare error string leaves it to retry the same call.
Returning errors versus throwing#
This distinction determines whether the agent can recover.
Return a structured error for anything the model could reasonably work around, not found, invalid input, insufficient time. The agent reads it and adapts.
Throw for genuine faults: your database is down, a required credential is missing. These are not the model's problem to solve.
import type { AgentTool } from '@coresourceai/sdk';
const tool: AgentTool = {
name: 'get_customer',
description: 'Fetch a customer record by ID.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
additionalProperties: false
},
handler: async (input) => {
const customer = await findCustomer(String(input.id));
if (!customer) {
// Recoverable: the agent can try a different id or ask the user.
return { error: 'not_found', message: `No customer ${String(input.id)}.` };
}
return customer;
}
};
declare function findCustomer(id: string): Promise<unknown>;Handler results cross the SDK bridge as JSON. A return value must be JSON serializable, and the complete tool-result frame must not exceed 1 MiB. Return a compact reference, such as an object id or URL, when the underlying result is larger.
Annotations#
Annotations are metadata hints describing a tool's behaviour. They do not replace authorization, confirmation, or idempotency controls in the handler.
| Annotation | Meaning |
|---|---|
title | Human-readable name for traces and UI |
readOnlyHint | The tool does not modify anything |
destructiveHint | The tool can destroy or overwrite data |
idempotentHint | Calling twice with the same input is safe |
import type { AgentTool } from '@coresourceai/sdk';
const deleteExport: AgentTool = {
name: 'delete_export',
description: 'Permanently delete a generated export file.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
additionalProperties: false
},
annotations: {
title: 'Delete export',
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true
},
handler: (input) => deleteExportById(String(input.id))
};
declare function deleteExportById(id: string): Promise<unknown>;Mark destructive tools honestly so runtimes and interfaces can distinguish them from read-only calls. Do not assume the annotation itself blocks or confirms an operation.
Keeping the tool set small#
maxTools caps how many are exposed:
import { createAgent } from '@coresourceai/sdk';
const agent = createAgent({
type: 'agent',
systemPrompt: 'Answer questions about orders.',
maxTools: 6,
tools: [/* ... */]
});
await agent.close();Use toolAllowlist and maxTools to keep the exposed catalog appropriate for the task. The SDK does not prescribe a universal tool count.
Testing handlers#
Handlers are ordinary functions. Test them without the SDK.
import type { AgentTool, AgentToolContext } from '@coresourceai/sdk';
function testContext(overrides: Partial<AgentToolContext> = {}): AgentToolContext {
return {
callId: 'test-call',
signal: new AbortController().signal,
deadlineAt: Date.now() + 300_000,
...overrides
};
}
export async function callTool(tool: AgentTool, input: Record<string, unknown>) {
return tool.handler(input, testContext());
}Worth covering: a malformed input the schema should have prevented, an already-aborted signal, and a deadline that has passed.
Checklist#
- Names match
^[A-Za-z][A-Za-z0-9_-]{0,63}$and are unique. additionalProperties: falseon every object schema.- The description says when to use the tool.
- Input is validated in the handler, not trusted from the schema.
context.signalis passed to everything downstream.deadlineAtis checked before long work.- Recoverable problems return; genuine faults throw.
- Destructive tools carry
destructiveHint.
