Skip to content

Server Hooks

Learn how to use server hooks to enrich, validate, or transform customUserData before it is sent to the converse Lambda and ultimately passed to the AI agent as session attributes.

By the end of this guide, you will:

  • Create a server hook to transform customUserData
  • Resolve missing fields via external lookups (e.g., database, API)
  • Understand how the hook integrates into the message request lifecycle
  • Know how to enable/disable the hook
  • A running Pika installation
  • Understanding of TypeScript and async/await
  • Familiarity with the Pika customUserData and SimpleAuthenticatedUser types

When a user sends a message, the message API route (+server.ts) builds a SimpleAuthenticatedUser object containing customUserData — the user's custom attributes provided by your authentication system (e.g., accountId, accountType, companyName). This object is signed into a JWT and sent to the converse Lambda, where the data becomes session attributes available to the AI agent.

The server hooks extension point lets you transform customUserData after it is resolved from override data or the auth provider, but before it is encoded into the JWT.

Message Request:
+server.ts -> resolve customUserData -> transformCustomUserData() -> JWT -> Lambda -> Agent

Create or modify the hooks file in the protected customization area.

Location: src/lib/custom/server-hooks.ts

import type { RecordOrUndef } from 'pika-shared/types/chatbot/chatbot-types';
export interface TransformCustomUserDataContext {
userId: string;
chatAppId: string;
}
/**
* Called during the message API route before customUserData is encoded
* into the JWT and sent to the converse Lambda.
*/
export async function transformCustomUserData(
customUserData: RecordOrUndef,
context: TransformCustomUserDataContext
): Promise<RecordOrUndef> {
if (!customUserData) {
return customUserData;
}
// Example: resolve a missing accountType from an external lookup
if (!customUserData.accountType && customUserData.accountId) {
try {
const resolved = await lookupAccountType(customUserData.accountId);
if (resolved) {
return { ...customUserData, accountType: resolved };
}
} catch {
// Lookup failed — return original data unchanged
}
}
return customUserData;
}
async function lookupAccountType(accountId: string): Promise<string | undefined> {
// Replace with your actual data source
const response = await fetch(`https://your-api.example.com/accounts/${accountId}`);
if (response.ok) {
const data = await response.json();
return data.accountType;
}
return undefined;
}

The transformCustomUserData hook receives:

  • customUserData: RecordOrUndef — The user's custom data, already resolved from overrideData[chatAppId] or customData. This is Record<string, string | undefined> | undefined.
  • context: TransformCustomUserDataContext — Contains userId and chatAppId for logging and lookup context.

The hook is async and returns Promise<RecordOrUndef>. Return the transformed data, or the original data unchanged if no transformation is needed.

The core message API route automatically imports and calls your hook. No additional wiring is needed.

import { transformCustomUserData } from '$lib/custom/server-hooks';
const SERVER_HOOK_TIMEOUT_MS = 5000;
// Resolve raw custom data (override or default)
const rawCustomUserData = user.overrideData?.[params.chatAppId] || user.customData;
// Allow consumer to transform customUserData before it reaches the agent
let resolvedCustomUserData = rawCustomUserData;
if (transformCustomUserData) {
try {
const hookResult = await Promise.race([
transformCustomUserData(rawCustomUserData, {
userId: user.userId,
chatAppId: params.chatAppId
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`timed out after ${SERVER_HOOK_TIMEOUT_MS}ms`)), SERVER_HOOK_TIMEOUT_MS)
)
]);
// Guard against hooks that accidentally return undefined when data existed
if (hookResult === undefined && rawCustomUserData !== undefined) {
console.warn('[server-hooks] transformCustomUserData returned undefined; using original data');
} else {
resolvedCustomUserData = hookResult;
}
} catch (e) {
console.warn('[server-hooks] transformCustomUserData threw an error, falling back to original data:', e instanceof Error ? e.message : String(e));
}
}
const simpleUser = {
userId: user.userId,
customUserData: resolvedCustomUserData
};

The if (transformCustomUserData) guard ensures the hook is only called when it is an actual function (not null). The framework provides three safety nets:

  1. Timeout — The hook must complete within 5 seconds. If it hangs (e.g., an unresponsive external service), the framework falls back to the original data and logs a warning.
  2. Error catch — Errors thrown inside the hook are caught. The original untransformed data is used as a fallback, and a warning is logged.
  3. Undefined guard — If the hook returns undefined when the original data was defined (a common bug from missing return statements), the framework uses the original data instead.

To disable the server hook, export null instead of a function:

/**
* Server hooks disabled — no custom transformation needed.
*/
export const transformCustomUserData = null;

The hook runs in the message request path and has a 5-second timeout. If your hook doesn't resolve within that window, the framework falls back to the original data. Avoid heavy computations or long chains of sequential API calls. Cache results where possible.

External lookups may be unavailable. The framework catches errors for you, but it's better to handle them inside your hook so you can log meaningful context:

try {
const resolved = await lookupAccountType(customUserData.accountId);
if (resolved) {
return { ...customUserData, accountType: resolved };
}
} catch (err) {
console.warn('[server-hooks] Account lookup failed:', err instanceof Error ? err.message : String(err));
}
return customUserData; // Graceful fallback

Always return a new object (e.g., using spread { ...customUserData, newField: value }) rather than modifying the input directly. The input may be shared with session or cookie state.

Make sure every code path returns the data (transformed or original). If your hook returns undefined when the input was defined, the framework treats it as a bug and falls back to the original data. Avoid implicit undefined returns:

// BAD — missing return on the else branch
export async function transformCustomUserData(data, ctx) {
if (someCondition) {
return { ...data, extra: 'val' };
}
// implicit return undefined — framework will use original data
}
// GOOD — always return
export async function transformCustomUserData(data, ctx) {
if (someCondition) {
return { ...data, extra: 'val' };
}
return data;
}

Check whether transformation is necessary before doing work. Skip the hook early when data is already complete:

// Already has the field — nothing to do
if (customUserData.accountType) {
return customUserData;
}

The server-hooks.ts file lives in the $lib/custom/ protected area. Pika sync will:

  • Deliver the file on first sync if it doesn't exist locally
  • Never overwrite the file once it exists, preserving your customizations

This means you can safely modify the hook without worrying about sync overwriting your changes.