Skip to content

@presencelearning/ai

AI integration library for Presence Learning. Provides streaming chat against Chiron, background job management, session and feedback APIs, and UI components for AI-powered features using the Vercel AI SDK.

Current version: 4.0.1. Ships as a root entry point plus per-component secondary entry points for the presentational pieces (@presencelearning/ai/entry-button, /alert, /markdown, etc.) — every component is still importable from the bare @presencelearning/ai root too. See “Secondary entry points” below for the full list and how entries are wired up.

Terminal window
npm install @presencelearning/ai @ai-sdk/angular ai marked

Declared in package.json:

  • @angular/core >=18.0.0 <22.0.0
  • @angular/common >=18.0.0 <22.0.0
  • @ai-sdk/angular ^2.0.0
  • ai ^6.0.0
  • rxjs ^7.0.0
  • marked >=15.0.0 <19.0.0

Additionally required at runtime, depending on what you use:

  • provideHttpClient() — AISessionService, AIAgentService, AIFeedbackService, and AIJobManager all inject HttpClient.
  • @angular/material — UserPromptDialogComponent (button, dialog, form-field, input, radio) and AIToolProgressComponent (icon).
  • @angular/forms — UserPromptDialogComponent.

Configure the library once in your root providers. Because the auth callback is invoked per request (not during construction), resolve your token source in the factory and close over it:

app.config.ts
import { ApplicationConfig, inject } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { PRESENCE_AI_CONFIG, PresenceAIConfig } from '@presencelearning/ai';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{
provide: PRESENCE_AI_CONFIG,
useFactory: (): PresenceAIConfig => {
const authStore = inject(AuthStore);
return {
chiron: { baseUrl: environment.apps.chiron.url },
workplace: { baseUrl: environment.apps.apiWorkplace.url },
auth: { getToken: () => authStore.getCurrentToken() },
};
},
},
],
};

Do not write getToken: () => inject(AuthStore).getCurrentToken(). getToken() runs inside each service’s getAuthHeaders() at request time, outside any injection context, so inject() throws NG0203.

When the token does not come from DI, providePresenceAI() is a shorthand for the same token:

app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { providePresenceAI } from '@presencelearning/ai';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
providePresenceAI({
chiron: { baseUrl: environment.apps.chiron.url },
workplace: { baseUrl: environment.apps.apiWorkplace.url },
auth: { getToken: () => tokenStore.token },
}),
],
};

providePresenceAI() registers the config with useValue, so it cannot resolve DI dependencies itself — use the useFactory form above when it needs to.

FeatureDescription
ChatPresenceChatFactory for streaming chat with tool call tracking. create(options) starts a new session; joinSession(options) attaches to an existing one and can seed prior turns via initialMessages to resume
TransportChironChatTransport — the SSE transport that talks to Chiron and surfaces ChironToolResults
SessionsAISessionService — listConversations (browse, search/pagination), getMessages (full-fidelity transcript), rename, delete
AgentsAIAgentService.getSuggestions(agentId) — starter prompt suggestions from the serving plane
FeedbackAIFeedbackService — submit, getForSession, positive, negative, retract
JobsAIJobManager — start, getStatus, cancel, poll for background AI jobs
ErrorsAIError plus AINetworkError, AIJobError, AIParseError, AIAuthError
Status messagesAI_STATUS_MESSAGES, getRandomAIStatusMessage(), createAIStatusMessageRotator()

AISessionService methods are option-object based: getMessages and delete take SessionOptions, listConversations takes ListConversationsOptions (adds search/limit/offset), and rename takes RenameConversationOptions. None accept a version — the agent version is pinned server-side to the one a session started on.

