AI Agent
The AI Agent is an interactive chat assistant connected to the background-tasks service. It supports streaming responses, tool execution, RAG document search, voice conversations, and conversation management with billing integration. The chat panel works on both company and job seeker dashboards.
Agent Management
The useAIAgent hook provides full CRUD for AI agents. It accepts both companyId (employers) and userId (job seekers) to fetch agents:
const { agents } = useAIAgent({
companyId: companyId || undefined, // employer agents
userId: !companyId ? userId : undefined, // job seeker agents (when no company)
})
| Method | Purpose |
|---|---|
fetchAgent() / fetchAgents() | Retrieve agent(s) |
createAgent() | Create a new agent |
updateAgent() | Update agent configuration |
enableAgent() / disableAgent() | Toggle agent active state |
deleteAgent() | Remove an agent |
refresh() | Re-fetch agent data |
State: agent, agents[], isLoading, isCreating, error, hasAgent
The hook communicates with the AI service endpoint (separate from the main backend).
Agent selection + stale localStorage
ChatPanel persists the active agent id as localStorage.selectedChatAgentId so the panel reopens on whatever the user had last. The restore path must validate that stored id against the loaded agents list before assigning it to selectedAgentId — a stale id (from a previous company context, a deleted agent, or a user who switched between employer and job-seeker mode) will otherwise propagate into the next startConversation call and trip a AgentConversation_agentId_fkey foreign-key violation on the background-tasks side.
The effect gates on isLoadingAgents so the list is fully resolved before it decides, precedence is propAgentId → valid-stored → first ACTIVE agent → env NEXT_PUBLIC_DEFAULT_AGENT_ID, and an invalid stored id is removed from localStorage and cleared from state so the lazy default-agent auto-create effect can fire.
Default-agent auto-create on open
If the resolved agent list is empty on open (job seeker with no agents, or first-ever company open), ChatPanel calls createDefaultAgent(companyId, userId) from lib/utils/createDefaultAgent.ts and assigns the returned agent. Always pass companyId directly — passing companyId || userId sends userId into the helper's first argument, which becomes input.companyId on the GraphQL mutation and creates an orphan row the agents(userId: ...) query can never find. The helper accepts an empty-string first argument as the jobseeker signal.
Idempotent (SUP-00016, 2026-04-24): The helper queries GET_AGENTS for the same scope (userId for jobseeker, companyId for recruiter, status: ACTIVE, limit: 1) before calling CREATE_AGENT. If any active agent exists in that scope, the existing one is returned and its id is written to localStorage.selectedChatAgentId. This makes all four trigger sites (onboarding ×2, addCompany, ChatPanel cold-open) safe to call repeatedly — no more per-company duplicate accumulation.
createDefaultAgent produces two distinct agent products based on whether companyId is provided:
| Field | Recruiter path | Job-seeker path |
|---|---|---|
companyId | Passed through | undefined (Agent row has companyId = NULL) |
type | RECRUITMENT_ASSISTANT | JOB_MATCHING |
name | "AI Assistant" | "Job Search Assistant" |
description | "Your default AI assistant for recruiting, talent search, and company operations." | "Your personal AI career assistant. Finds jobs that match your skills, helps improve your CV, and prepares you for interviews." |
enabledTools | RECRUITER_AGENT_TOOLS (38 tools) | JOB_SEEKER_AGENT_TOOLS (19 tools) |
JOB_SEEKER_AGENT_TOOLS mirrors the backend JOB_SEEKER_ALLOWED_TOOL_TYPES constant in background-tasks/app/services/agent/tool_service.py — if they drift, a job-seeker agent created with the full recruiter tool list will still work because the backend refuses non-allowlisted tools at dispatch time with a targeted redirect message (belt-and-braces). Keep the two lists in sync when adding a new tool type. See ViewMode-Aware Behaviour for the full dispatch-gate story.
Tool Definitions
The useToolDefinitions hook loads tool definitions from the API for the agent setup UI:
| Method | Purpose |
|---|---|
fetchToolDefinitions() | Load tool definitions for a company |
updateToolDefinition() | Update name, description, or active status |
toggleToolDefinition() | Enable/disable a tool definition |
State: toolDefinitions[], isLoading, error
The AgentSetup component loads tools from the API and groups them by category (Recruitment, Data & Knowledge, Scheduling, AI Pipelines). If the API is unavailable, it falls back to hardcoded tool options.
Agent Types
| Type | Description |
|---|---|
RECRUITMENT_ASSISTANT | Default for company users. Full recruiter tool palette. |
JOB_MATCHING | Default for job seekers. Reduced tool palette focused on job discovery, CV self-service, and own schedule. |
TALENT_SEARCH | Talent pool search and matching |
INTERVIEW_PREP | Interview preparation |
DATA_ANALYST | Data analysis and reporting |
CUSTOM | Custom agent type |
The type field is metadata-only on the backend — the actual recruiter-vs-job-seeker split is driven by the caller's ViewMode (via self.company_id), not by the agent row's type. That's why existing RECRUITMENT_ASSISTANT agents with companyId = NULL (created before the split landed) still behave correctly for job seekers once the ViewMode-aware dispatch ships — no data migration needed. See ViewMode-Aware Behaviour.
Real-Time Chat
The useAIAgentChat hook manages streaming conversations via GraphQL WebSocket subscriptions.
Streaming Events
Chat responses arrive as typed events on the agentChat subscription:
| Event | Description |
|---|---|
ChatProcessing | Agent is processing the request |
ChatContextLoaded | RAG context has been loaded (includes chunk count and token count) |
ChatChunk | Incremental text chunk (streamed with delta and chunkIndex) — appended to msg.content |
ChatReasoningChunk | Extended-thinking delta from a reasoning-capable model (Sonnet 4.x / Opus 4.x). Accumulates into a {type: "reasoning", text} segment on msg.contentSegments and is never appended to msg.content so list previews show the answer, not the chain of thought. The subscription document MUST select the inline fragment explicitly (... on ChatReasoningChunk { id delta chunkIndex }) — without it every field arrives as undefined and the UI ends up rendering empty/NaN-looking blocks. |
ChatToolCall | Agent is invoking a tool (includes toolName and toolType) |
ChatToolResult | Tool execution result (includes output, success, executionTimeMs) |
ChatComplete | Response is complete (includes messageId, full content, inputTokens, outputTokens, totalTimeMs) |
ChatError | An error occurred (includes code, message, recoveryHint). New code ATTACHMENT_FAILED triggers attachment-retry UX — see Attachment retry UX. |
ChatTitleUpdated | Background title-generation finished (includes title, conversationId) — drives the conversation-list refresh |
The handler in lib/hooks/ai-agent/chatEventHandler.ts dispatches strictly on __typename (no field-presence inference fallback). The event payload type is the discriminated union AgentChatStreamEvent exported from the same file — keep it in sync with the backend AgentChatUpdate union (background-tasks/app/graphql/types/agent.py).
Hook State
| State | Type | Purpose |
|---|---|---|
messages | Message[] | Conversation messages |
isLoading / isStreaming | boolean | Loading and streaming flags |
conversationId | string | Active conversation ID |
isConnected | boolean | WebSocket connection status |
activeAgentId | string | Currently active agent |
conversations | Conversation[] | Conversation history list |
Conversation Management
- Archive conversations
- Rate responses (1-5 rating with optional text feedback)
- Fetch conversation history with pagination (
beforecursor) - Switch between agents
- Auto-generated titles from first message
Loading Conversation History
When the user picks an older conversation from the history dropdown, useConversationManager.loadConversation() issues conversationMessages(conversationId, limit) against the AI service and maps the response into the in-memory ChatMessage[]. Two rules must hold or the loaded transcript renders broken:
- Drop
TOOLandSYSTEMrows before mapping. The backend persists each tool execution as both a standaloneTOOLrow and an entry inside the parent ASSISTANT message'stoolCalls[]— see Message Role Semantics. The assistant row already carries everything the UI needs (tool name, type, input, output, success, execution time). If theTOOLrows slip through,ChatMessageRenderer'sisAssistant = message.role === "assistant"check fails on them and they fall through to the user visual — you get a user-name + user-avatar message whose content is the raw JSON blob of a tool output, once per tool call.SYSTEMrows are the rendered system prompt and are not meant for the UI either. - Never blind-cast
msg.role. The legacy(msg.role || "user").toLowerCase() as "user" | "assistant"was a TypeScript lie —"TOOL".toLowerCase() === "tool", neither of the two literals, and the cast let it through at runtime. After filtering, the safe form is an explicit check:role === "ASSISTANT" ? "assistant" : "user".
Streaming conversations don't hit this because ChatToolResult events only update the in-flight assistant message's toolCalls[] — no standalone tool rows are ever appended to messages. Only loaded conversations go through the mapping path, so this is exclusively a history-loader concern.
File: lib/hooks/ai-agent/useConversationManager.ts, inside the loadConversation callback.
Attachment retry UX
Files attached to the chat go: paperclip → /api/upload (Next.js route) → S3 via presigned PUT → frontend gets {bucket, key, filename, mimeType, sizeBytes} → that's passed in the next agentChat subscription's attachments field. The backend hydrates each into a Bedrock image or document content block — see Attachments for the supported MIME types and limits.
Two design rules the chat UI must follow:
- Pre-validate against the SAME caps the backend enforces.
lib/config/attachmentLimits.tsis the single source of truth (mirrors the backend constants inattachment_processor.py): images 5 MB, documents 4.5 MB, total per message 20 MB, max 5 attachments.ChatPanel.tsxandChatModal.tsxboth import these and surface specific errors ("PDFs must be under 4.5 MB") instead of letting the backend reject a 4.8 MB PDF after upload completes. - Restore
pendingAttachmentsonATTACHMENT_FAILED. The send handler used to clearpendingAttachmentssynchronously beforesendMessage()resolved — when the backend then emittedChatError(code="ATTACHMENT_FAILED"), the chips were gone and the user had to re-select the files. Both panels now snapshot the array, clear locally for the optimistic UX, and restore the snapshot whenerror?.code === "ATTACHMENT_FAILED"after the send returns:const attachmentsSnapshot = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined
setPendingAttachments([]) // optimistic
try {
await sendMessage(messageText, { attachments: attachmentsSnapshot, ... })
if (attachmentsSnapshot && error?.code === "ATTACHMENT_FAILED") {
setPendingAttachments(attachmentsSnapshot)
}
} catch (err) {
// network/JS error before any subscription event landed — full rollback
if (attachmentsSnapshot) setPendingAttachments(attachmentsSnapshot)
// ...also restore inputValue, forcedTools
}
The matching backend behaviour: process_attachments raises AttachmentProcessingError BEFORE creating the user/assistant rows so neither becomes a zombie, the agent loop yields AttachmentFailed, and the subscription handler maps that to ChatError(code="ATTACHMENT_FAILED", recoveryHint=…). See AttachmentFailed surface.
Billing Integration
The chat hook detects billing errors (insufficient credits, plan limit exceeded, subscription inactive) and surfaces them to the user via the BillingErrorToastHandler. Error codes:
| Error Code | User Impact |
|---|---|
INSUFFICIENT_CREDITS | Prompt to purchase more credits |
SUBSCRIPTION_INACTIVE | Prompt to activate subscription |
PLAN_LIMIT_EXCEEDED | Prompt to upgrade plan |
RATE_LIMITED | Prompt to wait before retrying |
TOKEN_LIMIT_EXCEEDED | Daily budget exhausted, retry tomorrow |
RAG Document Management
The useAgentDocuments hook manages documents for RAG (Retrieval-Augmented Generation):
| Method | Purpose |
|---|---|
uploadDocument() | Upload a document for RAG indexing |
deleteDocument() | Remove a document |
enableRAG() / disableRAG() | Toggle RAG inclusion for a document |
reprocessDocument() | Re-index a document |
Document features:
- Categorization by type (
POLICY,PROCEDURE,KNOWLEDGE_BASE,FAQ,TEMPLATE,REFERENCE,TRAINING,OTHER) - Tagging for organization and filtered RAG search
- Priority-based retrieval ranking (
ragPriority) - Configurable chunking (
chunkSize,chunkOverlap) - Status tracking:
PENDING→PROCESSING→CHUNKING→EMBEDDING→COMPLETED(orFAILED)
State: documents[], isLoading, isUploading, uploadProgress
Upload path
Knowledge Base uploads go browser → S3 directly via a presigned PUT URL issued by the main backend's upload.generateUploadUrl mutation. The file never passes through the Next.js /api/upload proxy route, so uploads are limited only by S3's single-PUT ceiling (5 GB) rather than Amplify Compute's ~5 MB body cap. The registration step afterwards calls uploadAgentDocument on the AI service with the returned objectPath — that raw path is the authoritative S3 key; never re-parse it out of the presigned URL's pathname (the encoded form breaks background-tasks' GetObject with NoSuchKey). This behaviour shipped with SUP-00072.
See the File Upload Flow guide for the end-to-end pattern and for the list of features still on the legacy proxy path.
Knowledge Base page behaviours
app/company/chatbot/knowledge/page.tsx is mounted at two URLs: directly at /company/chatbot/knowledge, and as the knowledge-base settings tab via app/(shared)/userprofile/(settings)/[tab]/page.tsx. A few page-level behaviours are worth knowing because they're not visible from the hook surface alone.
Read-only role enforcement. The page derives readOnly from the panel definition itself so the sidebar entry and the page can never drift:
const role = useCompanyRoleFor(companyId || null)
const readOnlyTiers = findMasterPanel("knowledgeBase")?.readOnlyForTiers ?? []
const readOnly = readOnlyTiers.includes(role.roleTier)
readOnlyForTiers: ["member", "viewer"] lives on the knowledgeBase panel in lib/utils/settingsPresets.ts. When readOnly is true the toolbar's upload button is disabled, the page-wide drag overlay does not mount, the upload modal is suppressed (defence-in-depth), handleFilesAccepted short-circuits, the RAG toggle is disabled, and the reprocess + delete actions are hidden in both the grid card, list view, and preview drawer. A neutral info banner explains the state to the user. This is enforced at the page level rather than only at the settings router because the direct /company/chatbot/knowledge route bypasses [tab]/page.tsx entirely. See RBAC frontend → Settings panel readOnly pattern for the broader pattern.
Multi-agent merging + extras truncation. The picker is multi-select. The first selected agent is the primary and drives the live useAgentDocuments subscription; uploads always land on the primary. Any additional ("extra") agents are fetched once per selection change via a static listAgentDocuments query capped at 100 docs each. The extras path is fire-and-forget — it has no live subscription, so processing-status updates won't tick in real time for non-primary agents.
When an extra agent's total > 100, a warning banner names each affected agent in the form Acme Bot (100/247) and recommends making it primary to load all of its documents live. The hardcoded ceiling (EXTRAS_FETCH_LIMIT = 100) is on the page itself; raising it would also raise the cost of every multi-agent selection and is not currently parameterised.
Stats semantics. The "Trained" pill counts only docs where status === "COMPLETED" && includeInRag === true, matching the hook's stricter definition. A doc that has been embedded but had RAG flipped off is processed but not actually searchable by the agent, so counting it as trained was previously misleading. "Processing" covers PENDING / PROCESSING / CHUNKING / EMBEDDING; "Failed" covers FAILED.
Shared download helper. All three surfaces (DocumentsList list view, DocumentCard grid item, DocumentPreviewDrawer) call downloadDocument({ url, filename }) from lib/utils/documents/downloadDocument.ts. The helper opens in a new tab via window.open and surfaces a TWToast for the three failure modes that previously failed silently — missing URL ("Download unavailable"), popup blocker ("Download blocked"), and unexpected error ("Download failed"). Don't pre-flight with HEAD; S3 presigned URLs sign the method and most CORS configs wouldn't allow an unauthenticated HEAD anyway.
Task Configuration
Agents support task-specific configurations that override default agent settings:
| Method | Purpose |
|---|---|
createTaskConfig() | Create task-specific overrides |
fetchTaskConfigs() | List task configs for an agent |
Task configs allow a single agent to be used for different workflows (e.g., recruitment_chat, talent_search) with custom system prompts, LLM parameters, RAG filters, tool permissions, and guardrails.
Page Context
The ChatPanel component automatically detects the current page from the URL pathname and sends it with each message:
// ChatPanel.tsx
const pageContext = useMemo(() => getPageContext(pathname), [pathname])
// Sent with every message
await sendMessage(text, {
pageContext: pageContext.key, // e.g. "talent_detail"
entityId: pageContext.entityId, // e.g. talent UUID from URL
})
This enables:
- System prompt injection — the agent receives context about what the user is looking at
- Auto-activated tools — the page's tool set is UNION-ed into the agent's
enabledToolsat runtime, so a generic agent becomes capable of CV editing on the CV builder, candidate operations on the pipeline page, etc. without any per-page configuration. See Page-Context Tool Activation for the full map and allowlist. - Entity awareness — on detail pages, the agent knows which entity the user is referring to when they say "edit this" or "contact them"
The getPageContext() function in components/reusable/chatbot/ChatPanel.tsx maps URL patterns (company + job seeker + the shared /cvbuilder route → cv_builder) to context keys.
Chat UI Components
| Component | File | Purpose |
|---|---|---|
| ChatPanel | components/reusable/chatbot/ChatPanel.tsx | Collapsible right-edge side panel (company + job seeker dashboards) with page context detection, tool picker, voice mode, and file attachments. On desktop the close affordance is a half-pill handle on the panel's left edge; on mobile the panel goes full-width and closes via an X button in the header (the left-edge handle is hidden on < sm because it would sit off-screen). |
| ChatModal | components/reusable/chatbot/ChatModal.tsx | Legacy full-chat modal — not currently wired into any layout. Kept for reference; all active chat surfaces go through ChatPanel mounted by ChatBubble. |
| ChatBubble | components/reusable/chatbot/ChatBubble.tsx | The closed-state toggle + mount point for ChatPanel. Rendered globally from app/company/layout.tsx and app/jobseeker/layout.tsx. When the panel is closed, the toggle is a vertically-centered half-pill tab pinned to the right edge of the viewport (fixed right-0 top-1/2 -translate-y-1/2) that mirrors the panel's own close handle — open and close affordances sit on the same horizontal line. |
| ChatToolCards | components/reusable/chatbot/ChatToolCards.tsx | Tool execution result cards |
| AgentSetup | components/company/ai-agent/AgentSetup.tsx | Agent creation/edit with dynamic tool selection |
| AgentGrid | components/company/ai-agent/AgentGrid.tsx | Agent card grid with tool labels |
The chatbot page is at app/company/chatbot/.
The frontend renders ChatChunk events incrementally for a real-time typing effect. ChatToolCall and ChatToolResult events are displayed as collapsible action indicators in the chat UI.
Agent Refresh System
When the AI agent executes a tool that modifies data (e.g., update_cv, extract_cv, create_talent), the page currently being viewed auto-refreshes to show the changes. This is implemented via a Zustand store and a reusable hook:
Architecture:
Key files:
| File | Purpose |
|---|---|
lib/agentRefreshStore.ts | Zustand store broadcasting ToolResultEvent (toolName, toolType, success, pageContext, entityId, timestamp) |
lib/hooks/useAgentRefresh.ts | Hook that subscribes to the store and calls refetch() when a matching tool succeeds |
lib/hooks/ai-agent/chatEventHandler.ts | Fires onToolResult callback when ChatToolResult event arrives (looks up tool name from prior ChatToolCall) |
Usage in pages:
// In any page with an Apollo query + refetch
useAgentRefresh(refetchTalent, ["extract_cv", "read_cv", "update_cv"], talentId)
The hook filters by tool name and optionally by entity ID (so talent page A doesn't refresh when talent page B's data is modified).
Pages with agent refresh:
| Page | Query | Relevant Tools |
|---|---|---|
Talent detail (TalentDetails.tsx) | GET_TALENT | extract_cv, read_cv, update_cv, create_talent, edit_talent |
Jobseeker resume list (resume/page.tsx) | MY_CVS | extract_cv, read_cv, update_cv, create_cv, improve_cv |
CV edit (resume/edit/[cvId]/page.tsx) | GET_CV_BY_ID | extract_cv, read_cv, update_cv, improve_cv |
Conversation Export
Every conversation can be exported client-side from the chat panel header for debugging, bug reports, and trace analysis. The export is built from the in-memory messages array — it does NOT hit the backend exportConversation GraphQL query, so it works on in-progress conversations and captures streaming state exactly as the user sees it.
UI: Export dropdown next to the conversation history picker in ChatPanel.tsx, disabled when the conversation has no messages.
Formats:
| Format | Action | Contents |
|---|---|---|
| Copy as Markdown | navigator.clipboard.writeText | Human-readable trace: role headers, ISO timestamps, message text, tool-call headings (name + type + status + duration), JSON input blocks, output blocks (truncated at 50KB to match backend tool-output cap), RAG chunk list, token totals |
| Copy as JSON | navigator.clipboard.writeText | Full structured trace as a single JSON object — no truncation, machine-parseable |
| Download JSON | Blob → <a download> | Same JSON, saved as chat-trace-{ISO-timestamp}.json |
Trace shape (same for both copy-JSON and download):
{
exportedAt: string // ISO timestamp of export
conversationId: string | null
conversationTitle: string | null
agent: { id, name, type, llmModel }
page: { key, label, entityId, pathname }
messageCount: number
messages: Array<{
id, role, timestamp, content,
inputTokens, outputTokens,
attachments: [{ filename, mimeType, sizeBytes }],
toolCalls: [{ id, name, type, status, success, executionTimeMs, input, output }],
ragContext: [{ documentName, score, pageNumber, content }],
error,
}>
}
Implementation: buildConversationTrace(), traceToMarkdown(), and handleExportConversation() live in ChatPanel.tsx. All error paths (empty conversation, clipboard failure, etc.) surface a TWToast instead of throwing.
The feature intentionally captures everything a support engineer would want to see — message text, the exact tool input JSON the LLM produced, the tool output, RAG chunks that shaped the response, token counts, and page context — so a user pasting a Markdown trace into a bug report gives the engineer enough to reconstruct the turn.
Tool Result Cards
ChatToolCards.tsx renders typed result cards based on tool output structure. Each tool type has a dedicated card with inline count display:
| Tool Type | Card | Display |
|---|---|---|
TALENT_SEARCH | Talent cards with skills, experience, match score | Grid of talent profiles |
JOB_SEARCH | Job cards with company, location, salary | Grid of job listings |
CANDIDATE_SEARCH | Candidate cards with status, pipeline stage | Grid of candidates |
COMPANY_INFO | Company detail card | Single card with company data |
PIPELINE_OVERVIEW | Pipeline stages with candidate counts | Stage breakdown |
INTERVIEW_SCHEDULE | Interview cards with date, time, participants | Chronological list |
MEETING_LIST | Meeting cards with room info and participants | Meeting grid |
DATABASE_QUERY | Data table with column headers and row pagination | Table view (max 20 rows) |
RAG_SEARCH | Document match cards with score percentage and content preview | Ranked document list |
CREATE_TALENT | Talent creation confirmation with name, email, skills | Success card with talent details |
CV_EXTRACTION | Extracted CV summary with contact, skills, experience, education | Structured CV data card |
JOB_PARSING | Parsed job details with title, requirements, salary, location | Job description card |
Each card includes a type guard function (e.g., isTalentSearchOutput()) for safe runtime type checking of the tool output JSON.
All 12 tools are fully implemented and return structured data that the frontend renders as typed cards. The CUSTOM type is reserved for future company-specific tools.
Connection Configuration
The AI agent connects to a separate service from the main backend:
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_API_URL | Backend HTTP endpoint — used for agent CRUD via aiGatewayRequest('listAgents'/'createAgent'/…) and voice WS path |
NEXT_PUBLIC_GRAPHQL_WS_URL | Backend WS endpoint — used for agentChat streaming via subscribeAiOp('agentChat', …) |
The NEXT_PUBLIC_AI_* env vars were removed in the Phase 3.7 NestJS AI gateway cutover — the frontend no longer talks to background-tasks directly.