Control memory with lifecycle hooks
The @neo4j-labs/nams-ai-provider package talks only to the hosted Neo4j
Agent Memory Service (NAMS) at https://memory.neo4jlabs.com — it has no Bolt
equivalent for a self-managed Neo4j database.
Hooks mode takes the model out of the memory loop entirely: your code loads
the session transcript before every generation and saves it after
(prepare/onFinish), so nothing memory-related appears in the tool schema
and every turn is captured exactly once, regardless of what the model
decides. Lifecycle hooks add control around that cycle — block a prompt, deny
a tool call, rewrite a result, redact a write, retry a flaky tool, add
context for the next turn — at eight points in the generation’s lifecycle.
This page walks through a complete, runnable program that wires all eight.
A runnable copy of this program, alongside the other three modes, lives at
typescript/examples/nams-ai-provider.
Prerequisites:
-
Node.js 22 or newer (the package’s declared floor).
-
A
MEMORY_API_KEYfrom memory.neo4jlabs.com. -
An
OPENAI_API_KEY. The program uses@ai-sdk/openaiby default; to use another provider, pass its model factory to thehooksModeDemo()call at the bottom of the file. -
A Node.js project set up as an ES module, with the packages installed:
npm init -y # only if the directory has no package.json yet npm pkg set type=module npm install @neo4j-labs/[email protected] @neo4j-labs/[email protected] ai@^7 @ai-sdk/openai@^4 zod@^4 npm install --save-dev tsx
The program
hooks-mode.ts/**
* Lifecycle hooks mode: your code loads and saves the session transcript
* around every generation (`prepare` / `onFinish`); nothing memory-related is
* shown to the model. Lifecycle hooks add control around that. The four turns
* below exercise all eight events. SessionStart and SessionEnd run once per
* session; UserPromptSubmit, PreMemoryWrite and Stop run on every turn; the
* three tool events run when their tool is called:
*
* SessionStart adds a note about the user
* UserPromptSubmit redacts card numbers before the model sees them
* PreToolUse denies the destructive tool
* PostToolUse carries a note into the next turn
* PostToolUseFailure retries a flaky tool once, then gives up
* PreMemoryWrite redacts again, before it reaches the graph
* Stop logs what was saved
* SessionEnd runs on shutdown
*
* Run with:
*
* MEMORY_API_KEY=nams_... OPENAI_API_KEY=sk-... npx tsx src/hooks-mode.ts
*/
import { realpathSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { createNams, type NamsHookConfig } from '@neo4j-labs/nams-ai-provider';
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel } from 'ai';
import { z } from 'zod';
const userId = process.env.NAMS_DEMO_USER ?? 'demo-user-hooks-mode';
const modelId = process.env.NAMS_DEMO_MODEL ?? 'gpt-5.4-mini';
const CARD = /\b(?:\d[ -]*?){13,16}\b/g;
const redact = (text: string): string => text.replace(CARD, '[redacted card]');
/** Run the four-turn hooks demo. `buildModel` defaults to OpenAI; tests pass a mock model factory. */
export async function hooksModeDemo(buildModel: (id: string) => LanguageModel = openai): Promise<string[]> {
const apiKey = process.env.MEMORY_API_KEY;
if (!apiKey) {
throw new Error('Set MEMORY_API_KEY before running this example. Get a free key at https://memory.neo4jlabs.com');
}
let flakyAttempts = 0;
const hooks: NamsHookConfig = {
SessionStart: [({ reason }) => ({ additionalContext: `Session ${reason}. The user is on the free plan.` })],
UserPromptSubmit: [({ prompt }) => {
const clean = redact(prompt);
return clean === prompt ? undefined : { updatedPrompt: clean, systemMessage: 'Redacted a card number.' };
}],
PreToolUse: [{
matcher: 'delete_account',
hooks: [() => ({ permissionDecision: 'deny' as const, permissionDecisionReason: 'Account deletion is disabled in this demo.' })],
}],
PostToolUse: [{
matcher: 'get_weather',
hooks: [({ output }) => ({ additionalContext: `Last weather lookup: ${JSON.stringify(output)}` })],
}],
PostToolUseFailure: [{
matcher: 'flaky_lookup',
hooks: [({ attempt }) => (attempt === 1 ? { retry: true, systemMessage: 'flaky_lookup failed once, retrying.' } : undefined)],
}],
PreMemoryWrite: [({ turns }) => ({ updatedTurns: turns.map(t => ({ ...t, content: redact(t.content) })) })],
Stop: [({ turns }) => { console.log(` [hook] saved ${turns.length} turns`); }],
SessionEnd: [({ reason }) => { console.log(` [hook] session ended (${reason})`); }],
};
const nams = createNams({ apiKey, endpoint: process.env.MEMORY_ENDPOINT, workspaceId: process.env.MEMORY_WORKSPACE_ID });
const session = nams.hooks({ userId, hooks }); // one instance: prepare + onFinish share a client
const get_weather = tool({
description: 'Current weather for a city',
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, condition: 'sunny', tempC: 21 }),
});
const delete_account = tool({
description: 'Permanently delete the user account',
inputSchema: z.object({ confirm: z.boolean() }),
execute: async () => ({ deleted: true }),
});
const flaky_lookup = tool({
description: 'Look up a fact. Fails the first time it is called in this process.',
inputSchema: z.object({ topic: z.string() }),
execute: async ({ topic }) => {
flakyAttempts += 1;
if (flakyAttempts === 1) throw new Error('transient lookup error');
return { topic, fact: `${topic} is well documented` };
},
});
const INSTRUCTIONS = 'You are a helpful assistant.';
const agent = new ToolLoopAgent({
model: buildModel(modelId),
instructions: INSTRUCTIONS,
tools: session.withHooks({ get_weather, delete_account, flaky_lookup }), // wrapping is what runs the tool hooks
callOptionsSchema: z.object({ userId: z.string(), prompt: z.string(), memoryContext: z.string().optional() }),
// Hook context goes in instructions -- the AI SDK rejects system messages in `messages`.
prepareCall: ({ options, ...settings }) => ({
...settings,
instructions: [INSTRUCTIONS, options?.memoryContext].filter(Boolean).join('\n\n'),
runtimeContext: options,
}),
onFinish: session.onFinish(),
stopWhen: stepCountIs(5),
});
async function turn(label: string, message: string): Promise<string> {
console.log(`\n--- ${label}`);
console.log(`user: ${message}`);
// prepare() runs SessionStart and UserPromptSubmit, then loads the history.
const prepared = await session.prepare({ userId, prompt: message });
if (prepared.blocked) {
console.log(`blocked: ${prepared.blockReason}`);
return prepared.blockReason ?? '';
}
const { text } = await agent.generate({
messages: prepared.messages,
options: { userId, prompt: prepared.prompt, memoryContext: prepared.instructions },
});
console.log(`assistant: ${text}`);
return text;
}
const answers = [
await turn('Turn 1 -- a tool call the hooks allow', 'What is the weather in Oslo?'),
await turn('Turn 2 -- a flaky tool the hooks retry', 'Look up a fact about graph databases for me.'),
await turn('Turn 3 -- a tool call the hooks deny', 'Please delete my account, I confirm.'),
await turn('Turn 4 -- sensitive input', 'My card is 4111 1111 1111 1111, remember it.'),
];
await session.end({ userId, reason: 'demo_complete' });
return answers;
}
// Run only when executed directly, not when a test imports this file.
const entry = process.argv[1];
if (entry && import.meta.url === pathToFileURL(realpathSync(entry)).href) {
hooksModeDemo().catch(err => {
console.error(err);
process.exit(1);
});
}
How the program works
The diagram shows where each of the eight events fires around one generation. The API reference lists every event’s input and output fields and the matcher, timeout, and failure rules.
-
redact(text)replaces anything that looks like a 13–16 digit card number with[redacted card]; two different hooks call it below. -
Build a
hooks: NamsHookConfigobject with one entry per event this demo needs:-
SessionStart— a bare handler (matches every occurrence) that addsadditionalContextnoting the session’sreason(createdorresumed). -
UserPromptSubmit— redacts the incoming prompt; if redaction changed anything it returns{ updatedPrompt, systemMessage }, otherwiseundefined(no rewrite). -
PreToolUse— a group withmatcher: 'delete_account', so it only runs for that tool, returning{ permissionDecision: 'deny', permissionDecisionReason }. -
PostToolUse— a group matching'get_weather'that adds the tool’s output asadditionalContextfor the next turn. -
PostToolUseFailure— a group matching'flaky_lookup'that retries once: it returns{ retry: true, systemMessage }only whenattempt === 1, returningundefined(no further retry) on the second failure. -
PreMemoryWrite— a bare handler that maps every turn’scontentthroughredact()and returns{ updatedTurns }.UserPromptSubmithas already redacted the prompt that is saved, so this is a second line of defence: it catches a card number that the model’s answer or a tool-call record echoes back before the turns reach the graph. -
StopandSessionEnd— bare handlers that justconsole.loga summary; demonstrating that a hook doesn’t have to return anything.
-
-
createNams({ apiKey, endpoint, workspaceId })builds the memory client, thennams.hooks({ userId, hooks })returns one session handle —prepareandonFinishbelow share it, so they share the same pending-context queue and session-started bookkeeping. NoconversationIdis passed, so the handle continues the user’s most recent NAMS conversation and creates one only if the user has none. -
Define three AI SDK tools (
get_weather,delete_account,flaky_lookup) with plainexecutefunctions —flaky_lookupthrows on its first call in the process and succeeds after. -
Build the agent:
-
tools: session.withHooks({ get_weather, delete_account, flaky_lookup })— wrapping is what actually runs thePreToolUse/PostToolUse/PostToolUseFailurehooks; an unwrapped tool set would skip them. -
callOptionsSchemadeclares{ userId, prompt, memoryContext? }as the per-call options shape. -
prepareCallfoldsoptions.memoryContext(the hooks'additionalContext) intoinstructions, and copiesoptionsontoruntimeContextsoonFinishcan read the scope for this call. The AI SDK rejects system messages insidemessages, which is why hook context travels throughinstructionsinstead. -
onFinish: session.onFinish()— persists every turn once the tool loop finishes.
-
-
Define a
turn(label, message)helper:-
session.prepare({ userId, prompt: message })runsSessionStart(once per scope) andUserPromptSubmit, then loads prior history. If a hook blocked the prompt,prepared.blockedistrueand the helper returns the block reason without calling the model. -
Otherwise
agent.generate({ messages: prepared.messages, options: { userId, prompt: prepared.prompt, memoryContext: prepared.instructions } })runs the generation.prepared.promptis the prompt after anyUserPromptSubmitrewrite — pass that one on, not the original.
-
-
Run four turns, each exercising a different hook, then call
session.end({ userId, reason: 'demo_complete' })to fireSessionEndand reset the session-started bookkeeping for this scope.
Run and verify
Run the saved file with both keys set:
MEMORY_API_KEY=nams_... OPENAI_API_KEY=sk-... npx tsx hooks-mode.ts
To run the copy in the repository instead, follow the setup in the
example’s
README, then run npm run hooks-mode from
typescript/examples/nams-ai-provider/. The Run with comment at the top of
the program shows the path of that copy.
Expected output (wording, tool calls, and therefore the saved-turn counts
vary with the model). The [nams] lines are hook system messages and
warnings from the package’s default logger, which writes them to stderr:
--- Turn 1 -- a tool call the hooks allow
user: What is the weather in Oslo?
[hook] saved 4 turns
assistant: It's sunny in Oslo, 21C.
--- Turn 2 -- a flaky tool the hooks retry
user: Look up a fact about graph databases for me.
[nams] PostToolUseFailure hook: flaky_lookup failed once, retrying.
[hook] saved 4 turns
assistant: Graph databases are well documented.
--- Turn 3 -- a tool call the hooks deny
user: Please delete my account, I confirm.
[nams] PreToolUse denied delete_account: Account deletion is disabled in this demo.
[hook] saved 4 turns
assistant: I can't do that -- account deletion is disabled in this demo.
--- Turn 4 -- sensitive input
user: My card is 4111 1111 1111 1111, remember it.
[nams] UserPromptSubmit hook: Redacted a card number.
[hook] saved 2 turns
assistant: Got it, but I can't store card numbers.
[hook] session ended (demo_complete)
Check three things:
-
The turn 2 tool call succeeds only because
PostToolUseFailureretried it once. -
The turn 3 tool call never runs, because
PreToolUsedenied it. The model gets the denial back as the tool’s result, which is why turn 3 still saves four turns: the prompt, a[tool-call]audit turn, a[tool-result]audit turn holding the denial, and the answer. Turns 1 and 2 save the same four; turn 4 has no tool call, so it saves two. -
The card number typed in turn 4 never reaches NAMS unredacted.
UserPromptSubmitrewrote the prompt before the model saw it, and that rewritten prompt is the oneonFinishsaves.