NOETIC
Framework
Context Layers

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 StateFunction-Call State
Who writes stateTool code (imperative)The LLM (via function calls)
Layer creationAuto-generated by toolCalls()Hand-written ContextLayer
State accesstoolCtx.context.get() / .set()findFunctionCall() in store() hook
Scope'execution' by defaultAny scope you choose
Best forTracking tool side effectsScratchpads, 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, or undefined if uninitialized.
  • set(id, state) replaces the state. The next recall cycle 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;
  },
});

//#endregion

Both 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

  1. Your system prompt instructs the LLM to call a specific function name to update state
  2. The LLM emits a function_call item with that name
  3. Your store() hook uses findFunctionCall() to extract the arguments
  4. 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
  ],
});

On this page