ComponentSelectorDescription
PresenceChatPanelComponentpl-ai-chat-panelFull chat drawer — message list, composer, and ask_user prompt handling. See inputs below
AIMarkdownComponentpl-ai-markdownRenders AI-generated markdown (bold, italic, code, lists, links, blockquotes, tables). marked parses it; Angular’s DomSanitizer then sanitizes the resulting HTML
AIToolStatusComponentpl-ai-tool-statusDisplays tool invocation status during streaming. Inputs: showCompleted, labels. See tool labels below
AIToolProgressComponentpl-ai-tool-progressStreaming tool progress with step descriptions and summaries. Input: idleMessage
SparkleLoaderComponentpl-sparkle-loaderAnimated loading indicator with therapy-themed status messages
UserPromptDialogComponentpl-user-prompt-dialogMaterial dialog for Chiron ask_user tool prompts (radio options + optional free text)
InputTypeDescription
agentIdstringRequired. Chiron agent id to converse with
systemPromptstringOptional system prompt override
initialMessagestringMessage sent automatically once the panel mounts
placeholderstringPlaceholder/aria-label for the composer. Default 'Type a message…'
chatPresenceChatHost-owned chat instance. Read once in ngOnInit; the panel will not destroy() it. Omit to have the panel create and own one
localPrompt{ question, options } | nullHost-owned prompt rendered with the same markup as an agent ask_user prompt. An active agent prompt takes precedence
localEntriesReadonlyArray<{ id, kind, text }>Host-owned 'status' / 'note' entries rendered after the message list. Pass a new array reference to update (OnPush)
suggestionsreadonly string[]Starter prompt chips shown before the conversation starts. Clicking one sends it as the first message
useAgentSuggestionsbooleanWhen suggestions is empty, fetch the agent’s configured suggestions from chiron. Default false
emptyStateTemplateTemplateRefHost content shown before the conversation starts. Usually supplied as an <ng-template plAiChatEmptyState> child instead
enableFeedbackbooleanThumbs up/down feedback under finished assistant replies, sent to chiron. Clicking the active thumb takes the vote back. Restores earlier votes on reopened conversations. Default false
showFeedback(message: ChatMessage) => booleanWhich replies get feedback when enableFeedback is on. Default: all of them
stepsLabelstring | (count: number) => stringHeading of each reply’s steps log, with {count} for the number of steps. Default “Review 3 steps”
enableAttachmentsbooleanAttach button, plus drag and drop and paste onto the input. See File attachments. Default false
attachmentRulesAIFileAttachmentRulesNarrows which files can be attached: types, maxFiles, maxBytes. Defaults to Chiron’s limits

Outputs:

  • localPromptAnswered: EventEmitter<string> — emits the chosen option when a localPrompt chip is clicked.
  • turnCompleted: EventEmitter<ChatMessage> — emits the assistant’s final message each time a response finishes streaming, e.g. to read a <conversation-title> tag and rename the session. It doesn’t emit for messages loaded from history or for turns that end in an error. A stopped response emits its partial message, unless it was stopped before any reply arrived. pl-ai-chat-window forwards the same output.
  • feedbackSubmitted: EventEmitter<{ message, messageIndex, submission }> — emits each feedback vote as the user submits it, whether or not the request to chiron succeeds, so a host can record it elsewhere too. messageIndex is the message’s position in the full message list, as chiron numbers it. pl-ai-chat-window forwards it, along with the enableFeedback and showFeedback inputs.
  • feedbackRetracted: EventEmitter<{ message, messageIndex }> — emits when the user takes a vote back by clicking the active thumb again. The panel records the undo in chiron.
  • feedbackFailed: EventEmitter<{ message, messageIndex, rating, error }> — emits when saving a vote or undo fails. The panel has already put the earlier vote back, so use this to tell the user, e.g. with a “Couldn’t save feedback” toast. rating is what failed to save ('none' for an undo). It doesn’t fire for a change the user has since replaced. pl-ai-chat-window forwards both outputs.

While a response streams, the composer’s Send button becomes a Stop button. After a stop, the panel shows “Response was interrupted”. After an error, it shows the error text. Both offer Try again, which re-sends the last user message as a new turn (attachments aren’t re-sent). Hosts can call the panel’s stop() and retry() methods directly too. A standalone pl-ai-chat-input takes a streaming input and emits (stop).

Before the first message, the panel shows an optional host template and any suggestion chips. Both disappear once the conversation starts. pl-ai-chat-window takes the same suggestions and useAgentSuggestions inputs and template child, and shows them again for every new conversation:

