NOETIC
Framework
Memory

Custom Memory Layers

How to build your own memory layer by implementing MemoryHooks, choosing a slot and scope, and configuring budgets and timeouts.

Overview

Every built-in memory layer is just a MemoryLayer object. You can build your own by implementing the same interface. This guide walks through each decision point.

Step 1: Define Your State Type

Your layer manages a single state object of type TState. Define what you need to track:

interface MyLayerState {
  entries: string[];
  lastUpdated: number;
}

Step 2: Choose a Slot

The slot determines where your layer's output appears in the assembled prompt. Lower slots appear first.

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

// Use a built-in constant...
const slot = Slot.PROCEDURAL; // 250

// ...or pick your own number
const slot = 275; // between PROCEDURAL and EPISODIC

Built-in slot constants for reference:

ConstantValue
REMINDER80
STEERING90
WORKING_MEMORY100
ENTITY150
OBSERVATIONS200
PROCEDURAL250
EPISODIC300
RAG350
SEMANTIC_RECALL400

Step 3: Choose a Scope

Scope controls when state is shared or isolated:

ScopeUse When
'execution'State should not survive past the current run
'thread'State should persist per conversation thread
'resource'State should be shared across threads for the same resource
'global'State should be shared across everything

Step 4: Configure the Budget

The budget controls how many tokens your layer can inject during recall:

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

const fixedBudget: BudgetConfig = 500;

const rangeBudget: BudgetConfig = { min: 200, max: 1500 };

const autoBudget: BudgetConfig = 'auto';

Step 5: Implement Hooks

Implement only the hooks you need. All hooks are optional.

import { createMessage } from '@noetic-tools/core';
import type { MemoryLayer, MemoryHooks } from '@noetic-tools/core';

function myCustomLayer(): MemoryLayer<MyLayerState> {
  return {
    id: 'my-custom-layer',
    name: 'My Custom Layer',
    slot: 275,
    scope: 'thread',
    budget: { min: 200, max: 1000 },
    hooks: {
      async init({ storage }) {
        const saved = await storage.get<MyLayerState>('state');
        return {
          state: saved ?? { entries: [], lastUpdated: 0 },
        };
      },

      async recall({ state, budget }) {
        if (!state.entries.length) return null;

        const text = state.entries.join('\n');
        const content = `<my_context>\n${text}\n</my_context>`;

        return {
          items: [createMessage(content, 'developer')],
          tokenCount: Math.ceil(content.length / 4),
        };
      },

      async store({ newItems, state }) {
        // Extract assistant text from the model's latest output
        const texts: string[] = [];
        for (const item of newItems) {
          if (item.type !== 'message' || item.role !== 'assistant') continue;
          const parts = Array.isArray(item.content) ? item.content : [item.content];
          for (const part of parts) {
            if (part.type === 'output_text') texts.push(part.text);
          }
        }

        if (!texts.length) return;

        return {
          state: {
            entries: [...state.entries, ...texts],
            lastUpdated: Date.now(),
          },
        };
      },

      async onComplete({ state, outcome }) {
        // Optionally finalize state based on outcome
        return {
          state: {
            ...state,
            lastUpdated: Date.now(),
          },
        };
      },
    },
  };
}

Input-side and read-side hooks

Beyond recall/store, two hooks let a layer shape what the model sees without touching storage:

  • onItemAppend runs when input items (user messages, tool outputs — never LLM responses) are about to be appended. Layers compose in slot order, each receiving the previous layer's output. Return the items to actually append: pass them through unchanged, transform them, return [] to drop them, or include extras to inject. The result may also carry an updated state and a rerender: true request (with optional timing and scope) to re-run recall mid-turn.
  • projectHistory runs once per LLM step to project (cap, summarize, redact) the history portion of the context window. It is read-side only — itemLog storage is never mutated. See History Window for a layer built entirely from this hook.

Step 6: Set Timeouts (Optional)

If any hook makes network calls or runs LLM inference, set a timeout to prevent hangs:

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

const timeouts: Partial<LayerTimeouts> = {
  store: 30_000,
  recall: 10_000,
};

Timeouts exist for every hook, including beforeToolCall, afterModelCall, onItemAppend, and projectHistory.

Failure and recall modes

Two top-level fields tune how the runtime treats your layer:

  • onInitError — what happens when init throws (or times out). The default 'throw' aborts the execution: memory is load-bearing, and silently dropping a layer hides failures. Set 'disable' only for genuinely non-critical layers; the runtime then logs a diagnostic and skips the layer's hooks for the run.
  • recallMode'atomic' (default) runs recall() in the hot path before every model call. Set 'eventual' for expensive recalls that can tolerate staleness: recall is served from a cache that refreshes after store(), so the next turn sees the update and the model call is never blocked. A harness configured with forceAtomicRecall overrides this and treats every layer as atomic.

