Building
Tools
Give your agents a new action the model can call: a description, a JSON schema and a handler, running in the plugin sandbox.
A tool is a function the model can decide to call, exactly like the built-in web_search or
workspace_read_file. You describe it with a name, a description and a JSON Schema for its input; the model
decides when to call it and with what arguments; your handler runs in the sandbox and whatever it returns goes
back to the model.
Register a tool
Written for these docs. Official plugins register tools the same way — for example
briefing_weatherinplugins/morning-briefing/src/weather.ts. The shape below is exactly what the runtime and the publish check enforce.
globalThis.manifest = {
egress: ["api.open-meteo.com"],
};
globalThis.tools.get_forecast = {
description: "Get tomorrow's temperature range for a latitude/longitude. Use when the user asks about weather.",
parameters: {
type: "object",
properties: {
latitude: { type: "number", description: "Decimal degrees, north positive" },
longitude: { type: "number", description: "Decimal degrees, east positive" },
},
required: ["latitude", "longitude"],
},
handler: async (input: { latitude: number; longitude: number }) => {
const response = await host.fetch({
url:
"https://api.open-meteo.com/v1/forecast" +
`?latitude=${input.latitude}&longitude=${input.longitude}` +
"&daily=temperature_2m_max,temperature_2m_min&forecast_days=2",
});
if (response.status !== 200) throw new Error(`forecast service returned ${response.status}`);
const daily = JSON.parse(response.body).daily;
return { minC: daily.temperature_2m_min[1], maxC: daily.temperature_2m_max[1] };
},
};
| Field | Rules |
|---|---|
| name (the key) | ^[a-z][a-z0-9_]{0,63}$ — lowercase, digits, underscores, starts with a letter |
description |
required, non-empty. This is what the model reads to decide when to call you: say what it does and when to use it. |
parameters |
optional JSON Schema for the input. Defaults to { "type": "object", "properties": {} } (no arguments). Must be plain JSON. |
handler |
required, a function (input, context) => result (sync or async). |
alwaysListed |
optional boolean. true keeps the tool in the agent's tool list when an agent has more tools than its model takes and your other tools move behind search_tools. At most 3 per plugin; a 4th fails the publish. |
deferred |
optional boolean. true keeps the tool behind search_tools on every agent, however few tools the agent has. Cannot be combined with alwaysListed; a tool that sets both fails the publish. |
A bare function (globalThis.tools.x = async () => …) or a registration without a description fails the publish.
Keep a tool out of the list
A plugin with many tools would add all of them to every request of every agent it is installed on. Mark a tool
deferred: true and the agent no longer sees it in its list; it finds the tool with search_tools and runs it with
call_tool. Write the tool's description with the words someone would search for, because that text is what
search_tools matches on. The owner can still pin the tool on the agent's Access tab, and a pin overrides
deferred. You cannot set both deferred and alwaysListed on one tool.
What your handler receives and returns
inputis the model's arguments object, parsed from JSON. The platform doesn't validate it against your schema — check what you depend on.contextis{ callId: string; initiator: TurnInitiator }, frozen.initiatorsays what started the turn, set by the platform and never by the model:kindis"user","channelSender","schedule","event","agent"or"platform", andisVerifiedPersonis true when every input came from a signed-in user or a verified contact. It does not see text the model read through tools or earlier turns, so it is not proof that an action is free of planted instructions. Use it to refuse actions that a schedule, an event or another agent must not trigger on its own.- Return any JSON-serializable value. It is serialized and handed to the model as the tool's result. Returning
undefinedsendsnull. - Throwing is fine. A thrown error, a timeout or a memory fault becomes a failed tool result the model can see and react to; the agent's turn carries on.
Keep results compact: everything you return lands in the model's context.
How the model sees it
Your tool joins the agent's tool list next to the built-ins, named <plugin-slug>__<tool-name> — for example
weather__get_forecast for a plugin with slug weather. Two plugins can therefore both have a search tool.
(A tool whose final name equals a built-in tool's name is dropped with a warning.)
Which agents get it
Installing the plugin makes the tool available to every agent on the account. On each agent's Access tab
the owner can switch Tools from "all" to a short list, deny your tool on one agent, or pin it. Nothing to do on
your side. A crew plugin can instead fix an agent's tool list itself with tools on
host.agents.create/update.
What a tool can reach
A tool call runs inside a chat, on behalf of one agent, so it gets the richest context of any capability:
host.fetch,host.settings,host.crypto,host.log— always.host.session.*— act on the calling chat: read the conversation, post a message, manage todos, schedule a timer, spawn a subagent, message a peer (each by permission — see Permissions).host.workspace.*— read and write the calling agent's files (and the shared workspace).host.agents.*,host.cronJobs.*,host.models.list()— your plugin's own crew and schedules.host.plugins.*— build, try and release the plugins the account owns, asking the user before anything leaves the account (details).host.oauth.accounts(),host.license()— the customer's connected sign-ins and your licence.host.session.llm,host.llm,host.decide— call a model.host.media.*— upload and transcode audio.
Written for these docs — a tool that saves a note into the agent's workspace:
globalThis.tools.save_note = {
description: "Save a short note into the agent's workspace under notes/.",
parameters: {
type: "object",
properties: { title: { type: "string" }, text: { type: "string" } },
required: ["title", "text"],
},
handler: async (input: { title: string; text: string }) => {
const path = `notes/${input.title.replace(/[^a-z0-9-]+/gi, "-").toLowerCase()}.md`;
await host.workspace!.write({ path, content: input.text });
return { saved: path };
},
};
Calling host.workspace.write derives host:workspace:write, so the installer sees "can write the agent's
files" before installing.
Limits
30 s wall clock and 128 MB per call (at least 60 s and 512 MB with host:media:transcode, and at least 120 s with agentApi:plugins.write). Each call is metered as
a plugin invocation.
TypeScript
The downloadable SDK types declare the registry: declare var tools: Record<string, ToolDefinition>, with
ToolDefinition = { description: string; parameters: Record<string, unknown>; handler: (input: any, context: ToolCallContext) => unknown | Promise<unknown>; alwaysListed?: boolean; deferred?: boolean }. The types require parameters; the
runtime defaults it to no arguments, so a tool with none still writes parameters: { type: "object", properties: {} }
when you type-check.