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.

Reacting to decisions with onDecision

A decide call does not append to the item log and does not run store(). To learn about decisions, implement onDecision. The runtime calls it after every decide step completes, in slot order, one layer at a time. It receives { state, record, ctx }, where record is a DecisionRecord (stepId, at, modelId, source, answers). Return { state } to keep new state, or undefined to leave it unchanged.

hooks: {
  async onDecision({ state, record }) {
    if (record.stepId !== 'triage') return undefined;
    return { state: { ...state, lastTriage: record.answers } };
  },
},

A hook that throws or times out (5s by default) is logged as a diagnostic; it does not fail the step. Decisions History is a built-in layer made from this hook.

Steering a running turn with onSteer

A message sent with deliveryMode: 'between-rounds' or 'next-item' is a steer: it joins the turn that is running (see Runtime). While a steer is queued, the model call offers it to onSteer at each steer point:

  • item_done: the stream just finished an output item. Applying here stops the stream.
  • before_tool_call: a tool call is about to run. Applying here skips this call and the rest of the round.
  • before_round: the next model call is about to start.

The hook receives { steer, point, action, pendingCalls, ctx, state }. action starts at the delivery mode's default for that point and carries any change from layers in earlier slots. Return { action?, items?, state? }; omitted fields keep their current value. This layer holds steers while a deploy runs, and stops a call early when the user says "stop":

import { SteerAction, SteerPoint } from '@noetic-tools/core';

hooks: {
  async onSteer({ steer, point, pendingCalls }) {
    if (pendingCalls.some((call) => call.name === 'deploy')) {
      return { action: SteerAction.Defer };
    }
    const text = JSON.stringify(steer.items).toLowerCase();
    if (point === SteerPoint.BeforeToolCall && text.includes('stop')) {
      return { action: SteerAction.Apply };
    }
    return {};
  },
},

Return recall: true when a steer changes what your layer should show: say the user names a different database, and your schema layer needs to render that one. The call ends before its next model request, recall runs again with the steer's text as the query, and the call continues on the new view. Any layer asking is enough.

A deferred steer is offered again at the next point. SteerAction.Drop discards it and emits steer_dropped. Applied items still pass through onItemAppend. A hook that throws or times out (5s by default) leaves the steer unchanged.

Recall can be a peek

A decide step renders your layer's recall() as a peek (RecallOptions.peek): your hook gets a deep copy of its state, the output is used, any state your recall() returns is dropped, and an eventual layer's output is not cached. Your hook is not told it is a peek. A layer that drains a queue as it renders, as the steering layer does, may change the copy in place and still has the queue for the next callModel step. The copy uses structuredClone, so a layer whose state holds functions cannot be copied and is left out of a peek. Side effects outside the returned state (a write to storage, a network call) still happen, so keep them out of recall().

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, onSteer, onItemAppend, projectHistory, and onDecision.

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

OnDecisionParams

FieldType
stateTState
recordDecisionRecord
ctxExecutionContext

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