Skip to main content

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)
})
MethodPurpose
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:

FieldRecruiter pathJob-seeker path
companyIdPassed throughundefined (Agent row has companyId = NULL)
typeRECRUITMENT_ASSISTANTJOB_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."
enabledToolsRECRUITER_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:

MethodPurpose
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

TypeDescription
RECRUITMENT_ASSISTANTDefault for company users. Full recruiter tool palette.
JOB_MATCHINGDefault for job seekers. Reduced tool palette focused on job discovery, CV self-service, and own schedule.
TALENT_SEARCHTalent pool search and matching
INTERVIEW_PREPInterview preparation
DATA_ANALYSTData analysis and reporting
CUSTOMCustom agent type
note

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:

EventDescription
ChatProcessingAgent is processing the request
ChatContextLoadedRAG context has been loaded (includes chunk count and token count)
ChatChunkIncremental text chunk (streamed with delta and chunkIndex) — appended to msg.content
ChatReasoningChunkExtended-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.
ChatToolCallAgent is invoking a tool (includes toolName and toolType)
ChatToolResultTool execution result (includes output, success, executionTimeMs)
ChatCompleteResponse is complete (includes messageId, full content, inputTokens, outputTokens, totalTimeMs)
ChatErrorAn error occurred (includes code, message, recoveryHint). New code ATTACHMENT_FAILED triggers attachment-retry UX — see Attachment retry UX.
ChatTitleUpdatedBackground 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

StateTypePurpose
messagesMessage[]Conversation messages
isLoading / isStreamingbooleanLoading and streaming flags
conversationIdstringActive conversation ID
isConnectedbooleanWebSocket connection status
activeAgentIdstringCurrently active agent
conversationsConversation[]Conversation history list

Conversation Management

  • Archive conversations
  • Rate responses (1-5 rating with optional text feedback)
  • Fetch conversation history with pagination (before cursor)
  • 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:

  1. Drop TOOL and SYSTEM rows before mapping. The backend persists each tool execution as both a standalone TOOL row and an entry inside the parent ASSISTANT message's toolCalls[] — see Message Role Semantics. The assistant row already carries everything the UI needs (tool name, type, input, output, success, execution time). If the TOOL rows slip through, ChatMessageRenderer's isAssistant = 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. SYSTEM rows are the rendered system prompt and are not meant for the UI either.
  2. 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:

  1. Pre-validate against the SAME caps the backend enforces. lib/config/attachmentLimits.ts is the single source of truth (mirrors the backend constants in attachment_processor.py): images 5 MB, documents 4.5 MB, total per message 20 MB, max 5 attachments. ChatPanel.tsx and ChatModal.tsx both 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.
  2. Restore pendingAttachments on ATTACHMENT_FAILED. The send handler used to clear pendingAttachments synchronously before sendMessage() resolved — when the backend then emitted ChatError(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 when error?.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 CodeUser Impact
INSUFFICIENT_CREDITSPrompt to purchase more credits
SUBSCRIPTION_INACTIVEPrompt to activate subscription
PLAN_LIMIT_EXCEEDEDPrompt to upgrade plan
RATE_LIMITEDPrompt to wait before retrying
TOKEN_LIMIT_EXCEEDEDDaily budget exhausted, retry tomorrow

RAG Document Management

The useAgentDocuments hook manages documents for RAG (Retrieval-Augmented Generation):

MethodPurpose
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: PENDINGPROCESSINGCHUNKINGEMBEDDINGCOMPLETED (or FAILED)

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:

MethodPurpose
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:

  1. System prompt injection — the agent receives context about what the user is looking at
  2. Auto-activated tools — the page's tool set is UNION-ed into the agent's enabledTools at 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.
  3. 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

ComponentFilePurpose
ChatPanelcomponents/reusable/chatbot/ChatPanel.tsxCollapsible 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).
ChatModalcomponents/reusable/chatbot/ChatModal.tsxLegacy full-chat modal — not currently wired into any layout. Kept for reference; all active chat surfaces go through ChatPanel mounted by ChatBubble.
ChatBubblecomponents/reusable/chatbot/ChatBubble.tsxThe 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.
ChatToolCardscomponents/reusable/chatbot/ChatToolCards.tsxTool execution result cards
AgentSetupcomponents/company/ai-agent/AgentSetup.tsxAgent creation/edit with dynamic tool selection
AgentGridcomponents/company/ai-agent/AgentGrid.tsxAgent 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:

FilePurpose
lib/agentRefreshStore.tsZustand store broadcasting ToolResultEvent (toolName, toolType, success, pageContext, entityId, timestamp)
lib/hooks/useAgentRefresh.tsHook that subscribes to the store and calls refetch() when a matching tool succeeds
lib/hooks/ai-agent/chatEventHandler.tsFires 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:

PageQueryRelevant Tools
Talent detail (TalentDetails.tsx)GET_TALENTextract_cv, read_cv, update_cv, create_talent, edit_talent
Jobseeker resume list (resume/page.tsx)MY_CVSextract_cv, read_cv, update_cv, create_cv, improve_cv
CV edit (resume/edit/[cvId]/page.tsx)GET_CV_BY_IDextract_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:

FormatActionContents
Copy as Markdownnavigator.clipboard.writeTextHuman-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 JSONnavigator.clipboard.writeTextFull structured trace as a single JSON object — no truncation, machine-parseable
Download JSONBlob<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 TypeCardDisplay
TALENT_SEARCHTalent cards with skills, experience, match scoreGrid of talent profiles
JOB_SEARCHJob cards with company, location, salaryGrid of job listings
CANDIDATE_SEARCHCandidate cards with status, pipeline stageGrid of candidates
COMPANY_INFOCompany detail cardSingle card with company data
PIPELINE_OVERVIEWPipeline stages with candidate countsStage breakdown
INTERVIEW_SCHEDULEInterview cards with date, time, participantsChronological list
MEETING_LISTMeeting cards with room info and participantsMeeting grid
DATABASE_QUERYData table with column headers and row paginationTable view (max 20 rows)
RAG_SEARCHDocument match cards with score percentage and content previewRanked document list
CREATE_TALENTTalent creation confirmation with name, email, skillsSuccess card with talent details
CV_EXTRACTIONExtracted CV summary with contact, skills, experience, educationStructured CV data card
JOB_PARSINGParsed job details with title, requirements, salary, locationJob description card

Each card includes a type guard function (e.g., isTalentSearchOutput()) for safe runtime type checking of the tool output JSON.

info

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:

VariablePurpose
NEXT_PUBLIC_API_URLBackend HTTP endpoint — used for agent CRUD via aiGatewayRequest('listAgents'/'createAgent'/…) and voice WS path
NEXT_PUBLIC_GRAPHQL_WS_URLBackend 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.