Tools

Adding a tool

A walkthrough of how to extend the catalog with a new tool. Covers both paths: a provider module under src/lib/tools/providers/ for any new tool, and the legacy native catalog at src/lib/chat-tools.ts when the tool needs to share helpers with the existing surface. Includes naming, ToolContext use, schema shape, status labels, connection gates, testing, and the rules of thumb that keep new tools usable for the model.

Updated today

Overview

At a glance
Preferred path
Provider module
Module folder
src/lib/tools/providers/
Module type
ToolModule in src/lib/tools/types.ts
Native catalog
src/lib/chat-tools.ts
Skill allow-list
Add the new tool name to skill manifests in src/lib/skills/registry.ts
Activity feed
Status label drives the live pill in the sidebar
Typecheck
npx tsc --noEmit -p tsconfig.json

Adding a tool is the most common kind of change once the engine is stable. The contract is small, the typecheck catches the common mistakes, and the new tool lights up in the catalog on the next chat handler restart. The hard parts are not in the code; they are in the schema description, the connection gate, and deciding which skills should be allowed to call the tool at all.

Decision tree

Module or native. Pick once.

SituationPath
New provider, no existing tools for itProvider module
New tool for an existing provider that already lives in a moduleAdd to the existing module file
New tool that depends on shared helpers in chat-tools.ts (web search, scraping, log_activity)Native catalog
Quick fix to an existing native toolNative catalog
Tool intended to be deprecated within the next quarterNative catalog
Tip
When in doubt, choose the module path. Modules are easier to delete cleanly, easier to test in isolation, and easier to migrate to a separate codebase later. The native catalog is a fine destination for tools that genuinely share infrastructure with other native tools.

Naming conventions

A few small rules that keep the catalog scannable.

RuleWhy
Lowercase, underscore separatedMatches every existing native tool. Easy to grep.
Prefix with the provider id where the tool wraps an external service (stripe_, brevo_, gmail_)Lets the activity feed cluster tools by provider
Use a verb in the tool name (create, list, send, search)The model picks better when the name reads like a sentence stem
Keep the name under 40 charactersLong names truncate in the activity sidebar
Never collide with an existing nameDispatcher routes by name; collisions are silent

Add a tool as a provider module

The end-to-end recipe for a new module.

  1. 01
    Create the file
    Add src/lib/tools/providers/<provider>.ts. Start from an existing module like stripe.ts as a structural reference.
  2. 02
    Define the schemas array
    Each entry is a tool-call function definition. Keep the description short and operational; one or two sentences max.
  3. 03
    Write the executors map
    One async function per tool name. Signature is (ctx: ToolContext, args) => Promise<string>. Return a string the model can read.
  4. 04
    Add status labels (optional)
    Map each tool name to a synchronous function that returns a short verb phrase rendered in the activity sidebar.
  5. 05
    Export the module
    Default export an object that implements ToolModule: { provider, schemas, executors, statusLabels }.
  6. 06
    Register it
    Open src/lib/tools/registry.ts and add the module to the MODULES array. That is the only place outside the module that needs to change.
  7. 07
    Update skill allow-lists
    Open src/lib/skills/registry.ts and add the new tool name to the tools array of any skill that should be allowed to call it. Add the same names to the SKILL.md frontmatter for consistency.
  8. 08
    Typecheck and push
    Run npx tsc --noEmit -p tsconfig.json. Commit, push, deploy.
src/lib/tools/providers/example.tsts
1import type { ToolModule, OpenAIFunctionSchema } from "@/lib/tools/types"
2import type { ToolContext } from "@/lib/chat-tools"
3
4const schemas: OpenAIFunctionSchema[] = [
5 {
6 type: "function",
7 function: {
8 name: "example_lookup",
9 description: "Look up a record from the example provider by id.",
10 parameters: {
11 type: "object",
12 properties: {
13 id: { type: "string", description: "Record id." },
14 },
15 required: ["id"],
16 },
17 },
18 },
19]
20
21async function exampleLookup(ctx: ToolContext, args: { id: string }): Promise<string> {
22 const client = await getExampleClientFor(ctx.userId)
23 if (!client) return "Example provider is not connected for this account."
24 const row = await client.records.get(args.id)
25 return JSON.stringify({ id: row.id, name: row.name, status: row.status })
26}
27
28const exampleModule: ToolModule = {
29 provider: "example",
30 schemas,
31 executors: { example_lookup: exampleLookup },
32 statusLabels: {
33 example_lookup: (args) => `Looking up ${args.id}`,
34 },
35}
36
37export default exampleModule

