Skip to content

Extension Points

Extension points are exported functions in src/lib/custom/ that you can override to add deployment-specific behavior. Each has a no-op default — Pika works correctly without any overrides.

All files under src/lib/custom/ are protected from pika sync, so your overrides survive framework updates.

FileExportDefaultPurpose
site-admin.tsisUserAllowedAdminAccess(user)delegates to isUserSiteAdmin()Gate admin routes on custom criteria
additional-session-sources.tsgetAdditionalSessionSources(user, chatAppId)[]Add 0..N custom session sources to the sidebar
session-read-only.tsisSessionReadOnly(session, user)falseMark additional session types as read-only
request-user-id-resolver.tsresolveRequestUserId(requestedUserId, sessionUserId, ctx)undefinedOverride the user id used by message routes
session-entity-extraction.tsgetSessionEntityValue(session)session.entityIdExtract entity/account ID from a session
session-account-context.tstransformSessionAccountContext(session, user)session unchangedBackfill missing account context before sessions are returned
server-hooks.tstransformCustomUserData(data, ctx)data unchangedTransform customUserData before it reaches the converse Lambda
server-hooks.tsonAuthProviderCallback(event, provider)no-opRun logic on OAuth provider callbacks
server-hooks.tsonBeforeAuth(event, pathName, user){ clearSession: false }Clear the session conditionally before auth proceeds
chat-user-auth.tsshouldBypassChatUserRoleMerge(user)falseUse token roles as source-of-truth; skip DDB role merge
admin/site-admin-extensions.tscustomAdminPages[]Add pages to the Site Admin nav
message-visibility.tsshouldRenderMessage(message)trueHide messages from the rendered transcript without removing them from the session
trace-content-access.tsmayReceiveTraceContent(user, tracesFeatureEnabled)trueDecide whether a user may receive trace content (system prompt, tool inputs and outputs)
chat-html-allowed-tags.tsallowedCustomTags, allowedCustomAttrs[], {}Allow custom elements, or lift a default ban such as img, in the chat and trace HTML sanitizer
  1. Open the relevant file in src/lib/custom/.
  2. Replace the default function body with your implementation.
  3. The framework calls your override automatically.

Example — require AzureAD for admin access:

src/lib/custom/site-admin.ts
import { isUserSiteAdmin } from '$lib/server/utils';
import type { AuthenticatedUser, RecordOrUndef } from 'pika-shared/types/chatbot/chatbot-types';
export async function isUserAllowedAdminAccess(
user: AuthenticatedUser<RecordOrUndef, RecordOrUndef>
): Promise<boolean> {
return isUserSiteAdmin(user) && user.authData?.provider === 'azuread';
}

isUserAllowedAdminAccess — site-admin.ts

Section titled “isUserAllowedAdminAccess — site-admin.ts”

Controls which users can access the site admin panel and session insights.

export async function isUserAllowedAdminAccess(
user: AuthenticatedUser<RecordOrUndef, RecordOrUndef>
): Promise<boolean>

The default delegates to the built-in isUserSiteAdmin(user) check. Override to add additional criteria such as provider checks, group membership, or external permission lookups.


getAdditionalSessionSources — additional-session-sources.ts

Section titled “getAdditionalSessionSources — additional-session-sources.ts”

Adds 0..N custom session sources to the sidebar. Each source has its own loader, optional sidebar slots, and optional per-source read-only predicate. Pika iterates the returned array in declared order and renders one Sidebar.Group per source.

export async function getAdditionalSessionSources(
user: ChatUser<RecordOrUndef>,
chatAppId: string
): Promise<SessionSource[]>
export interface SessionSource {
/** Stable identifier for this source. Used as a Svelte key and for diagnostics. */
id: string;
/** Header label rendered above this source's session list. */
label?: string;
/**
* Loads sessions for this source. Called client-side once per chat-app init,
* in parallel with other sources via Promise.allSettled. Capture user/chatAppId
* from the enclosing getAdditionalSessionSources closure when constructing this
* descriptor — the framework does not re-pass them.
*
* Return [] for "applicable but empty." If the source is not applicable to this
* user, omit it from the getAdditionalSessionSources return array entirely.
*/
load(): Promise<ChatSession<RecordOrUndef>[]>;
/** Per-source read-only predicate. OR-ed with the top-level isSessionReadOnly hook. */
isReadOnly?: (session: ChatSession<RecordOrUndef>) => boolean;
/**
* Optional sidebar slots:
* - `trigger` renders before load completes (e.g. a "Load sessions" button)
* - `header` renders inside Sidebar.GroupLabel above the loaded session list
*/
sidebarSlot?: {
header?: Component<Record<string, never>>;
trigger?: Component<Record<string, never>>;
};
}