<pl-ai-chat-window agentId="admin-copilot" agentName="AI Copilot" [useAgentSuggestions]="true">
<ng-template plAiChatEmptyState>
<p>What would you like help with?</p>
</ng-template>
</pl-ai-chat-window>

Import AIChatEmptyStateDirective alongside the panel or window to use the template.

In pl-ai-chat-window’s history view, each past conversation has an options menu with Rename (edit the title in place: Enter saves, Escape cancels) and Delete (confirmed in the row). The window saves them through AISessionService and updates the list right away:

  • Deleting the open conversation starts a new chat, and conversationDeleted emits the session ID.
  • If a rename or delete fails, the window reloads the list to undo its change and emits historyActionFailed ({ action, sessionId, error }), e.g. for a toast.
  • Set [conversationActions]="false" to hide the menu.

A standalone pl-ai-conversation-history takes an actions input and only emits renameRequested / deleteRequested; the host saves them and passes back updated conversations.

Two opt-in options on pl-ai-chat-panel and pl-ai-chat-window smooth out how streaming looks:

  • revealText: reveals a streaming reply at a steady pace instead of in network-sized bursts. The panel stays busy (Stop shown, no sending) until the reveal catches up; pressing Stop then shows the rest at once. Replies loaded from history show in full.
  • holdToolStatus: keeps each tool status on screen for at least 500ms, so fast tool calls don’t flicker.

Both are off by default. They match what edu-clients Provider AI Chat does today.

Hosts can add their own buttons inside the chat input, before the textarea (plAiChatInputPrefix) or after it, next to Send (plAiChatInputSuffix). The template gets the input’s state and controls as let-input: text, disabled, streaming, setText(), appendText() and focus(). Use this for app-owned features like dictation, which stays in the host because the Web Speech API sends audio to Google in Chrome:

<pl-ai-chat-window agentId="content-lake" agentName="Presence AI">
<ng-template plAiChatInputSuffix let-input>
<app-dictate-button (transcript)="input.setText($event)" />
</ng-template>
</pl-ai-chat-window>
  • Give every host button type="button", including the one inside a component like app-dictate-button. The templates render inside the input’s <form>, and a <button> with no type submits it, sending the draft.
  • Keep a dictation control mounted while it’s listening. Wrapping it in @if (!input.text) destroys it as soon as the first transcript fills the input, cutting off the rest of the dictation. To hide an idle mic once the user has typed, gate it on your own listening state as well, e.g. @if (listening() || !input.text).

The same templates work on pl-ai-chat-panel and a standalone pl-ai-chat-input. Import AIChatInputPrefixDirective / AIChatInputSuffixDirective to use them.

While the agent works, pl-ai-chat-panel and pl-ai-chat-window show one line of status text with animated typing dots, naming what the agent is doing now: the label of the tool that’s running, or “Thinking…” between tools. It updates as each tool runs, is announced politely to screen readers, and stays until the reply has fully rendered, including a revealText reveal. holdToolStatus keeps each tool’s label up for at least 500ms.

When the reply finishes, the status line goes away and the reply’s completed tool calls collapse into a steps log above it (“Review 3 steps”), collapsed by default. Steps use each tool’s completed label. Failed tool calls aren’t listed, and a reply that used no tools has no log. Replies reopened from history list the steps from the transcript’s tool calls. Set stepsLabel to change the heading, e.g. 'Processing {count} steps', or pass a function of the count.

This replaces the earlier sparkle loader and list of tool calls. pl-ai-tool-status is still exported for standalone use, and pl-ai-loading-message takes a message input for any status text.

pl-ai-tool-status shows each tool’s name in Title Case (get_session_notes becomes “Get Session Notes”). To show custom text instead, register labels once with provideAIToolStatusLabels(). This also covers the status line and steps log in pl-ai-chat-panel and pl-ai-chat-window, which use running and completed respectively:

import { provideAIToolStatusLabels } from '@presencelearning/ai';
providers: [
provideAIToolStatusLabels({
// Same text for every status
search_students: 'Searching students',
// `running` while pending/running/errored, `completed` once done
get_session_notes: { running: 'Reading SOAP notes...', completed: 'Read SOAP notes' },
}),
];

