@package-system

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

RuleWhy
$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 requiredCatalog display.
type must be "object"MCP wire shape.
additionalProperties: false at the rootThe model must not send unexpected properties.
Every property declares type, anyOf, oneOf, or $refNo 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 shapesThe 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 stringsStandard JSON Schema.
Root description is an operating contract: what outcome the tool enables, when to choose it, and its decisive recovery/safety boundaryThe 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 blocksOne 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"] }
}
FieldRequiredRules
operationyesDotted path, /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)*$/ (e.g. catalog.list).
transportyesOne of embedded, mcp, http, stdio, remote.
familynoGroups tools for UI and gating; /^[a-z][a-z0-9-]*$/.
defaultsnoObject of default parameter values.
annotationssee belowMCP-style hints, the risk category, and the optional cancellable promise.
uinopresentation (inline | card | explored), cardType, icon.
requiresnoFeature gate — see below.
askOnKeysnoRoot input key names whose PRESENCE makes a call ask for approval — see below.
concurrencySafenotrue 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:

PartitionDefault 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 content blob 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-level structuredContent keys.
  • The full result is a separate source of truth. Paginate and summarize; the live card view is bounded on purpose, and running, success and error must all render from whatever arrived.

On this page