Rendering semantics: the sidebar renders each source's group through three states:

  • Loading: if sidebarSlot.trigger is set, render it; otherwise render a default loading row.
  • Loaded: render sidebarSlot.header (if set) inside Sidebar.GroupLabel, then the session list (or "No sessions found." when empty).
  • Errored: render sidebarSlot.header (if set) and an inline error row. The group does not collapse; sibling sources are unaffected.

Sources load via Promise.allSettled, so one failing source never breaks the others. The default returns [] (no additional sources).


isSessionReadOnly — session-read-only.ts

Section titled “isSessionReadOnly — session-read-only.ts”

Marks additional session types as read-only in the chat UI. Applies cross-cutting rules — for per-source rules, prefer SessionSource.isReadOnly.

export function isSessionReadOnly(
session: ChatSession<RecordOrUndef> | undefined,
user: ChatUser<RecordOrUndef> | undefined
): boolean

The framework already marks shared sessions as read-only. Override this hook to add further conditions — for example, marking sessions belonging to a different effective user as read-only. The result is OR-ed with the framework's shared-session predicate and with each SessionSource.isReadOnly. The default returns false.


resolveRequestUserId — request-user-id-resolver.ts

Section titled “resolveRequestUserId — request-user-id-resolver.ts”

Server-side hook called on message routes before the effective user id is used. Override to honor an alternate user id source (e.g. a separate cookie for legacy actions).

export async function resolveRequestUserId(
requestedUserId: string,
sessionUserId: string,
context: RequestUserIdResolverContext
): Promise<string | undefined>
export interface RequestUserIdResolverContext {
request: Request;
cookies: Cookies;
stage: string;
chatAppId?: string;
}

Return a non-undefined string to override the user id used for the request; return undefined (the default) to leave requestedUserId unchanged. Scope is limited to the message routes (api/message/+server.ts and api/message/[chatAppId]/[sessionId]/+server.ts).

Consumers that need a cookie name should define their own constant — the framework no longer exports one.


getSessionEntityValue — session-entity-extraction.ts

Section titled “getSessionEntityValue — session-entity-extraction.ts”

Extracts the entity or account ID from a session for display and analytics.

export function getSessionEntityValue(
session: ChatSession<RecordOrUndef>
): string | undefined

Override when your deployment stores the relevant account context in a field other than session.entityId, or when you need to normalize/transform the raw value. The default returns session.entityId || undefined.


transformSessionAccountContext — session-account-context.ts

Section titled “transformSessionAccountContext — session-account-context.ts”

Backfills missing account context onto sessions before they are returned to the client.

export function transformSessionAccountContext(
session: ChatSession<RecordOrUndef>,
user: AuthenticatedUser<RecordOrUndef, RecordOrUndef>
): ChatSession<RecordOrUndef>

Called on every session returned from the sessions API and the site-admin search endpoint. Override to copy account data from user onto sessions that were created before account context was stored on sessions. Return a new object rather than mutating the input. The default returns the session unchanged.


Called on every request after the AUTH_USER cookie is deserialized but before the ChatUser refresh and auth-provider validation steps run.

export async function onBeforeAuth(
event: RequestEvent,
pathName: string,
user: AuthenticatedUser<RecordOrUndef, RecordOrUndef> | undefined
): Promise<{ clearSession: boolean }>

Return { clearSession: true } to atomically clear all cookies and drop the current session — the request continues as unauthenticated, triggering a fresh auth flow. Use this to discard stale sessions based on path, request headers, or cookie state without editing the synced hooks.server.ts. The default returns { clearSession: false }.


