NOETIC
Framework
Context Layers

Custom Context Layers

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

Overview

Every built-in context layer is just a ContextLayer 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
SCRATCHPAD100
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 { ContextLayer, ContextLayerHooks } from '@noetic-tools/core';

function myCustomLayer(): ContextLayer<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 callModel 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 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, recall, and placement

Three 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: context 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.
  • placement — which band of the context window your output renders into: 'anchor' (before history, pinned for the prompt cache), 'live' (after history, re-rendered every turn), or 'auto' (default), which starts anchored and lets the runtime move it based on how often your output changes. See Prompt Cache Anchoring.

Leave placement at 'auto' unless you know something the runtime cannot observe. Two cases are worth an explicit value:

  • Set 'anchor' for a large payload that rarely changes — a loaded instruction file, a tracked file set. Churn takes a few turns to learn, and the anchor band is where the saving is.
  • Set 'live' when your output is genuinely different every turn — a clock, a queue drain, a per-turn reminder. Pinning it would just produce a supersede every turn.

If your recall() returns new state, the runtime treats the call as non-repeatable and renders the layer live regardless of placement — replaying an older render would discard what that call committed.

Describing a change compactly with renderDelta

An anchored layer's pinned output is re-sent unchanged until the epoch ends. When your fresh recall() output stops matching the pin, the runtime publishes the difference as one <context_updates> developer message after history. By default it republishes your whole new block there — correct, but expensive when the block is large and the change is small.

Implement renderDelta to describe the change yourself:

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

const myLayer: ContextLayer<MyLayerState> = {
  id: 'my-custom-layer',
  slot: 275,
  scope: 'thread',
  placement: 'anchor',
  hooks: {
    async renderDelta({ prev, next }) {
      const added = entriesOf(next).filter((e) => !entriesOf(prev).includes(e));
      if (!added.length) return null; // fall back to a full republish
      return `Added: ${added.join(', ')}`;
    },
  },
};

Return null when you cannot summarise the change — the runtime republishes in full. The hook is only worth writing when the payload is large and its changes are small; filesystem uses it to send the one file that changed instead of the whole set.

Step 7: Register with the Agent

Pass custom layers to spawn's context option (or the harness-level contextLayers array):

const agent = spawn({
  id: 'assistant',
  child: agentLoop,
  context: [
    scratchpad(),
    myCustomLayer(),
    observations(),
  ],
});

Configure persistence via environment.storage.adapter on the harness:

const harness = new AgentHarness({
  name: 'my-agent',
  params: {},
  environment: { storage: { adapter: 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

ScopedStorage is get / set / delete / list / getMany, all namespaced to your layer and scope. Rehydrating several keys at once — list() then read each — should go through getMany(keys) rather than a loop of get: it is one round trip on a backend that supports batch reads, and a parallel sweep on one that does not. Keys with nothing stored are absent from the returned map.

RecallParams

FieldType
logItemLog
querystring
ctxExecutionContext
stateTState
budgetnumber

RenderDeltaParams

FieldType
prevReadonlyArray<Item>
nextReadonlyArray<Item>
prevStateTState | undefined
stateTState | undefined
ctxExecutionContext
budgetnumber

prevState is held by reference, not snapshotted — a layer that mutates its state in place sees the current object here.

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.context['layerId'].prop. Function entries are also automatically injected as LLM tools, namespaced as layerId/fnName.

Use the layerData() and layerFunction() builders:

import { z } from 'zod';
import { createMessage, layerData, layerFunction } from '@noetic-tools/core';
import type { ContextLayer } 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: layerFunction<{ 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 ContextLayer<MyLayerState>;
}

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

Type-Safe Access with context() and InferContext

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

import { context, scratchpad, type InferContext } from '@noetic-tools/core';

const mem = context([scratchpad(), myCustomLayer()]);
type Mem = InferContext<typeof mem>;

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

In a runCode, access the typed context:

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

Auto-Injected LLM Tools

Every layerFunction 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 Calls -- imperative state access via toolCtx.context and function-call state patterns

On this page