Tools
Schema-first tool definitions: strict JSON Schema, the x-neuralis extension, and result bounding.
Every package tool starts with a schema, not with code. A tool is one .json
file in tools/ — the tool name is the filename (weather_forecast.json
defines the tool weather_forecast), the body is a strict JSON Schema for the
tool's input, and a single x-neuralis extension block carries the Neuralis
metadata. The schema is validated at package load; an invalid tool schema fails
the package load for every trust tier, so first-party and project packages
share one contract.
Root schema rules
| Rule | Why |
|---|---|
$schema is required, and must be the exact string "http://json-schema.org/draft-07/schema#" | Write it character for character. The load check accepts that spelling and the same URL without the trailing #, and nothing else: the near-miss variants — https://…, or a trailing / — name a meta-schema the dispatch-time validator cannot compile, so they are refused at load. A non-draft-07 value (2019-09, 2020-12) is refused the same way. |
The tool's NAME — its file name — must match ^[A-Za-z0-9_-]{1,64}$ | The name is what the model sends back when it calls the tool, and every provider constrains it; 64 characters is the smallest ceiling among them. A name outside the set fails the whole package at load, on purpose: a provider that refuses the name refuses the entire tool list rather than the one entry, so a single name outside the intersection ends every turn on that family — and the error names the provider, not the package that caused it. Note that an MCP-valid name is not automatically usable here: the MCP specification permits . and :, so name a tool admin_tools_list, never admin.tools.list. A dotted name coming from a connected MCP server is withheld from the model for that run and reported on the agent's setup view, naming the server. |
title is required | Catalog display. |
type must be "object" | MCP wire shape. |
additionalProperties: false at the root | The model must not send unexpected properties. |
Every property declares type, anyOf, oneOf, or $ref | No untyped inputs. |
Several verbs on one tool: each verb is a root key holding its own closed object ({ "plan": { … } }, { "mark": { … } }) — never an action enum over one flat object whose fields a "for X only" description gates, and never a root anyOf of alternative shapes | The nesting reaches the model the same way on every provider (the Gemini adapter drops conditional keywords such as if/then/allOf but keeps nested objects), and the dispatch validator rejects a field outside its verb instead of ignoring it. A root anyOf beside the required root additionalProperties: false rejects every call. Document the order in which keys sent together apply — a verb that closes a state runs before the verb that opens the next, so one call can replace the state — and refer to an immutable item by a stable number rather than retyped text. Only verbs sharing one approval posture belong on one tool — the approval floor never reads an argument's VALUE, so a destructive verb stays a tool of its own unless its root key is declared in x-neuralis.askOnKeys (below). |
required is an array of strings | Standard JSON Schema. |
Root description is an operating contract: what outcome the tool enables, when to choose it, and its decisive recovery/safety boundary | The model receives it with the full schema on every step. Keep property descriptions input-specific and leave long procedures in skills; automatic rules retain constraints needed before activation. |
No legacy x-tool, x-output, or x-validation blocks | One canonical extension: x-neuralis. |
For a tool-name preflight in code, import TOOL_NAME_PATTERN and
isWireSafeToolName from @neuralis/package-system/tool-name. This entry has no
React or Node built-in dependencies and works in server and browser graphs.
The validation barrel includes server filesystem code; the client barrel
includes React UI hooks. Neither is a shared server/browser entry point.
Reserved input keys
Session context is never sourced from tool input — the runtime always reads
the caller's identity from the verified session ticket, never from arguments. To
make that impossible to get wrong, the validator rejects every SessionContext
field name (plus the bundled session) as a top-level input property:
userId, projectId, agentId, conversationId, requestId, toolUseId, delegateRunId, delegateMode,
role, priority, grantedFeatures, spendLimits, llmRateLimitRpm,
agentAccess, agentOwnership, session. The ban is root-only by design:
the top-level arguments object is the only place a model-supplied key could be
mistaken for caller identity. The same names are legitimate domain fields when
nested — a todos[].priority enum, a members[].userId, a chat message
role — so nesting them is allowed. Handlers receive the caller's verified
session separately — see Sessions.
Open-bag normalization
After the contract checks pass, the validator walks every nested subschema and
injects a default propertyNames constraint on any open object bag
(additionalProperties: true without an existing propertyNames). The default
pattern blocks prototype-pollution and reserved-namespace keys (__proto__,
constructor, prototype, anything starting with __ or $). Declare your
own propertyNames to opt for a different shape; the root is exempt because it
must be closed anyway.
The x-neuralis extension
"x-neuralis": {
"family": "weather",
"operation": "weather.forecast",
"transport": "embedded",
"defaults": { "days": 3 },
"annotations": {
"title": "Weather Forecast",
"readOnlyHint": true,
"destructiveHint": false,
"category": "network",
"estimatedDurationMs": 2000
},
"ui": { "presentation": "card", "cardType": "weather.result", "icon": "cloud-sun" },
"requires": { "features": ["weather.read"] }
}| Field | Required | Rules |
|---|---|---|
operation | yes | Dotted path, /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)*$/ (e.g. catalog.list). |
transport | yes | One of embedded, mcp, http, stdio, remote. |
family | no | Groups tools for UI and gating; /^[a-z][a-z0-9-]*$/. |
defaults | no | Object of default parameter values. |
annotations | see below | MCP-style hints, the risk category, and the optional cancellable promise. |
ui | no | presentation (inline | card | explored), cardType, icon. |
requires | no | Feature gate — see below. |
askOnKeys | no | Root input key names whose PRESENCE makes a call ask for approval — see below. |
concurrencySafe | no | true only; the handler promises two of its calls may run at the same time. Requires readOnlyHint: true, and may not appear beside askOnKeys. |
Unknown keys inside x-neuralis are rejected, as is any recovery field —
recovery posture is resolved by the platform's mutation-safety policy at
runtime, never declared on a tool.
askOnKeys — a per-key approval posture
The approval floor is resolved per tool and never reads an argument's value.
The one declared exception is presence-based: askOnKeys lists root input
key names, and a call that SENDS one of them turns an otherwise-quiet posture
into an approval request. {"delete": false} asks too — sending the key is the
signal, its value is never read.
It can only make a call ask; it never grants one the floor would have
stopped, and a tool that declares nothing behaves exactly as before. It is valid
only on a tool with destructiveHint: true whose category is write,
execute, network or machine — in every other category the guard asks about
every call anyway, so the declaration would gate nothing and is rejected at load.
At most eight distinct keys, each a real root property of the tool's own input
schema. So a writer whose delete key must be confirmed can keep its ordinary
edits quiet, without splitting into two tools the model has to choose between.
Risk category is mandatory for native tools
annotations.category classifies the tool for guard-profile evaluation and is
required on every natively-authored tool:
read, write, execute, network, machine, credential, admin, or
unknown. Imported tools (external MCP servers) default to unknown plus
destructiveHint: true, and unknown is treated as approval-required in every
guard profile. The category is distinct from feature grants: features answer
"may this caller access the capability", the category answers "what approval
posture applies once access is granted".
The boolean hints (readOnlyHint, destructiveHint, idempotentHint,
openWorldHint) and estimatedDurationMs follow the MCP annotation
conventions and are all optional — only category is required. One of them
has a visible side effect worth knowing: with no ui.cardType and no explicit
ui.presentation, readOnlyHint: true switches the tool's chat rendering to the
compact explored style instead of the inline one — right for a chatty read
tool, wrong for a result the user must actually look at. Set
x-neuralis.ui.presentation explicitly when you care.
annotations.cancellable — what happens when a run is stopped
cancellable is an optional boolean, and it is a promise about your handler,
not a request to the platform. Declare it and the platform hands your handler the
run's abort signal when a person stops the turn, then waits for the handler to
finish — with only your trust tier's execution ceiling as a backstop. So declare
it only for a tool that is bounded by construction and returns promptly once the
signal fires.
Leave it off — the default — and the tool is never interrupted. The platform stops waiting after a short grace, tells the model the call was interrupted, and your handler keeps running to completion with its result discarded. For anything that mutates state, undeclared is the safer answer: a half-honoured abort is worse than a dropped result.
requires — feature gating on tools
requires.features is the one declarative source of tool feature-gating. A
caller whose grants do not satisfy every listed feature never sees the tool —
not in the model-facing catalog, not over hosted MCP, not in the client
snapshot — and a direct dispatch attempt is rejected with a generic message.
requires.escalations is catalog metadata only: it documents additional
features a handler may demand based on its input (for example, a drive-class
action needing a stronger grant than the read baseline) without itself gating.
Semantics and the shared predicate:
Features and access.
A complete example
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "List Catalog",
"type": "object",
"additionalProperties": false,
"description": "List all items in the catalog. Returns an array of items with id, name, and status.",
"properties": {
"filter": {
"type": "string",
"enum": ["active", "archived", "all"],
"default": "active",
"description": "Optional filter by status."
}
},
"required": [],
"x-neuralis": {
"family": "catalog",
"operation": "catalog.list",
"transport": "embedded",
"annotations": {
"title": "List Catalog",
"readOnlyHint": true,
"destructiveHint": false,
"category": "read"
},
"ui": { "presentation": "explored", "icon": "list" }
}
}Handlers pair with schemas by filename — tools/catalog_list.json with
src/tools/catalog_list.ts for first-party node packages, or a name === 'catalog_list' branch inside the single WASM entry for project packages. See
Lifecycle for both handler contracts.
Result shape and bounding
A handler returns { content, isError?, structuredContent?, _meta? }.
content is a string or an array of MCP content blocks — text, image,
audio or resource, and nothing else: any other block type a handler returns
is silently dropped at normalization. Image, audio and resource blocks are
preserved end-to-end into the model payload (video rides an embedded resource
with a video/* mimeType — there is no video block type), and a
resource_link arriving from an external MCP server is normalized into
resource on the way in. The in-product model reads content only — a fact it
needs belongs in the text. structuredContent is JSON for card rendering and
external MCP clients. _meta is UI/runtime-only and is stripped before the model ever
sees it — put accounting and runtime metadata under _meta.neuralis.*.
Every result is bounded so a single tool call can never flood a conversation:
| Partition | Default cap |
|---|---|
content[].text (total) | 20,000 characters |
structuredContent (serialized) | 20,000 characters |
_meta (serialized) | 256,000 characters |
Bounding never replaces structuredContent with a tombstone when legitimate
data can be kept — arrays shrink from the tail, top-level strings clip, and
primitive metadata survives. Text truncation appends a marker with the original
and capped character counts. The cap is applied at three boundaries: inside
well-behaved handlers, at the dispatch funnel before the result is recorded,
and on results returned by external MCP servers.
Settled-header status convention
The chat card header draws one status icon whose accessible name is the
status word; there is no visible status pill. The settled status derives from
structuredContent: ok: true (or a string status of success /
completed / pending_review) renders the green check; ok: false renders
the red X; any other string status becomes the icon's name, and its tone
follows the word — error / fail draw the red X, partial the amber
triangle, and a word containing pending draws the in-flight spinner.
Declaring ok on success payloads is the first-party convention. A completed
tool with no marker at all still settles — the platform floors a
runtime-completed call to committed, so a foreign tool is never forced into
the convention — but never reuse the status key for a domain value (an HTTP
status code belongs under httpStatus), and never emit a settled status word
containing pending: pending_review is the one floored carve-out. A card
claiming to run forever is a lie the user cannot dismiss, and the platform
cannot tell it apart from a call that really is still open.
The rule about motion is separate, and it is stricter. The first-party status
spinner no longer splits the timeline into layers — it is drawn with a
mechanism the compositor does not accelerate. Your own card body still can:
inside the virtualized chat timeline, a CSS keyframe on transform,
opacity, filter, background-color or clip-path — which is what
Tailwind's animate-spin and animate-pulse are — promotes that element into
its own compositor layer, pushes every row painted after it into a squashing
layer, and makes the rows slide against each other on a fast scroll. It
applies while the tool runs as much as after it finishes. Keep a card body
still, and let the header's status icon carry "this is running".
Binding a tool to a card
x-neuralis.ui.cardType is what routes a result to a package's own
card surface: the value must equal a
declared card's type. Card type resolves first-match-wins — an app marker on
the result, then ui.cardType, then operation, then
structuredContent.kind/.type, then the tool name, then default.
An unmatched cardType is neither an error nor a blank surface: the result
falls back to the generic default card. That is often deliberate — a
host-provided card type, or a tool that only needs the standard rendering — so
it is a warning in the packaging pre-flight rather than a rejection. If you
meant to ship your own card, that warning is the only signal you get.
A sandboxed iframe card receives a second, tighter budget on top of the one
above. The one-way payload it is sent carries only the tool's name, input,
status, result and structuredContent, capped at 131,072 bytes serialized
in total and 16,384 characters per string, with an explicit truncation marker
where a cap cut real data. Two consequences for schema design:
- Put the headline in the structure. A card that has to parse a
20,000-character
contentblob to find a count will render a truncation marker instead. Return the few fields the surface draws — a count, a status, a short top-N array — as top-levelstructuredContentkeys. - The full result is a separate source of truth. Paginate and summarize;
the live card view is bounded on purpose, and
running,successanderrormust all render from whatever arrived.