Add a tool to the native catalog

When the new tool needs to share helpers in chat-tools.ts.

  1. 01
    Pick a section
    Find the area of chat-tools.ts that matches the new tool's category. Schemas are grouped by category.
  2. 02
    Add the schema
    Append the tool-call schema to the section's schemas array. Keep the description short and operational.
  3. 03
    Add the executor
    Find _executeTool (or the equivalent dispatch function for the section) and add a case for the new name that calls a private function defined elsewhere in the file.
  4. 04
    Add a status label
    If the tool deserves a custom label, add a branch to getToolStatusLabel. Otherwise the default label runs.
  5. 05
    Update the connection gate (if applicable)
    Add the tool name to the provider map in toolPassesConnectionGate so users without the provider connected do not see the tool.
  6. 06
    Update skill allow-lists
    Same as the module path. Open src/lib/skills/registry.ts and add the tool name to skills that should call it.
  7. 07
    Typecheck and push
    Run the typecheck. Confirm no other tool name changed. Ship.

ToolContext reference

Every executor receives this object. Knowing what is on it saves duplicated lookups.

src/lib/chat-tools.tsts
1export interface ToolContext {
2 supabase: SupabaseClient // service-role client scoped by RLS
3 userId: string // resolved auth.users.id
4 workspaceCode: string // used for per-user scoping of external servers
5 conversation: { id: string; project_id: string | null }
6 turnIndex: number // current iteration in the tool-calling loop
7 model: string // resolved model id, not the tier label
8 tier?: "lite" | "smart" | "deep" | "plinth" // Minuet / Allegro / Forte / Plinth
9 logActivity: (event: ActivityEvent) => Promise<void>
10 saveMemory: (entry: MemoryEntry) => Promise<void>
11}
Tip
ctx.logActivity and ctx.saveMemory are convenience wrappers around the system tools of the same name. They write to the same tables but do not consume a turn. Use them when a tool wants to surface progress or persist a finding without going through the model.

Writing the schema

A schema description is the only thing the model reads when deciding to call a tool. Treat it like prompt engineering.

DoAvoid
Lead with a verb. Lists, creates, sends, returns.Past tense. Was used to do X.
Name the upstream provider explicitly when relevant.Generic descriptions that could apply to multiple tools.
Mention the operation cost in token count if non-trivial.Bragging about features the model cannot see.
Document required arguments inline in the description if their meaning is non-obvious.Repeating the JSON-Schema property descriptions in prose.
Use the exact name of the related skill or persona when scoping the tool.Inventing aliases the rest of the catalog does not use.

Writing the executor

An executor takes ctx and args, does the work, returns a string.

The return value is what the model sees. Return JSON when the model needs to read structured fields. Return a short summary string when the model only needs the gist. Return an error string starting with Error: when the call fails for a reason the model should react to (provider not connected, rate limit, missing input). The platform turns thrown exceptions into a generic tool error; an explicit error string gives the model a useful payload to work with.

Warning
Do not log secrets. Do not echo full credentials in a tool result. The result is persisted as a message row and may be replayed during compression. Sensitive output should be summarised, not surfaced verbatim.

Status labels

A short verb phrase rendered while the tool is in flight.

src/lib/tools/providers/example.tsts
1statusLabels: {
2 example_lookup: (args) => `Looking up ${args.id}`,
3 example_send: (args) => `Sending to ${args.recipient ?? "the recipient"}`,
4 example_search: (args) => `Searching for "${(args.query ?? "").slice(0, 40)}"`,
5},
Note
Status labels run on every tool start. They get the args object and return a string. They must be synchronous, must not throw, must not call out to the network, and should produce text under 60 characters.

Connection gates

Gates remove the tool from the catalog when the upstream provider is not connected.