shouldBypassChatUserRoleMerge — chat-user-auth.ts

Section titled “shouldBypassChatUserRoleMerge — chat-user-auth.ts”

Controls whether the framework uses roles from the authentication token instead of merging with the stored ChatUser record in the database.

export function shouldBypassChatUserRoleMerge(
user: AuthenticatedUser<RecordOrUndef, RecordOrUndef>
): boolean

When this returns true the framework will:

  • Treat roles as unchanged during periodic refreshes (no cookie re-serialization for role drift)
  • Use token roles instead of database roles when building the merged user object
  • Skip mergeAuthenticatedUserWithExistingChatUser on initial login
  • Create the ChatUser record if it is missing during a refresh rather than forcing re-authentication

This is useful when the identity provider is the authoritative source for roles and the database should not override them. The default returns false.


transformCustomUserData — server-hooks.ts

Section titled “transformCustomUserData — server-hooks.ts”

Transforms customUserData server-side before it is encoded into the message JWT and sent to the converse Lambda.

See Server Hooks for full documentation.


onAuthProviderCallback — server-hooks.ts

Section titled “onAuthProviderCallback — server-hooks.ts”

Called from hooks.server.ts whenever a request arrives at an OAuth provider callback path (/auth/callback/<provider>).

export async function onAuthProviderCallback(
event: RequestEvent,
provider: string
): Promise<void>

The provider parameter is the path segment after /auth/callback/ (e.g., "azuread", "okta"). The hook fires before the route is resolved, so cookies set here are visible to the downstream route handler.

Use this hook to run per-provider post-callback logic — such as clearing legacy session cookies or triggering a token exchange — without editing the synced hooks.server.ts. The default is a no-op.

customAdminPages — admin/site-admin-extensions.ts

Section titled “customAdminPages — admin/site-admin-extensions.ts”

Adds your own pages to the Site Admin nav. Unlike the other extension points this is an array, not a function.

export const customAdminPages: NavItem[] = [];

Entries are appended after the built-in admin pages. Each NavItem needs a title, a url of the form /admin/<slug>, an icon, and a pageComponent; the existing /admin/[page] route renders the component for the matching url. Pick a slug that doesn't collide with a built-in page.

src/lib/custom/admin/site-admin-extensions.ts
import type { NavItem } from '$lib/client/app/types';
import Gauge from '$icons/lucide/gauge';
import UsageReport from './usage-report/usage-report.svelte';
export const customAdminPages: NavItem[] = [
{
title: 'Usage Report',
url: '/admin/usage-report',
icon: Gauge,
pageComponent: UsageReport
}
];

Access to these pages is governed by the same admin gate as the rest of the section (isUserAllowedAdminAccess). The default is [], which leaves the stock admin nav.

shouldRenderMessage — message-visibility.ts

Section titled “shouldRenderMessage — message-visibility.ts”

Returns false to hide a message from the rendered transcript, for example a turn your app sends on the user's behalf.

export function shouldRenderMessage(message: ChatMessageForRendering): boolean

Hidden messages stay in the session and still count as the conversation having started. The default returns true, so every message renders.

src/lib/custom/message-visibility.ts
import type { ChatMessageForRendering } from 'pika-shared/types/chatbot/chatbot-types';
export function shouldRenderMessage(message: ChatMessageForRendering): boolean {
return !message.message.startsWith('[app-turn]');
}

mayReceiveTraceContent — trace-content-access.ts

Section titled “mayReceiveTraceContent — trace-content-access.ts”

Returns false to withhold trace content from a user.

export function mayReceiveTraceContent(user: ChatUser<RecordOrUndef> | undefined, tracesFeatureEnabled: boolean): boolean

tracesFeatureEnabled is the chat app's resolved features.traces.enabled; site-admin reads pass true. When the hook returns false, the trace route returns 403, the messages route and site-admin view strip traces, and the live stream is reduced to label-only step frames. The default returns true. It does not cover detailedTraces, which is UI-only.

src/lib/custom/trace-content-access.ts
export function mayReceiveTraceContent(user, tracesFeatureEnabled) {
return user?.userType === 'internal-user';
}

