NOETIC
Framework
Steps

invokeTool

Invoke a single tool directly as a typed step.

Quick Example

import { z } from 'zod';
import { invokeTool, tool } from '@noetic-tools/core';

const calculator = tool({
  name: 'add',
  description: 'Add two numbers',
  input: z.object({
    a: z.number(),
    b: z.number(),
  }),
  output: z.object({
    sum: z.number(),
  }),
  execute: async (args) => ({
    sum: args.a + args.b,
  }),
});

const addStep = invokeTool({
  id: 'add-numbers',
  tool: calculator,
  args: {
    a: 10,
  },
});

What It Does

invokeTool invokes a single tool without going through an LLM. This is useful when you know exactly which tool to call -- for example, after a conditional routes to a known action, or as part of a deterministic pipeline.

The optional args field lets you preset some or all of the tool's inputs. At runtime, preset args are merged with the step input.

The Tool Interface

Every tool in Noetic follows the same interface, whether it is passed to callModel or invoked directly via invokeTool.

PropertyTypeRequiredDescription
namestringYesTool name (used in LLM function calling)
descriptionstringYesHuman-readable description for the model
inputStandardSchemaV1<I>YesSchema defining the tool's input — any Standard Schema v1 validator (Zod, Valibot, ArkType, …); Zod is the default
outputStandardSchemaV1<O>YesSchema defining the tool's output
inputJsonSchemaRecord<string, unknown>NoExplicit JSON Schema override/fallback for non-Zod tool input. Zod uses its native converter; other validators can implement StandardJSONSchemaV1. A validation-only schema must provide this field or conversion throws MISSING_JSON_SCHEMA
execute(args: I, toolCtx: ToolExecutionContext) => Promise<O>YesThe function that runs the tool. The second argument is a ToolExecutionContext — { ctx, harness, fs, shell, context, assembledView, lastStepMeta } — not the step Context itself (which is available as toolCtx.ctx)
needsApprovalbooleanNoEvery call needs the harness approver's go-ahead before it runs — see Human-in-the-Loop Approval
contextToolContextDeclarationNoDeclares tool-owned state; the runtime generates a context layer from it
uiToolUiDeclarationNoRender functions (call / progress / result / error) so the tool's calls carry their own UI — see Generative UI

API Reference

invokeTool Options

PropertyTypeRequiredDescription
idstringYesUnique step identifier
toolTool<StandardSchemaV1<unknown, I>, StandardSchemaV1<unknown, O>>YesThe tool to invoke
argsPartial<I>NoPreset arguments merged with step input

Defining a Tool

import { z } from 'zod';

import type { Tool } from '@noetic-tools/core';

const fetchUrl: Tool<typeof UrlInput, typeof UrlOutput> = {
  name: 'fetch-url',
  description: 'Fetch the contents of a URL',
  input: z.object({
    url: z.string().url(),
  }),
  output: z.object({
    body: z.string(),
    status: z.number(),
  }),
  execute: async (args) => {
    const res = await fetch(args.url);
    return {
      body: await res.text(),
      status: res.status,
    };
  },
};

const UrlInput = z.object({
  url: z.string().url(),
});
const UrlOutput = z.object({
  body: z.string(),
  status: z.number(),
});

Standard Schema v1

Tool schemas accept any Standard Schema v1 validator, not just Zod. Runtime validation uses Zod safeParse or ~standard.validate (sync and Promise results are supported), and execute receives the parsed/transformed value.

Model-facing JSON Schema uses three tiers: Zod's native converter, the Standard JSON Schema v1 companion trait, then inputJsonSchema. For non-Zod inputs, an explicit inputJsonSchema overrides the trait and falls back when the trait converter throws. Zod 4.2+, ArkType 2.1.28+, Zod Mini, VineJS, and Sury implement the trait; Valibot adds it with toStandardJsonSchema() from @valibot/to-json-schema. Validation-only schemas still require inputJsonSchema. Noetic adds no per-validator runtime dependency.

import { toStandardJsonSchema } from '@valibot/to-json-schema';
import * as v from 'valibot';
import { tool } from '@noetic-tools/core';

const search = tool({
  name: 'search',
  description: 'Search the knowledge base',
  input: toStandardJsonSchema(v.object({ query: v.string() })),
  output: v.object({ results: v.array(v.string()) }),
  execute: async (args) => ({ results: ['Result 1', 'Result 2'] }),
});

Standard Schema support covers tool input/output/event schemas and callModel / ACP agent structured output only. Channels, context-layer schemas, item extension schemas, and JSON workflow schemas remain Zod-specific.

Human-in-the-Loop Approval

Set needsApproval: true on a tool and give the harness an approver with approveToolCall. Before every call to that tool runs, the harness asks the approver, which returns a steering decision: allow runs the call; deny (or guide) refuses it, with guidance as the reason. The approver may be async and may wait — for example on a channel a human answers.

import { z } from 'zod';
import { AgentHarness, channel, tool } from '@noetic-tools/core';

declare const db: { delete(recordId: string): Promise<void> };

const deleteTool = tool({
  name: 'delete-record',
  description: 'Permanently delete a database record',
  input: z.object({
    recordId: z.string(),
  }),
  output: z.object({
    deleted: z.boolean(),
  }),
  needsApproval: true,
  execute: async (args) => {
    // Runs only once the approver allows the call
    await db.delete(args.recordId);
    return {
      deleted: true,
    };
  },
});

// An operator UI reads requests and writes answers.
const approvalRequests = channel('approval-requests', {
  schema: z.object({ toolName: z.string(), toolArgs: z.unknown() }),
  mode: 'queue',
});
const approvalAnswers = channel('approval-answers', {
  schema: z.boolean(),
  mode: 'queue',
});

const harness = new AgentHarness({
  name: 'ops-agent',
  params: {},
  tools: [deleteTool],
  approveToolCall: async ({ toolName, toolArgs, ctx }) => {
    await ctx.harness.send(approvalRequests, { toolName, toolArgs }, ctx);
    const approved = await ctx.harness.recv(approvalAnswers, ctx);
    return approved
      ? { action: 'allow' }
      : { action: 'deny', guidance: 'refused by operator' };
  },
});

The approver receives { toolName, toolArgs, callId, ctx }. toolArgs are the validated arguments — exactly what execute receives — and callId is the same id the tool_call_started event carries (the model's call id, or the step id for invokeTool).

Order. Approval is the last gate before execute. beforeToolCall gating layers run first, so a layer refusal stops the call before the approver is asked.

Refusal. A refused call never runs. On the model-driven path the model receives an error tool result (Tool call denied: <guidance>), the same shape as a gating-layer refusal. An invokeTool step throws NoeticError kind steering_denied carrying the guidance. An approver that throws refuses the call.

No approver. A harness without approveToolCall runs the call unapproved and logs a warning once per tool name. Traces show it: each tool.call span carries tool.approval — approved, denied, or none_configured.

Self-gating integrations. A wrapper that gates inside execute does not declare needsApproval, so its calls are gated once. fromAiSdkTool is one: it waits on its own approval channels and fails the call when it is refused or times out.

  • callModel -- pass tools to an LLM and let the model decide when to call them.
  • runCode -- for arbitrary async logic that is not a tool.
  • Steps overview -- comparison of all step types.

On this page