A standalone pl-ai-tool-status also takes a labels input with the same shape. Its entries win over provided labels for any tool both define. Tools with no label keep the Title Case name.

Some agents add tags to a reply that are meant for the app, not the user, such as <conversation-title>. Register them with provideAIHiddenContentBlocks() to strip them from the rendered message, including while they stream in. The raw message content keeps the tag, so you can still read it, e.g. from turnCompleted:

import { provideAIHiddenContentBlocks } from '@presencelearning/ai';
// Root ApplicationConfig providers only, like provideAIContentBlockRenderers
providers: [...provideAIHiddenContentBlocks(['conversation-title', 'library-results'])];

Agents can return structured content that the chat draws with your own components:

  • Content blocks: a custom tag in the reply text, e.g. <next-steps>["Plan the next session"]</next-steps>. The tag’s body is parsed as JSON.
  • Tool results: a completed tool call, keyed by the chiron tool name.

A renderer is a component with a data input. Register renderers in the root providers, or provide AIRendererRegistry alongside them on one component to scope them there:

import { provideAIContentBlockRenderers, provideAIToolRenderers } from '@presencelearning/ai';
providers: [
...provideAIContentBlockRenderers([{ key: 'next-steps', component: NextStepsComponent }]),
...provideAIToolRenderers([{ key: 'display_chart', component: AIDisplayChartComponent }]),
];

A renderer that needs the chat injects AI_BLOCK_CONTEXT. Renderers that only take data don’t need it.

MemberDescription
messageId, sessionIdThe message the block is in, e.g. for analytics
statusSignal: streaming, awaiting-input (latest message, finished) or complete (anything has followed it)
answerSignal: the text of the user message that followed, or null
respond(text)Answers the block’s question as the next user message. Only works while awaiting-input, so an answered block can’t answer again
send(text), send({ text, files })Sends new input, e.g. from a file dropzone, regardless of status. Only sends while the chat is idle: like the input, it does nothing while a reply is streaming or being revealed
import { Component, computed, inject, input } from '@angular/core';
import { AI_BLOCK_CONTEXT, AISuggestionChipComponent } from '@presencelearning/ai';
@Component({
selector: 'app-next-steps',
imports: [AISuggestionChipComponent],
template: `
@for (step of data(); track step) {
<pl-ai-suggestion-chip
[label]="step"
[selected]="context?.answer() === answerFor(step)"
[disabled]="!canAnswer()"
(pressed)="context?.respond(answerFor(step))"
/>
}
`,
})
export class NextStepsComponent {
readonly data = input<string[]>([]);
// Optional, so the renderer also works outside a chat
readonly context = inject(AI_BLOCK_CONTEXT, { optional: true });
readonly canAnswer = computed(() => this.context?.status() === 'awaiting-input');
answerFor(step: string): string {
return `Next step: ${step}`;
}
}

status and answer are read from the transcript, so an answered block shows the same receipt after a reload or when a conversation is reopened from history. Make answers self-describing (“Selected appointment: Jane D., Tue 3:00pm”, not “Tue 3pm”), since the agent may not have the block’s options in its context. pl-ai-chat-panel and pl-ai-chat-window provide the context. To render blocks yourself, pass one to pl-ai-content-blocks / pl-ai-tool-parts through their context input, and keep it the same object for the life of the message, or the renderers are recreated. See the Examples/RendererContext story.

AIMarkdownComponent link handling: all links render with target="_blank" rel="noopener noreferrer". Absolute http(s):/mailto: URLs and same-origin app paths are allowed; protocol-relative, backslash, javascript:, and data: URLs are neutered to #.

PresenceChat.send() accepts files. CHIRON_FILE_ATTACHMENT_LIMITS mirrors Chiron’s server-side limits so the UI validates against the same source of truth:

  • maxFiles: 5
  • maxTotalBytes: 20 MB for all the files on one message together
  • application/pdf: 15 MB
  • text/csv, text/markdown, text/plain, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, image/jpeg, image/png, image/gif, image/webp: 5 MB each

Chiron also rejects two files with the same name in one message.

