Tool registration and schemas
Entry point and tool schemas
The entry exports a synchronous or asynchronous register(ctx):
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const result = (text) => ({
content: [{ type: "text", text }],
details: {}
});
export function register(ctx) {
ctx.registerTool(defineTool({
name: "MyTool",
label: "Example tool",
description: "Returns the supplied text.",
parameters: Type.Object({
value: Type.String({ description: "Text to return." })
}),
execute: async (_callId, params) => {
if (typeof params.value !== "string") {
return result("value must be a string");
}
return result(params.value);
}
}));
ctx.logger.info("example tool registered");
}
Use PI's defineTool and TypeBox schemas. The schema is the tool's input
contract exposed to the model. Use the TypeBox constructors for objects,
strings, numbers, booleans, arrays, unions, optionals, and literals. Validate
data from external systems again inside the handler. A normal result has a
content array containing text or other PI-supported content and a
details object for host-only metadata.
File and directory parameter names
Use file_path for a tool's primary single-file target, whether it reads, writes,
shares or executes a definition file. Read, Write, Edit, ShareFile, ShareImage
and WorkflowStart use this name. Use path for a directory or search scope,
including a scope that can select either a file or a directory; Glob, Grep,
Search, ListDir, FileInfo and GitStatus use it. This follows the Claude Code
Read,
Edit
and Glob
schemas.
Keep distinct roles distinct when a tool needs two files: EditImage reads
file_path and writes its optional output_path. An execution working
directory remains cwd; an API route remains path. These fields are not
single-file targets.
Renaming a parameter changes the live schema and every caller together, without
aliases. Reject unknown parameters instead of silently using a default target.
Journal arguments remain immutable. The existing common subject-key reader in
src/brain/messageView.ts already handles both file targets and search scopes;
attachment ownership and retention read result references, never source paths.
Do not add a historical argument reader or rewrite journal entries.
A failure the model cannot fix by calling differently is thrown rather than
returned as text: the host turns a throw into a tool result flagged
is_error. That is every transport and host fault (a network, DNS or socket
error, an origin answering 5xx, a dead remote transport), never a bad
argument, a missing resource or a policy refusal, which stay text results the
model can act on. Use markTransportFailure(error) from
elowen-plugin-shared/transport to tag such a failure and isTransportFailure
in the tool's own catch-all so it is rethrown instead of flattened into an
answer.
registerTool(tool, { external: true }) marks an externally authored schema.
The host validates this boolean and normalizes an omitted value to false for
all registrations. PluginToolMetadata is the normalized host-side type; tool
names never imply origin, and an explicit false stays internal even with an
mcp__ prefix. The bundled MCP bridge supplies external: true, including its
snapshot-backed runner registrations. Metadata follows the accepted tool owner
through registry merges; a rejected collision contributes neither origin nor
read-only classification.
During session composition, external descriptions are capped at 1,000 UTF-8
bytes and definitions above 8,000 serialized bytes receive a permissive object
parameter schema with an omission notice. The external owner remains the
argument validator. External schemas are never reconstructed to add reason;
internal tools receive the optional reason status field, except the fixed
Bash and ToolSearch exclusions. Advertise one flat object without root combinators:
OpenAI-compatible Responses endpoints can reject those schemas before the turn
runs. ShareImage checks its file-or-latest choice in its handler. Its flat schema
lets an unknown-only call reach that check, which names file_path and latest
in the refusal. Origin classification is independent of deferral.
Cache diagnostics receive a separate projection of external names and redact
both individual names and hosted namespaces containing external tools. This
metadata is never inserted into provider payloads. Work is linear in the tool
catalog plus the external schemas inspected at composition.
A declared provides.readOnlyTools: ['mcp__*'] expands only to that plugin's
registered external tools and requires the matching provides.tools entry.
Exact read-only names retain their own declaration and ownership checks. Neither
form grants tool execution access or changes account wildcard grammar, output
artifact selection, automatic image previews or locked deferral. Plan mode admits
exact read-only names but never an external tool, whichever form declared it.
Without external registrations, local schemas and diagnostic names stay local.
registerTool(tool, { ownerUserId, projectId, platform }) can scope a contribution
to one account, one Project, or conversations from a named platform. Without
these options the contribution is available instance-wide. A platform-scoped
tool is omitted from other sessions' model tool lists and catalog, not merely
rejected when called. This is useful for tools that require a visitor's page or
another platform-specific turn. Platform scoping does not replace execution-time
authorization. Resolve identity and access at execution time:
const identity = ctx.currentIdentity();
const access = ctx.currentAccess();
const accountUserId = ctx.currentAccountUserId();
Do not capture those values during registration or reuse them across turns.
PluginAccessDescriptor.planFile?: string is a core-minted exact guest path, present only on a managed or local Project conversation's own Plan turn. Sandbox consumes it at each file operation to admit that single plan write/upload and its create-only parent directories; the paired-computer executor admits only a write to that same exact path and narrows the operation to its parent directory. It grants no remove, rename, copy, lifecycle or Desktop permission. It is absent on host and Build turns and on all delegated read-only origins. Do not construct or forward this allowance to a child; read currentAccess() at execution time. Command execution follows the core non-destructive permission boundary, including read-only children, and does not require a writable environment descriptor.
currentAccess().readOnly carries both an admitted Plan restriction and the immutable delegated read-only origin. An imposed delegated origin also keeps planMode true for further delegation even though the child itself starts in Build and receives no owner-chat mode directive. Plugins must never clear either restriction when constructing child access.
currentAccess().admin and projectIds are the live effective policy scope, intersected with
the session's captured ceiling. Account demotion and project revocation apply without respawning;
later grants cannot widen a previously scoped session. File and directory tools consume the same
host policy. currentIdentity() also removes captured admin/owner privilege when live host authority
is revoked; it never promotes a turn. Read access immediately before each operation, including after
an asynchronous wait. Queued host Write/Edit recheck the anchored path inside the mutation queue.
currentAccess resolves account-backed role, Project scope and roots in one fresh authorization evaluation; identity/account projections reuse facts only within that descriptor. Files Read derives read-state identity from the same metadata returned to its caller, including PDF/notebook results. Host skill resources stay host-keyed, local Windows paths keep their native platform key, and Write/Edit retain their post-queue live checks and byte/version read-before-write guard.
Tool calls still pass through the host permission boundary, plan-mode policy, plugin grant checks, and applicable hooks.
Who may call a plugin tool is decided by the account's plugin grant and tool
deny-list, not by the tool. A tool does not add an identity.owner check of its
own to restrict access; the bundled Discord API and role tools, for example,
rely on the grant. Use identity.owner for plugin state that only the instance
operator may change, not for tool entitlement.
The manifest metadata completes the runtime tool definition:
- Successful output is hidden unless
showOutputmatches the tool. - Plan mode keeps every tool advertised and executes only the read-only set:
the core read-only tools plus every plugin's exact
provides.readOnlyToolsnames, and the host plan defaults (CORE_TOOL_DEFAULTSinsrc/brain/toolMetadata.ts). A tool not declared there is refused at execution time. The same declaration decides what read-only sub-agents receive, so declare a tool only when it changes nothing outside the agent's own working state. deferLoadingdefers matching tools even in a small session, unless deferral is switched off or an administrator overrides the tool. This is useful for optional or large tool families. A read-only declaration does not keep a tool loaded: plan mode reaches deferred read-only tools through the loader like any other turn.- A tool name in
provides.toolsis an audit declaration. The actual registration remains the source of the live contribution report.
Owning code and consumers
- The
PluginContextmethods and the registration option types live insrc/plugins/api.ts:PluginToolRegistrationOptionsat line 2503 (ownerUserId,projectId,platform,external),registerToolat line 2562,currentAccessat line 2881,currentIdentityat line 2891,currentAccountUserIdat line 2905, andloggerat line 3152.PluginAccessDescriptoris at line 2533; the turn-internal extension that addsplanModeandprincipalisInternalAccessDescriptorinsrc/plugins/pathGuard.tsat line 47. - The manifest fields
showOutputanddeferLoadingare declared insrc/plugins/manifest.tsat lines 169 and 170.provides.readOnlyToolsis at line 157 and its validation at lines 321 to 325. Plan-mode admission isplanModeToolNamesinsrc/brain/toolMetadata.tsat line 51, overCORE_TOOL_DEFAULTSat line 18. - The external caps (1,000 UTF-8 bytes for descriptions, 8,000 serialized
bytes for definitions) are the constants in
src/brain/toolSchemaCap.tsat lines 24 and 29. markTransportFailureandisTransportFailureare inpackages/plugin-shared/transport.mjsat lines 20 and 28, exported aselowen-plugin-shared/transport.- The
reasonstatus field is owned bysrc/brain/toolReason.ts: the exclusion set (ToolSearch,Bashand every external tool) isisReasonExcludedat line 33, andwithReasonat line 41 is applied insrc/brain/session/capabilities.ts. - The bundled MCP bridge is the real consumer of
external: true; seeplugins/mcp/index.mjsat line 739.
Absent behavior, limits and cost
- A tool that is never registered is absent from the model's tool list.
Manifest lists in
providesare audit declarations and do not register anything. - External schema and description limits are applied when the session is composed. The object-root rule for local schemas, and what happens to a refused schema, are in row 1 of the seam catalog.
- A tool's schema and description are part of the provider request, so their size costs input tokens on every turn that offers the tool.