src/lib/chat-tools.tsts
1// The gate maps a tool name to the provider id that must be connected.
2const TOOL_PROVIDER: Record<string, string> = {
3 example_lookup: "example",
4 example_send: "example",
5 // ...
6}
7
8export function toolPassesConnectionGate(
9 name: string,
10 connected: Set<string>,
11): boolean {
12 const required = TOOL_PROVIDER[name]
13 return required ? connected.has(required) : true
14}

A new tool that wraps an external provider almost always needs a gate entry. Without one, the tool serializes to the model even when the user has not connected the provider, which leads to confident attempts that fail at the first call. The user-friendly outcome is for the tool to vanish from the catalog until the integration is connected.

Exposing the tool to skills

A tool is invisible to skills until they list it in their allow-list.

src/lib/skills/registry.tsts
1{
2 id: "lead-enrichment",
3 name: "Lead enrichment",
4 category: "lead-gen",
5 description: "Use when ...",
6 tools: [
7 "web_search",
8 "search_memory",
9 "lookup_leads",
10 "example_lookup", // <-- new tool added here
11 "save_memory",
12 ],
13 model: "allegro",
14 maxTurns: 8,
15}
Tip
Update the SKILL.md frontmatter alongside the registry. The loader prefers the manifest at runtime but a stale frontmatter confuses humans reading the markdown file.

Testing

A handful of checks before the tool reaches a real user.

CheckHow
Typechecknpx tsc --noEmit -p tsconfig.json
Schema validityHit the chat handler with a debug flag and inspect the serialized tool catalog. The new schema should appear with the right name and description.
Connection gateDisconnect the provider on a test account, open a session, confirm the tool does not appear in the catalog.
Happy pathConnect the provider, prompt a skill that lists the tool, watch the activity sidebar for the status label and the eventual completion.
Error pathForce the upstream to fail (revoke the token, point at a bad URL) and confirm the model receives a usable error string.
Status labelVerify the label appears under the right tool name and is under 60 characters.

Shipping

The deploy is the same as any other change to the chat surface.

The build pipeline runs the typecheck, the linter, and the bundle step, then ships a new container on every release. The new tool becomes available on the next chat handler invocation; sessions in progress at the moment of deploy do not see the change until their next turn.

Warning
A bad tool can poison a turn for every user. Keep new tools dark behind a feature flag for the first few sessions if the upstream provider is new or the tool touches a destructive action. The flag check goes inside the connection gate.

Gotchas

The mistakes that show up in code review.

Returning an object instead of a string
The executor signature returns a string. Returning a non-string fails the typecheck. Stringify structured payloads with JSON.stringify before returning them.
Forgetting the connection gate
A tool with no gate entry is offered to every user. Users without the upstream provider see a tool that always errors. Add the gate before you ship.
Long tool names
Names over 40 characters truncate in the activity sidebar and look bad in the slash-command picker. Keep names short. The description carries the detail.
Description that summarises the workflow
The schema description is a trigger blurb, not a docstring. If it reads like documentation, the model will skip reading the rest. Keep it operational.
Adding to a module without registering
A new module file does nothing until it appears in the MODULES array in registry.ts. The typecheck does not catch the omission.

File map

src/lib/tools
types.tsToolModule, OpenAIFunctionSchema, ToolExecutor, StatusLabelFn
registry.tsMODULES array, dispatcher, schema flattener
providers
hitl.ts
stripe.ts
<your-new-module>.ts
src/lib
chat-tools.tsnative catalog + connection gate + status labels
mcp-client.tsexternal server discovery and dispatch
src/lib/skills
registry.tsskill manifest with tools allow-list
types.tsSkillMeta interface

Glossary

Provider module
A self-contained tool bundle under src/lib/tools/providers/. Preferred path for new tools.
Native catalog
The historical surface in src/lib/chat-tools.ts. Still receives changes when a tool needs to share helpers.
Tool name
The string the model uses to call a tool. Lowercase, underscore separated, unique across the merged catalog.
Schema description
The short operational sentence on a schema. Treated as prompt by the discovery and selection steps.
Executor
The async function that runs when the model calls a tool. Receives ToolContext plus args, returns a string.
Connection gate
The runtime filter that removes a tool from the catalog when its provider is not connected for the user.
Status label
A short verb phrase rendered in the activity sidebar while the tool is running. Synchronous, never throws.
Skill allow-list
The tools array on a SkillMeta. A tool not listed here is not exposed to that skill at runtime.