NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Tool registration and schemas
Developer reference

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.

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 normal status field except the fixed Bash and ToolSearch exclusions. 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, plan-mode policy, output artifact selection, automatic image previews or locked deferral. 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. 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. Tool calls still pass through the host permission boundary, plan-mode policy, plugin grant checks, and applicable hooks.

The manifest metadata completes the runtime tool definition:

  • Successful output is hidden unless showOutput matches the tool.
  • Plan mode advertises the core read-only tools plus exact names in planSafe. An undeclared plugin tool is not plan-safe.
  • deferLoading defers matching tools even in a small session. This is useful for optional or large tool families. planSafe 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.tools is an audit declaration. The actual registration remains the source of the live contribution report.

Owning code and consumers

  • The PluginContext methods and the registration option types live in src/plugins/api.ts: PluginToolRegistrationOptions at line 2370 (ownerUserId, projectId, platform, external), registerTool at line 2398, currentAccess at line 2773, currentIdentity at line 2783, currentAccountUserId at line 2797, and logger at line 3048.
  • The manifest fields showOutput, planSafe and deferLoading are declared in src/plugins/manifest.ts at lines 147 to 149. provides.readOnlyTools is at line 134 and its validation at lines 291 to 293.
  • The external caps (1,000 UTF-8 bytes for descriptions, 8,000 serialized bytes for definitions) are the constants in src/brain/toolSchemaCap.ts at lines 23 and 27.
  • markTransportFailure and isTransportFailure are in packages/plugin-shared/transport.mjs at lines 20 and 28, exported as elowen-plugin-shared/transport.
  • The bundled MCP bridge is the real consumer of external: true; see plugins/mcp/index.mjs at line 678.

Absent behavior, limits and cost

  • A tool that is never registered is absent from the model's tool list. Manifest lists in provides are 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.