Set enableAttachments on pl-ai-chat-window or pl-ai-chat-panel to add an attach button to the input. Files can also be dropped onto the input, which shows a drop state, or pasted into it. Queued files show as square thumbnails in one row above the text, scrolling sideways with arrows when they don’t fit. Hovering or focusing a thumbnail shows its file name and an X to remove it. Sent files show on the user’s message.

Invalid files are rejected with one message under the input, announced to screen readers, e.g. "notes.docx" isn't a supported file type. Try CSV, Markdown, text, PDF, Excel or image.

attachmentRules narrows the limits for one use, e.g. an agent that only reads PDFs. It can only tighten Chiron’s limits: types Chiron doesn’t accept are dropped, and maxFiles and maxBytes are capped at Chiron’s.

<pl-ai-chat-window
agentId="report-reviewer"
agentName="Report reviewer"
[enableAttachments]="true"
[attachmentRules]="{ types: ['application/pdf'], maxFiles: 1 }"
/>

types takes MIME types, or a wildcard like image/*.

A standalone pl-ai-chat-input takes attachments and attachmentRules. When files are attached, a submit emits (sendWithFiles) with { text, files } instead of (send); text may be empty. Host input actions can queue files with input.addFiles(files).

Attachments on a reopened conversation don’t show yet. Chiron records which files each turn read, but its transcript API doesn’t return them.

pl-ai-file-dropzone is a larger “Choose or drop a file” area for workflow-style uploads, with a type and size hint and a Choose file button. It validates the files and emits the valid ones from (filesSelected). It never sends anything itself, so a custom renderer can send the files through its AIBlockContext:

<pl-ai-file-dropzone
[rules]="{ types: ['application/pdf'] }"
hint="PDF · Up to 15 MB"
[disabled]="context?.status() !== 'awaiting-input'"
(filesSelected)="context?.send({ text: 'Review this report', files: $event })"
/>

send() does nothing while the chat is busy, so disable the dropzone unless the block is awaiting input. Otherwise files picked while a reply streams are dropped.

InputTypeDescription
rulesAIFileAttachmentRulesNarrows which files are accepted. Defaults to Chiron’s limits
multiplebooleanAccept several files at once. Default false
labelstringDefault 'Choose or drop a file'
hintstringDefaults to the accepted types and size, e.g. “PDF · Up to 15 MB”. '' hides it
buttonLabelstringDefault 'Choose file'
disabledbooleanDefault false

The same checks are exported for hosts with their own upload UI: validateFiles(files, existing, limits) splits files into accepted and rejections, resolveAttachmentLimits(rules) gives Chiron’s limits narrowed by rules, and fileRejectionMessage(rejections, limits) gives one message for a batch. describeAttachmentHint(limits) and formatFileSize(bytes) format hint text.

Messages can also be sent hidden from the transcript — send(text, { hidden: true }) or send({ text, files, hidden: true }).

ExportDescription
createAskUserHandlerCreates a reactive handler for the ask_user tool lifecycle — provides a pendingPrompt signal and a respond() method
getRandomAIStatusMessagePicks one entry from AI_STATUS_MESSAGES
createAIStatusMessageRotatorReturns a rotator that cycles status messages while a request is in flight
isPlIcon-style guardsnone — see @presencelearning/icons for icon helpers
TypeDescription
PresenceAIConfigShape of PRESENCE_AI_CONFIG — chiron.baseUrl, workplace.baseUrl, auth.getToken
PresenceChatChat instance with Angular signals for messages, status, streaming, tool calls, and deployedVersion
PresenceChatOptions / JoinSessionOptionsArguments to PresenceChatFactory.create() / .joinSession()
PresenceChatSendInputArgument to PresenceChat.send() (text, optional files, optional hidden)
ChatMessageA single transcript entry, including files
ChatStatusChat lifecycle status
ToolCallPart / ToolCallStatusTool call parts surfaced during streaming
ChironChatTransportOptionsOptions for ChironChatTransport
ChironToolResultTool result from the Chiron SSE stream
UserPromptPending ask_user prompt data (question, options, allowCustom, toolCallId)
AskUserHandlerReturn type of createAskUserHandler
UserPromptDialogDataInput data for UserPromptDialogComponent (adds optional title)
UserPromptDialogResultDialog return value (response string + isCustom flag)
ToolDescriptionMapMaps tool names to user-friendly descriptions for AIToolProgressComponent
ToolSummarizerFunction that extracts a summary string from a ChironToolResult
SessionOptions / SessionMessage / SessionToolCall / ConversationSummary / ConversationPage / ListConversationsOptions / RenameConversationOptionsAISessionService argument and response shapes
FeedbackParams / FeedbackRating / SessionFeedbackEntryAIFeedbackService argument and response shapes. Feedback is append-only; retract() records a 'none' rating
JobStatus / JobState / JobResult / JobTracker / StartJobOptions / PollOptionsAIJobManager types
AIStatusMessageA single entry in AI_STATUS_MESSAGES

Importing anything from the root @presencelearning/ai barrel bundles the whole package, including the chat/session/job/feedback machinery and its heavy peer dependencies (ai, @ai-sdk/angular, chart.js, ng2-charts). If you only need one small presentational component, import it from its own subpath instead so those peer dependencies are never pulled into your bundle:

SubpathExports
/entry-buttonAIEntryButtonComponent
/alertAIAlertComponent
/loading-messageAILoadingMessageComponent, AILoadingPhase
/suggestion-chipAISuggestionChipComponent
/processing-stepsAIProcessingStepsComponent
/chat-headerAIChatHeaderComponent
/chat-header-lightAIChatHeaderLightComponent
/chat-inputAIChatInputComponent
/feedback-widgetAIFeedbackWidgetComponent, AIFeedbackSubmission
/markdownAIMarkdownComponent
/sparkle-loaderSparkleLoaderComponent
/conversation-date-labelAIConversationDateLabelComponent
/conversation-list-itemAIConversationListItemComponent
/chartsAIDisplayChartComponent, DisplayChartPayload (isolates chart.js/ng2-charts)

AIConversationHistoryComponent, AIToolStatusComponent, AIToolProgressComponent, PresenceChatPanelComponent, PresenceChatWindowComponent, and UserPromptDialogComponent are root-only — they depend directly on the chat/session machinery, or (for AIConversationHistoryComponent) they hit an ng-packagr/TypeScript bug (see below).

moduleResolution prerequisite: subpath imports rely on the package’s exports map, so they only resolve under a TypeScript moduleResolution of bundler, node16, or nodenext. Root-level imports are unaffected and keep working under any resolution strategy via the retained main/module fields.

The hand-written exports map in packages/ai/package.json doesn’t get generated — it has to be kept in sync by hand whenever a new secondary entry point is added. To add one:

  1. Create packages/ai/<name>/ng-package.json with lib.entryFile pointing directly at the existing component file, e.g. "../src/components/ai-alert.component.ts" (no barrel needed if the component file already exports everything the entry needs).
  2. Add an entry for ./<name> to the exports map in packages/ai/package.json, prefixed with dist/ (matching the pattern of the existing entries).
  3. The component stays exported from the root barrel too (src/index.ts) — this package doesn’t remove things from root when splitting them out.

Known compiler limitation: if the component’s entry file transitively imports a file that lives in a different subdirectory of src/ (e.g. a component in src/components/ importing a util from src/sessions/ or src/messages/), and that combination pulls in more than one already-compiled file, the build can fail with Cannot destructure property 'pos' of 'file.referencedFiles[index]' as it is undefined. — a known ng-packagr/TypeScript bug (angular/angular#57850), not anything wrong with the export itself. This is why AIConversationHistoryComponent (which imports three sibling components plus session types) isn’t split out yet. Workaround demonstrated by the sparkle-loader entry: co-locate the dependency in the same directory as its only consumer (ai-status-messages.ts was moved from src/messages/ into src/components/, next to sparkle-loader.component.ts) so nothing crosses a directory boundary. That only works when the dependency has a single real consumer; for a component tangled with multiple independently-used siblings, that’s not a clean option.

The package ships a Vite demo harness that exercises chat, attachments, and the panel against a proxied Chiron:

Terminal window
npm run demo --workspace=@presencelearning/ai

Source: packages/ai