Tool Calls
How tools expose imperative state and function-call interfaces to context layers and LLMs.
Overview
Noetic provides two patterns for connecting tools and context layers. Both inject state into the LLM's context via recall, but they differ in who writes the state:
| Tool-Owned State | Function-Call State | |
|---|---|---|
| Who writes state | Tool code (imperative) | The LLM (via function calls) |
| Layer creation | Auto-generated by toolCalls() | Hand-written ContextLayer |
| State access | toolCtx.context.get() / .set() | findFunctionCall() in store() hook |
| Scope | 'execution' by default | Any scope you choose |
| Best for | Tracking tool side effects | Scratchpads, self-managed state |
Tool-Owned State
Tools can declare a context property. The runtime collects these declarations and auto-generates a ContextLayer for each unique context.id. Tools then read and write state imperatively during execution.
ToolContextDeclaration
import type { ToolContextDeclaration } from '@noetic-tools/core';
interface ToolContextDeclaration<TState = unknown> {
/** Shared id -- tools with the same id share state. Defaults to tool.name. */
id?: string;
/** Factory for the initial state. */
init: () => TState;
/** Project state into the LLM context. Return null to omit. */
recall: (state: TState) => string | null;
}The recall function returns string | null -- a shorthand the runtime accepts in place of a full RecallResult. Returning a string wraps it as a developer message item automatically.
Registering Tool Call Context
Pass your tools to toolCalls() to generate the layers, then include both in your agent:
import { callModel, loop, spawn, toolCalls, until } from '@noetic-tools/core';
const tools = [writeTodos, updateTodo, listTodos];
const agent = spawn({
id: 'planner',
child: loop({
id: 'planner-loop',
steps: [callModel({
id: 'planner-llm',
model: 'anthropic/claude-sonnet-4',
instructions: 'You are a task planner.',
tools,
})],
until: until.noToolCalls(),
}),
context: [
...toolCalls(tools),
],
});toolCalls() creates one layer per unique context.id. Tools that share the same id share the same state.
Reading and Writing State
Inside a tool's execute function, use toolCtx.context to access layer state:
interface ToolContext {
get<T>(layerId: string): T | undefined;
set<T>(layerId: string, state: T): void;
}get(id)returns the current state for the layer with that id, orundefinedif uninitialized.set(id, state)replaces the state. The nextrecallcycle picks up the new value.
Full Example: Todo Tools
Multiple tools share a single context layer by declaring the same context.id. Each tool reads and writes through toolCtx.context:
import { z } from 'zod';
import { tool } from '@noetic-tools/core';
import type { ToolContextDeclaration } from '@noetic-tools/core';
//#region Types
interface TodoItem {
id: string;
description: string;
status: 'pending' | 'in_progress' | 'completed';
}
interface TodoState {
items: TodoItem[];
}
//#endregion
//#region Context Declaration
const TODO_ID = 'todos';
const todoContext: ToolContextDeclaration<TodoState> = {
id: TODO_ID,
init: () => ({ items: [] }),
recall: (state) => {
if (!state.items.length) {
return null;
}
const lines = state.items.map(
(item) => `[${item.status}] ${item.id}: ${item.description}`,
);
return `<todos>\n${lines.join('\n')}\n</todos>`;
},
};
//#endregion
//#region Tools
const writeTodos = tool({
name: 'write_todos',
description: 'Create new todo items.',
input: z.object({
items: z.array(z.string()),
}),
output: z.array(z.object({
id: z.string(),
description: z.string(),
status: z.string(),
})),
context: todoContext,
execute: async (args, toolCtx) => {
const state = toolCtx.context.get<TodoState>(TODO_ID) ?? { items: [] };
const newItems: TodoItem[] = args.items.map((desc) => ({
id: crypto.randomUUID().slice(0, 8),
description: desc,
status: 'pending' as const,
}));
toolCtx.context.set(TODO_ID, {
items: [...state.items, ...newItems],
});
return newItems;
},
});
const updateTodo = tool({
name: 'update_todo',
description: 'Update the status of a todo item.',
input: z.object({
id: z.string(),
status: z.enum(['pending', 'in_progress', 'completed']),
}),
output: z.object({
id: z.string(),
description: z.string(),
status: z.string(),
}),
context: todoContext,
execute: async (args, toolCtx) => {
const state = toolCtx.context.get<TodoState>(TODO_ID) ?? { items: [] };
const item = state.items.find((i) => i.id === args.id);
if (!item) {
throw new Error(`Todo not found: ${args.id}`);
}
item.status = args.status;
toolCtx.context.set(TODO_ID, state);
return item;
},
});
//#endregionBoth writeTodos and updateTodo declare context: todoContext with the same id. The runtime generates a single shared layer. When either tool calls toolCtx.context.set(TODO_ID, ...), the updated state is projected into the LLM's context on the next turn via the recall function.
Scope and Lifetime
toolCalls() defaults to 'execution' scope -- state lives only for the current agent run and is discarded afterward. If you need persistence across runs, write a custom context layer with 'thread' or 'resource' scope instead.
Function-Call State
In this pattern, the LLM updates layer state by emitting a function call (like calling a tool), and the context layer's store() hook intercepts it. No formal tool schema is registered -- the layer itself acts as a pseudo-tool.
The built-in scratchpad() layer uses this pattern: the LLM calls scratchpad/update, and the store hook parses the arguments and merges them into state.
How It Works
- Your system prompt instructs the LLM to call a specific function name to update state
- The LLM emits a
function_callitem with that name - Your
store()hook usesfindFunctionCall()to extract the arguments - State is updated from the parsed arguments
findFunctionCall Utility
import { findFunctionCall } from '@noetic-tools/core';
// Searches newItems for the first function_call matching the name.
// Returns parsed JSON arguments as Record<string, unknown>, or null.
const args = findFunctionCall(newItems, 'updateEntityMemory');Example: Entity-Extraction Layer
A custom layer that lets the LLM store discovered entities by calling updateEntities:
import type { ContextLayer } from '@noetic-tools/core';
import { Slot, findFunctionCall } from '@noetic-tools/core';
import { createMessage, estimateTokens } from '@noetic-tools/core';
interface Entity {
name: string;
type: string;
notes: string;
}
interface EntityState {
entities: Entity[];
}
function entityContext(): ContextLayer<EntityState> {
return {
id: 'entity-context',
name: 'Entity Context',
slot: Slot.ENTITY,
scope: 'thread',
budget: { min: 200, max: 1000 },
hooks: {
async init({ storage }) {
const saved = await storage.get<EntityState>('state');
return {
state: saved ?? { entities: [] },
};
},
async recall({ state }) {
if (!state.entities.length) {
return null;
}
const text = state.entities
.map((e) => `- ${e.name} (${e.type}): ${e.notes}`)
.join('\n');
const content = `<known_entities>\n${text}\n</known_entities>`;
return {
items: [createMessage(content, 'developer')],
tokenCount: estimateTokens(content),
};
},
async store({ newItems, state }) {
const args = findFunctionCall(newItems, 'updateEntities');
if (!args) {
return;
}
const incoming = (args.entities ?? []) as Entity[];
const updated: EntityState = {
entities: [...state.entities, ...incoming],
};
// Returning state is enough: the runtime persists it to durable scoped
// storage (key 'state') for non-execution scopes, so init() rehydrates it next run.
return { state: updated };
},
},
};
}Register the layer and instruct the LLM in the system prompt:
const agent = spawn({
id: 'researcher',
child: loop({
id: 'researcher-loop',
steps: [callModel({
id: 'researcher-llm',
model: 'anthropic/claude-sonnet-4',
instructions: `You are a research assistant.
When you discover important entities (people, organizations, concepts),
call updateEntities to remember them:
updateEntities({ entities: [{ name, type, notes }] })`,
})],
until: until.noToolCalls(),
}),
context: [entityContext()],
});Because no tool schema is registered for updateEntities, the LLM relies entirely on the system prompt instructions. This is the key trade-off: function-call state is simpler to set up but depends on clear prompting.
Combining Patterns
A single agent can use both patterns together. Tool-owned state tracks values that tools modify imperatively, while function-call state gives the LLM a channel to update state directly:
const tools = [writeTodos, updateTodo];
const agent = spawn({
id: 'planner',
child: loop({
id: 'planner-loop',
steps: [callModel({
id: 'planner-llm',
model: 'anthropic/claude-sonnet-4',
instructions: 'You are a planner. Use todos to track tasks. Call updateNotes to save observations.',
tools,
})],
until: until.noToolCalls(),
}),
context: [
...toolCalls(tools), // imperative: tools write todo state
notesContext(), // function-call: LLM writes notes
],
});