Step 7: Register with the Agent

Pass custom layers to a react pattern (or spawn's memory option):

const agent = react({
  model: 'openai/gpt-4o',
  instructions: 'You are a helpful assistant.',
  memory: [
    workingMemory(),
    myCustomLayer(),
    observationalMemory(),
  ],
});

Configure persistence via AgentConfig.storage on the harness:

const harness = new AgentHarness({
  name: 'my-agent',
  params: {},
  storage: myStorageAdapter,
});

Layers are ordered by slot number regardless of array order. The runtime calls each layer's hooks in slot order.

State written by any path — store() hooks, provides functions, onComplete, the append pipeline — is durably mirrored for non-'execution' scopes, so the next execution's init can rehydrate it from ScopedStorage (key 'state'). Returning { state: undefined } clears the state and deletes the durable key. Mirror failures are reported as diagnostics and never interrupt the agent.

Hook Parameter Reference

InitParams

FieldType
storageScopedStorage
scopeKeystring
ctxExecutionContext

RecallParams

FieldType
logItemLog
querystring
ctxExecutionContext
stateTState
budgetnumber

StoreParams

FieldType
newItemsItem[]
logItemLog
responseLLMResponse
ctxExecutionContext
stateTState

SpawnParams

FieldType
parentStateTState
childCtxExecutionContext

ReturnParams

FieldType
childStateTState
childLogItemLog
parentStateTState
resultunknown

CompleteParams

FieldType
logItemLog
ctxExecutionContext
stateTState
outcomeExecutionOutcome

DisposeParams

FieldType
stateTState

Step 8: Add a provides Map (Optional)

The provides field exposes typed data and functions from your layer. Data entries are accessible in code steps via ctx.memory['layerId'].prop. Function entries are also automatically injected as LLM tools, namespaced as layerId/fnName.

Use the layerData() and layerFn() builders:

import { z } from 'zod';
import { createMessage, layerData, layerFn } from '@noetic-tools/core';
import type { MemoryLayer } from '@noetic-tools/core';

interface MyLayerState {
  entries: string[];
  lastUpdated: number;
}

function myCustomLayer() {
  return {
    id: 'my-custom-layer' as const,
    name: 'My Custom Layer',
    slot: 275,
    scope: 'thread',
    budget: { min: 200, max: 1e3 },
    provides: {
      // Data: read-only projection from state
      entryCount: layerData<number, MyLayerState>({
        read: (state) => state.entries.length,
      }),
      // Function: callable from code and auto-injected as LLM tool
      addEntry: layerFn<{ text: string }, void, MyLayerState>({
        description: 'Add a new entry to the custom layer.',
        input: z.object({ text: z.string() }),
        output: z.void(),
        execute: async (args, state) => ({
          result: undefined,
          state: {
            entries: [...state.entries, args.text],
            lastUpdated: Date.now(),
          },
        }),
      }),
    },
    hooks: {
      async init({ storage }) {
        const saved = await storage.get<MyLayerState>('state');
        return { state: saved ?? { entries: [], lastUpdated: 0 } };
      },
      async recall({ state }) {
        if (!state.entries.length) return null;
        const text = state.entries.join('\n');
        const content = `<my_context>\n${text}\n</my_context>`;
        return {
          items: [createMessage(content, 'developer')],
          tokenCount: Math.ceil(content.length / 4),
        };
      },
    },
  } satisfies MemoryLayer<MyLayerState>;
}

Note the as const on the id field and the satisfies MemoryLayer<MyLayerState> pattern. This preserves the literal 'my-custom-layer' type so that InferMemory can map the layer ID to its provides shape at compile time.

Type-Safe Access with memory() and InferMemory

Wrap your layers in the memory() builder to get full type inference:

import { memory, workingMemory, type InferMemory } from '@noetic-tools/core';

const mem = memory([workingMemory(), myCustomLayer()]);
type Mem = InferMemory<typeof mem>;

// Mem is:
// {
//   'working-memory': { snapshot: WorkingMemoryState; update: (args: Record<string, unknown>) => Promise<void> };
//   'my-custom-layer': { entryCount: number; addEntry: (args: { text: string }) => Promise<void> };
// }

In a step.run, access the typed memory:

const myStep = step.run({
  id: 'use-memory',
  execute: async (input: string, ctx: Context<Mem>) => {
    const count = ctx.memory['my-custom-layer'].entryCount;
    await ctx.memory['my-custom-layer'].addEntry({ text: input });
    return count;
  },
});

Auto-Injected LLM Tools

Every layerFn in a layer's provides is automatically registered as an LLM tool. The tool name follows the layerId/fnName convention. In the example above, the model sees a tool named my-custom-layer/addEntry with the description and input schema you defined.

See Also

  • Tool Memory -- imperative state access via toolCtx.memory and function-call memory patterns

On this page