Steps
The four step primitives -- runCode, callModel, decide, and invokeTool -- are the atomic units of work in Noetic.
Steps are the building blocks of every Noetic agent. Each step takes typed input, does one thing, and returns typed output. There are four kinds.
runCode -- Custom Logic
Execute any async function. Use it for API calls, database queries, transformations, or anything that is not an LLM call.
import { runCode } from '@noetic-tools/core';
const fetchUser = runCode({
id: 'fetch-user',
execute: async (userId: string, ctx) => {
const res = await fetch(`https://api.example.com/users/${userId}`);
return res.json();
},
});execute receives the step input and a Context object. Return whatever your downstream steps need.
See runCode deep dive.
callModel -- Language Model Call
Call a language model with a system prompt, optional tools, and optional structured output.
import { z } from 'zod';
import { callModel } from '@noetic-tools/core';
const classify = callModel({
id: 'classify-intent',
model: 'openai/gpt-4o-mini',
instructions: 'Classify the user message as one of: question, command, chitchat.',
output: z.object({
intent: z.enum([
'question',
'command',
'chitchat',
]),
}),
});When you provide an output Zod schema, the framework requests structured output from the model and validates the response at runtime.
See callModel deep dive.
decide -- Typed Decision
Ask a model typed questions about the current context and get typed answers with probabilities. A decision reads the conversation but never adds to it.
import { decide } from '@noetic-tools/core';
const triage = decide({
id: 'triage',
model: 'typesafe/jev-1.14',
questions: {
intent: {
type: 'choice',
choices: [
'question',
'command',
'chitchat',
],
},
},
});Use routeSwitch to route by the answers.
See decide deep dive.
invokeTool -- Direct Tool Invocation
Invoke a single tool without going through an LLM. Useful when you know exactly which tool to call and with what arguments.
import { z } from 'zod';
import { invokeTool, tool } from '@noetic-tools/core';
const weatherTool = tool({
name: 'get-weather',
description: 'Get current weather for a city',
input: z.object({
city: z.string(),
}),
output: z.object({
temp: z.number(),
condition: z.string(),
}),
execute: async (args) => {
return {
temp: 72,
condition: 'sunny',
};
},
});
const getWeather = invokeTool({
id: 'weather-lookup',
tool: weatherTool,
args: {
city: 'San Francisco',
},
});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.
See invokeTool deep dive.
Comparison
runCode | callModel | decide | invokeTool | |
|---|---|---|---|---|
| Custom code | Yes | No | No | No |
| LLM call | No | Yes | Yes | No |
| Tool execution | No | Via tools array | No | Direct |
| Structured output | You control | Via Zod output | Typed answers with probabilities | Via tool output schema |
| Retry policy | Yes | No | No | No |
Running Steps in Order
Wherever Noetic takes an array of steps, it runs them sequentially, in array order: each step starts only after the previous one finishes, and its output becomes the next step's input. The array's result is the last step's output. If a step throws, the steps after it do not run.
This is the default for a loop's steps and for a JSON workflow's sequence node. To run a chain once rather than repeatedly, use a loop that stops after one iteration:
import { callModel, loop, until } from '@noetic-tools/core';
const draft = callModel({ id: 'draft', model: 'openai/gpt-4o-mini', instructions: 'Draft a reply.' });
const polish = callModel({ id: 'polish', model: 'openai/gpt-4o-mini', instructions: 'Tighten the draft.' });
// draft runs first; polish then runs on draft's output. Once.
const draftThenPolish = loop({
id: 'draft-then-polish',
steps: [draft, polish],
until: until.maxSteps(1),
});Concurrency is never implicit. Steps run at the same time only inside inParallel, whose paths function also returns an array -- but each of those paths runs concurrently, against the same input.
Replay on Resume
On a harness with a checkpointStore, a resumed run replays each completed step's recorded output instead of running it again. Every step builder accepts two fields that change this: durable: false makes a step always re-run, and replayKey: (input, ctx) => string replays by content rather than by position. See Replay Identity.
Emitting Items Early
A step's output reaches the user only when it is the final output, unless the step emits it. Every step builder accepts emitItems. Its items are added to the conversation and stream through getItemStream while the rest of the tree runs, so a step inside a loop emits on every iteration.
emitItems: trueemits the output as a final item: a string as an assistant message, an object whosetypenames a custom item in scope as that item.- A function picks what to emit: text (an assistant message), an
Item, or a list of items. Returningundefinedemits nothing. - The hook form
{ item, onStart, onDone }runsonStartwith the input andonDonewith the output. Naming a declared custom item, the hooks (plain, async, or generator functions, like a tool'sexecute) yield its events and return its final fields.
import { loop, runCode, until } from '@noetic-tools/core';
const crawl = loop({
id: 'crawl',
steps: [
runCode({
id: 'fetch-page',
execute: async (page: number) => page + 1,
emitItems: (page) => `Fetched page ${page}`,
}),
],
until: until.maxSteps(5),
});emitItems is separate from the emit field on callModel, decide, and step.acpAgent, which filters framework events.
A resumed run replays finished steps and puts their items back on the log without streaming them. Set emitItemsOnReplay: true on the harness to stream emitted items again on replay, even when a whole parent replays without its children running.
Next Steps
- Control Flow -- route between steps with
conditionalandinParallel. - Loop & Until -- repeat steps with termination conditions.
- Spawn -- run steps in isolated child contexts.