See Monitor with Traces for the full behavior.

allowedCustomTags and allowedCustomAttrs — chat-html-allowed-tags.ts

Section titled “allowedCustomTags and allowedCustomAttrs — chat-html-allowed-tags.ts”

Controls which elements survive the HTML sanitizer. Chat messages and trace content are rendered from markdown with HTML enabled, and the result is sanitized with DOMPurify before it is inserted into the page.

export const allowedCustomTags: readonly string[] = [];
export const allowedCustomAttrs: Readonly<Record<string, readonly string[]>> = {};

By default the sanitizer forbids these tags: form, input, button, select, textarea, iframe, object, embed, img, picture, source, video, audio, track, style, svg and math. It also forbids the attributes style, action, method, formaction, srcdoc, srcset, poster and background. The point is that nothing in a reply fetches a URL when it renders, since an injected URL can leak data.

  • Custom elements are removed unless their tag name is in allowedCustomTags. An allowed custom element keeps the attributes listed for its tag in allowedCustomAttrs, plus data-* attributes, which DOMPurify allows by default. Attribute names starting with on are never allowed.
  • A tag listed in allowedCustomTags is also removed from the forbidden-tag list, so listing img opts images back in. Lifting img or source also allows srcset, and lifting video allows poster; style and background stay forbidden. A lifted built-in tag keeps DOMPurify's default attributes for that tag, because allowedCustomAttrs applies to custom elements only. Lifting svg brings back DOMPurify's SVG elements, including image and feImage, which load URLs on render.
src/lib/custom/chat-html-allowed-tags.ts
export const allowedCustomTags: readonly string[] = ['order-card', 'img'];
export const allowedCustomAttrs: Readonly<Record<string, readonly string[]>> = {
'order-card': ['order-id']
};

If DOMPurify is not supported in the browser, the sanitizer logs an error and renders nothing instead of unsanitized HTML.


The v0.26.0 → v0.27.0 release replaces five ai-bot-specific hooks with three generic seams. The pika migrate v0.26.0-v0.27.0 CLI subcommand removes the orphan hook files from your consumer tree (see Migration Guides for the full procedure).

v0.26.0 hookv0.27.0 replacementMigration
loadLegacyChatsIfNeeded(user, chatAppId) → LegacySessionsResultgetAdditionalSessionSources(user, chatAppId) → SessionSource[] returning a single descriptorMove loader body into source.load(); if loaded: false was the signal, omit the source from the array instead.
getLegacyChatsSectionHeader() → Component?SessionSource.sidebarSlot.headerMove component reference onto the source descriptor.
getLegacyChatsSectionTrigger() → Component?SessionSource.sidebarSlot.triggerSame.
isCurrentSessionReadOnly(session)isSessionReadOnly(session, user)Add user to the signature. session-read-only.ts is sync-protected — pika sync does not overwrite it and pika migrate does not touch it. You must hand-edit the file. For per-source rules, prefer SessionSource.isReadOnly instead.
validateLegacyUserIdIfNeeded(effectiveUserId, sessionUserId, ctx)resolveRequestUserId(requestedUserId, sessionUserId, ctx)Argument order changed. The framework call site swapped from (user.userId, params.userId, ctx) to (params.userId, user.userId, ctx) — arg1 is now the request-supplied (attacker-influenceable) id, not the session id. Re-read your override end-to-end; do not just rename the parameter.
LEGACY_ACTION_USER_ID_COOKIE constantRemovedDefine your own constant in consumer code.
Type LegacySessionsResultRemovedNo replacement; not needed under the new shape.

Client-side hook signatures use ChatUser<U> (not AuthenticatedUser<T, U>) so server-only fields like authData are not leaked across the public hook surface. Server-side hooks (e.g. resolveRequestUserId's context) continue to use the server-side types where appropriate.

test/custom/contract.test.ts contains compile-time type assertions and runtime smoke tests for every hook. If a Pika upgrade accidentally changes a hook's signature, this test fails before the breakage reaches your deployment.

Run the tests with:

Terminal window
pnpm test --filter pika-chat -- --testPathPattern=contract