AI Agent System
The AI agent system provides configurable, RAG-powered conversational assistants within the AIQLick platform. Agents can answer questions using uploaded documents, execute tools to query the database and search talents/jobs, and stream responses in real time over GraphQL subscriptions. Agents run on the background-tasks service and support two ownership models: company-scoped (employer) and user-owned (job seeker).
Ownership Model
Agents support dual ownership to serve both company users and job seekers:
| Owner | companyId | createdById | Scope | Use Case |
|---|---|---|---|---|
| Company | Set (UUID) | Optional | Shared across company members | Employer agents for recruitment, pipeline, etc. |
| User | NULL | Set (UUID) | Private to the user | Job seeker personal assistant |
- Company agents are unique by
(companyId, createdById, name)and visible to all company members. - User agents have
companyId = NULLand are only visible to the creating user. - Idempotent creation: The
createAgentmutation returns an existing agent if one with the same name already exists for the company/user, preventing duplicates from the frontend auto-create flow. - Access control: Company agents verify
context.company_idmatches. User agents verifycontext.user_idmatchescreatedById.
Default Agent Provisioning
Every user is expected to land with at least one default agent so the chat panel can work on first open. Provisioning is now enforced natively by the backend via two mechanisms:
- Event Listeners:
SystemAgentListenercatchescompany.createdanduser.email.verifiedevents to provision agents immediately during onboarding. - Session Checks: The
WhoAmIGraphQL resolver checks for the existence of system agents (both jobseeker-scoped and company-scoped) on every session load. If missing, it asynchronously provisions them in the background.
This backend-driven approach replaces the legacy frontend-side createDefaultAgent fallback, ensuring agents are reliably available regardless of how the user registered (e.g. OAuth) or whether they are an older user from before the agent feature existed.
| Trigger | Caller / Location | Scope created | Agent type | Default name | Tool set |
|---|---|---|---|---|---|
| Company creation | company.created event | Company-scoped (companyId set) | RECRUITMENT_ASSISTANT | "AiQlick System Agent" | Recruiter palette (38 tools) |
| User email verification | user.email.verified event | User-scoped (companyId = NULL) | JOB_MATCHING | "AiQlick System Agent" | Job-seeker palette (19 tools) |
| Session Load (WhoAmI) | UsersResolver.whoAmI | Both (as needed) | RECRUITMENT_ASSISTANT / JOB_MATCHING | "AiQlick System Agent" | Respective palette |
Idempotency (SUP-00016): The backend SystemAgentService.ensureForUser and ensureForCompany methods perform a Prisma.findFirst check (isSystem: true and respective scope) before creating an agent. A unique constraint on (companyId, createdById, name) prevents race conditions during concurrent provisioning attempts.
The ChatPanel path uses selectedAgentId which is only cleared when a stored localStorage.selectedChatAgentId fails validation against the loaded agents list. The frontend no longer attempts to auto-create agents; it simply waits for the backend provisioning to reflect in the listAgents query.
Page Context
When the frontend sends a pageContext key with each chat message, the system injects contextual guidance into the LLM system prompt. This helps the agent provide relevant suggestions based on what the user is currently looking at.
How It Works
- Frontend detects the current page from the URL pathname (e.g.,
/company/resume/talents/uuid→talent_detail) - The
pageContextand optionalentityIdare sent with eachagentChatsubscription message - The conversation service appends a
[Page Context]block to the system prompt - Tools are auto-activated for the page: the agent's configured
enabledToolsis UNION-ed with the page's tool set at runtime (gated by an allowlist — see Page-Context Tool Activation), then the resulting list is priority-ordered so the most relevant tools are considered first
Context Envelope
Every agent turn is built against a structured context envelope (AgentContext) assembled once at the top of send_message_stream by context_builder.build_agent_context(). The envelope carries three blocks into both the system prompt and the tool service:
UserSnapshot— broad context about the caller: name, email, view mode, company, role, plan, billing mode, subscription status, credit balance, active CV. Assembled by joiningUser→Company→UserCompanyRole→Role→Subscription→Plan→CreditBalancein one query.EntitySnapshot— compact snapshot of the entity the user is currently looking at on a detail page (e.g. ontalent_detailthe Talent's name, title, email, location, active CV, pool, candidate count, note count). Fetched per-page by a dedicated async function.AgentContext— combines both pluspage_context,page_pathname,conversation_id, andagent_id.
Every fetcher is wrapped in a 500ms asyncio.wait_for() timeout. If a query fails or times out, the fetch degrades to None with a warning log — the conversation still runs with a thinner prompt rather than failing the whole turn.
Rendered system prompt shape:
You are a helpful AI assistant for the AIQLick recruitment platform.
[User]
Name: Reza Zeraat
Email: rezdev97@gmail.com
Role: EMPLOYER at AIQLick (Owner)
Plan: Enterprise (POSTPAID) [ACTIVE]
Credit balance: 1250
Active CV: none
[Page]
Page: talent_detail
Route: /company/resume/talents/abc-123-def-456
[Entity: Talent]
id: abc-123-def-456
name: Jane Smith
title: Senior Frontend Engineer
email: jane@example.com
location: Berlin, Germany
active_cv_id: cv-789-xyz-012
talent_pool: Engineering Q4 2025
candidate_count: 3
note_count: 5
[Instructions]
When the user says 'this talent' / 'them' / 'edit this', call tools like
update_talent, add_talent_note, add_candidate_to_job, … WITHOUT the
talent_id parameter — the system uses the talent from [Entity: Talent]
automatically.
Because the LLM sees the real entity UUID in [Entity], it no longer needs to invent placeholder strings like "TALENT-UUID" or "user_cv_id". Even when it does slip up, the tool handler still receives the envelope via self.context and can resolve the missing ID from the page — see Entity Fallback Resolution.
Entity Fallback Resolution
Write handlers for detail pages share a common three-step resolution pattern:
- Validate the UUID from params via
_is_valid_uuid(). Non-UUID strings (LLM hallucinations like"CV-UUID-PLACEHOLDER") are treated as missing, not passed to asyncpg. - Fall back to the page entity via
_resolve_page_entity_id(expected_pages), which returnsself.context.entity.entity_idonly if the current page matches one of the expected contexts and the UUID is well-formed. - Enforce authorization via an
_assert_*_accesshelper (see Access Control Helpers) before any DB mutation. The fallback is never a trust vector — the access check is what actually authorizes the write.
Handlers covered by this pattern:
| Handler | Fallback page | Access helper |
|---|---|---|
_handle_read_cv / _handle_update_cv | js_resume, cv_builder (via self.user_id's active CV) | _assert_cv_access |
_handle_update_talent | talent_detail | _assert_talent_access |
_handle_update_candidate_status | candidate_detail | _assert_candidate_access |
_handle_add_candidate_to_job | talent_detail (talent) + job_detail (job) | _assert_talent_access + _assert_job_access |
_handle_add_talent_note | talent_detail | _assert_talent_access |
_handle_recommend_jobs_for_talent | talent_detail | _assert_talent_access |
_handle_get_talent_cv | talent_detail | _assert_talent_access |
_handle_get_match_scores | talent_detail / candidate_detail / job_detail | _assert_talent_access + _assert_job_access |
_handle_contact_talent | talent_detail | _assert_talent_access |
_handle_edit_job | job_detail | _assert_job_access(writable=True) |
_handle_duplicate_job | job_detail | _assert_job_access(writable=True) |
_handle_edit_candidate | candidate_detail | _assert_candidate_access |
Access Control Helpers
DB-backed authorization, single source of truth. All write handlers invoke the matching helper before any mutation — the fallback-resolved entity_id goes through the same check, so a caller who isn't allowed to touch the page entity is still denied.
| Helper | Company agent | Jobseeker agent |
|---|---|---|
_assert_cv_access(cv_id) | CV belongs to a talent in the company pool OR to a user with a UserCompanyRole in the company | CV is owned by self.user_id |
_assert_talent_access(talent_id) | Talent's pool companyId matches self.company_id | Denied (talents are recruiter-side) |
_assert_candidate_access(candidate_id) | Candidate's Job companyId matches | Candidate's userId matches self.user_id |
_assert_job_access(job_id, writable=False) | Job companyId matches OR caller has a UserCompanyRole in the job's company | Read-only: any existing job. Writable=True always denied. |
Supported Contexts
Company pages:
| Context Key | Route Pattern | Entity |
|---|---|---|
dashboard | /company/dashboard | -- |
pipeline | /company/pipeline | -- |
talents | /company/resume/talents | -- |
talent_detail | /company/resume/talents/:id | talent UUID |
talentpool | /company/resume/talentpool | -- |
candidates | /company/resume/candidates | -- |
candidate_detail | /company/resume/candidates/:id | candidate UUID |
jobs | /company/jobs | -- |
job_detail | /company/jobs/:id | job UUID |
interviews | /company/interviews | -- |
meetings | /company/meetings | -- |
team | /company/team | -- |
connections | /company/connections | -- |
sharelist | /company/resume/sharelist | -- |
Job seeker pages:
| Context Key | Route Pattern | Entity |
|---|---|---|
js_dashboard | /jobseeker/dashboard | -- |
js_jobs | /jobseeker/jobs | -- |
js_job_detail | /jobseeker/jobs/:id | job UUID |
js_applications | /jobseeker/application | -- |
js_interviews | /jobseeker/interviews | -- |
js_interview_detail | /jobseeker/interviews/:id | interview UUID |
js_resume | /jobseeker/resume | -- |
js_connections | /jobseeker/connections | -- |
Shared pages:
| Context Key | Route Pattern | Entity |
|---|---|---|
cv_builder | /cvbuilder | -- |
Page-Context Tool Activation
When pageContext is provided and no forcedTools are set, the conversation service auto-activates the page's tool set — it does NOT require the agent's DB config to already list those tools. This means a generic agent instantly becomes capable of CV editing on the CV builder page, candidate operations on the pipeline page, etc., without any per-page configuration.
Union semantics (conversation_service.py, _stream_chat_response):
effective_enabled_tools = list(config.enabled_tools or [])
if page_context in AgentToolService.TOOL_PRIORITY_BY_CONTEXT:
for t in AgentToolService.TOOL_PRIORITY_BY_CONTEXT[page_context]:
if t in effective_enabled_tools:
continue
if t not in AgentToolService.AUTO_ENABLE_FROM_CONTEXT:
continue # allowlist gate
effective_enabled_tools.append(t)
The resulting list is then passed to AgentToolService and priority-ordered (tools from TOOL_PRIORITY_BY_CONTEXT[page_context] appear before the rest in the LLM's tool_config).
AUTO_ENABLE_FROM_CONTEXT allowlist — only tool types on this set are eligible for auto-activation. Read-heavy tools, CV tools (caller-scoped via _assert_cv_access), common recruitment writes, self tools, and page-entity-scoped writes are allowed; destructive/sensitive operations must still be explicitly configured on the agent.
Current members: RAG_SEARCH, JOB_SEARCH, CANDIDATE_SEARCH, TALENT_SEARCH, COMPANY_INFO, PIPELINE_OVERVIEW, INTERVIEW_SCHEDULE, MEETING_LIST, GET_MEETING_INSIGHT, CV_READ, CV_UPDATE, CV_EXTRACTION, CV_ANALYSIS, GET_TALENT_CV_DETAILS, GET_MATCH_SCORES, HIRING_ANALYTICS, TALENT_POOL_INSIGHTS, RECOMMEND_TALENTS_FOR_JOB, RECOMMEND_JOBS_FOR_TALENT, JOB_PARSING, GENERATE_JOB_DESCRIPTION, CREATE_TALENT, UPDATE_TALENT, UPDATE_CANDIDATE_STATUS, ADD_TALENT_NOTE, ADD_CANDIDATE_TO_JOB, SHORTLIST_CANDIDATES, COMPARE_CANDIDATES, GET_ME, GET_MY_STATS, GET_MY_SUBSCRIPTION, GET_MY_RECENT_ACTIVITY, UPDATE_MY_PROFILE, CONTACT_TALENT, EDIT_JOB, DUPLICATE_JOB, EDIT_CANDIDATE.
Note: DATABASE_QUERY, voice operations, and any future privileged tool are intentionally excluded — they must be enabled on the agent explicitly.
Page → tool map (TOOL_PRIORITY_BY_CONTEXT in tool_service.py):
| Context | Auto-Activated Tools |
|---|---|
dashboard | GET_ME, GET_MY_STATS, GET_MY_SUBSCRIPTION, COMPANY_INFO, PIPELINE_OVERVIEW, JOB_SEARCH, HIRING_ANALYTICS, TALENT_POOL_INSIGHTS |
pipeline | PIPELINE_OVERVIEW, CANDIDATE_SEARCH, UPDATE_CANDIDATE_STATUS, EDIT_CANDIDATE, GET_MATCH_SCORES, SHORTLIST_CANDIDATES, COMPARE_CANDIDATES |
talents | TALENT_SEARCH, CREATE_TALENT, CV_EXTRACTION, UPDATE_TALENT, ADD_TALENT_NOTE, CONTACT_TALENT, RECOMMEND_JOBS_FOR_TALENT |
talent_detail | CV_READ, CV_UPDATE, GET_TALENT_CV_DETAILS, UPDATE_TALENT, ADD_TALENT_NOTE, CONTACT_TALENT, CV_EXTRACTION, CV_ANALYSIS, GET_MATCH_SCORES, RECOMMEND_JOBS_FOR_TALENT, ADD_CANDIDATE_TO_JOB, TALENT_SEARCH |
talentpool | TALENT_SEARCH, TALENT_POOL_INSIGHTS, CREATE_TALENT |
candidates | CANDIDATE_SEARCH, GET_MATCH_SCORES, UPDATE_CANDIDATE_STATUS, EDIT_CANDIDATE, SHORTLIST_CANDIDATES |
candidate_detail | CV_READ, CV_UPDATE, GET_TALENT_CV_DETAILS, UPDATE_CANDIDATE_STATUS, EDIT_CANDIDATE, GET_MATCH_SCORES, CV_ANALYSIS, ADD_TALENT_NOTE, CONTACT_TALENT, CANDIDATE_SEARCH |
jobs | JOB_SEARCH, JOB_PARSING, GENERATE_JOB_DESCRIPTION, RECOMMEND_TALENTS_FOR_JOB, DUPLICATE_JOB, EDIT_JOB |
job_detail | EDIT_JOB, DUPLICATE_JOB, CANDIDATE_SEARCH, RECOMMEND_TALENTS_FOR_JOB, GET_MATCH_SCORES, JOB_PARSING, GENERATE_JOB_DESCRIPTION, JOB_SEARCH |
interviews | INTERVIEW_SCHEDULE |
meetings | MEETING_LIST, GET_MEETING_INSIGHT |
sharelist | TALENT_SEARCH |
team | COMPANY_INFO, GET_ME |
connections | COMPANY_INFO |
ai | RAG_SEARCH |
js_dashboard | GET_ME, GET_MY_STATS, GET_MY_SUBSCRIPTION, GET_MY_RECENT_ACTIVITY, UPDATE_MY_PROFILE, JOB_SEARCH, INTERVIEW_SCHEDULE |
js_jobs | JOB_SEARCH, GET_MATCH_SCORES |
js_job_detail | JOB_SEARCH, GET_MATCH_SCORES, JOB_PARSING |
js_resume | CV_READ, CV_UPDATE, UPDATE_MY_PROFILE, CV_EXTRACTION, CV_ANALYSIS, RAG_SEARCH, GET_ME |
cv_builder | CV_READ, CV_UPDATE, UPDATE_MY_PROFILE, CV_EXTRACTION, CV_ANALYSIS, RAG_SEARCH, GET_ME |
js_interviews / js_interview_detail | INTERVIEW_SCHEDULE |
js_applications | JOB_SEARCH, INTERVIEW_SCHEDULE |
Before this mechanism existed, the CV builder chat agent would refuse prompts like "change my email to rz@example.com" because its DB-configured enabledTools didn't list CV_UPDATE. Rather than manually seed every agent with the CV tools, the service now auto-activates the tools that make sense for wherever the user is standing in the UI. The allowlist + tool-level authorization (see Tool Security Hardening) keep the expansion safe.
ViewMode-Aware Behaviour
The job-seeker agent and the recruiter agent are different products that happen to share a row in the Agent table. The split happens at three layers in the backend, all of them gated on self.company_id (which is None for job seekers and a UUID for recruiters — derived from the request context, not the agent's stored type):
- Tool allowlist —
get_enabled_tools()filters the advertised tool list down toJOB_SEEKER_ALLOWED_TOOL_TYPESwhen the caller has no company context, so the LLM never even sees recruiter-only tools. - Dispatch gate —
execute_tool()refuses non-allowlisted tools with a targeted error fromJOB_SEEKER_TOOL_REDIRECTS("usesearch_jobs/recommend_jobs_for_talentinstead") so an LLM that tries to call one anyway gets a recovery hint instead of a permission error deep in a handler. - Persona prefix —
_render_system_prompt()prepends a ViewMode-specific persona block before the agent's storedsystem_prompt, so an agent created with a legacy recruiter prompt (e.g. aRECRUITMENT_ASSISTANTfrom before the split existed) automatically talks like a career advisor when the caller is a job seeker, without any data migration.
Job-seeker allowlist
The 15 tools job seekers are allowed to call (defined in tool_service.py as JOB_SEEKER_ALLOWED_TOOL_TYPES):
| Category | Tools |
|---|---|
| Discovery & search | RAG_SEARCH, JOB_SEARCH, RECOMMEND_JOBS_FOR_TALENT, GET_MATCH_SCORES, COMPANY_INFO |
| CV self-service | CV_READ, CV_UPDATE, CV_EXTRACTION, CV_ANALYSIS, GET_TALENT_CV_DETAILS |
| External parsing | JOB_PARSING |
| Schedule & meetings (own) | INTERVIEW_SCHEDULE, MEETING_LIST, GET_MEETING_INSIGHT |
| Self profile | GET_ME, GET_MY_STATS, GET_MY_SUBSCRIPTION, GET_MY_RECENT_ACTIVITY, UPDATE_MY_PROFILE |
Every tool in the allowlist has a job-seeker branch in its handler that scopes reads to the caller (by userId, by self-owned Talent row, or by MeetingAttendee membership) instead of by companyId:
| Handler | Job-seeker scoping strategy |
|---|---|
_handle_job_search | Drops the "companyId" = $1 filter; LEFT JOIN "UserJobMatchScore" on self.user_id and ORDER BY ms.score DESC NULLS LAST, j."createdAt" DESC so keyword results are ranked by personalised fit when scores exist |
_handle_recommend_jobs_for_talent | talent_id no longer required — resolves the target directly from self.user_id, drops the company filter, and includes company_name + job_id in every result entry so the LLM can render "Title at Company" |
_handle_interview_schedule | WHERE c."userId" = $1 — scopes to interviews where the caller is the candidate |
_handle_get_match_scores | Drops the j."companyId" = $1 filter, scopes by ms."userId" = self.user_id, always filters out DRAFT/CLOSED jobs |
_handle_company_info | New company_id / company_name params let the caller look up any company; private stats (total_talents, total_candidates, total_interviews, team_members, member list) are suppressed for cross-company lookups — only public fields and active-job counts are returned |
_handle_meeting_list | JOIN "MeetingAttendee" ma ON ma."meetingId" = m.id WHERE ma."userId" = $1 — scopes to meetings the caller was invited to |
_handle_get_meeting_insight | meeting_id / interview_id lookups join MeetingAttendee for attendance scoping; candidate_id is refused in job-seeker mode with "recruiter-only lookup" error |
_assert_talent_access | Self-ownership branch: WHERE id = $1 AND "userId" = $2 — a job seeker can reference their own Talent row but nobody else's |
Recruiter tools (TALENT_SEARCH, CANDIDATE_SEARCH, PIPELINE_OVERVIEW, CREATE_TALENT, UPDATE_TALENT, UPDATE_CANDIDATE_STATUS, ADD_CANDIDATE_TO_JOB, ADD_TALENT_NOTE, RECOMMEND_TALENTS_FOR_JOB, HIRING_ANALYTICS, TALENT_POOL_INSIGHTS, SHORTLIST_CANDIDATES, COMPARE_CANDIDATES, CONTACT_TALENT, EDIT_JOB, DUPLICATE_JOB, EDIT_CANDIDATE, GENERATE_JOB_DESCRIPTION, DATABASE_QUERY) are not in the allowlist — each returns a specific "use X instead" redirect via JOB_SEEKER_TOOL_REDIRECTS when a job-seeker agent attempts them.
Persona prefix
AgentConversationService._render_system_prompt() prepends one of two class-level constants based on context.user.view_mode:
| ViewMode | Prefix constant | Framing |
|---|---|---|
JOB_SEEKER | PERSONA_PREFIX_JOB_SEEKER | "You are the user's personal AI career assistant... help the user find jobs that match their skills, prepare for interviews, improve their CV, and track their applications. You do NOT have access to recruiter tools — do not attempt to call them. Your primary tools are: recommend_jobs_for_talent (no arguments), search_jobs, read_cv/update_cv, get_interviews, get_meetings, get_company_info, update_my_profile." |
EMPLOYER | PERSONA_PREFIX_EMPLOYER | "You are an AI recruitment assistant... help the user manage their company's hiring pipeline. Prefer recruiter tools: search_talents, search_candidates, recommend_talents_for_job, get_pipeline_overview, hiring_analytics, etc." |
The prefix lands before the agent's configured system_prompt in the rendered prompt, so if a job seeker is talking to an agent whose stored prompt says "You are a recruitment assistant", the persona prefix overrides that framing for the turn. This is what makes existing RECRUITMENT_ASSISTANT agents (created before the split) behave correctly for job seekers without a data migration.
Unit tests covering all three layers live in tests/unit/services/agent/test_job_seeker_branches.py (13 tests: dispatch gate, allowlist filter, self-ownership branch, persona prefix ordering).
We already had a JOB_MATCHING value in the AgentType enum, and the frontend does now use it for new job-seeker agents (see Default Agent Provisioning). But the type field is metadata-only on the backend — the dispatch gate and persona prefix both branch on self.company_id / context.user.view_mode, not on agent.type. That's deliberate: it means existing agents (like "Reza JS Assistant" — a RECRUITMENT_ASSISTANT row with companyId = NULL from before the split) just start working correctly when the new backend ships, with no migration. The JOB_MATCHING type is purely cosmetic / descriptive.
Data Model
The agent system is backed by 12 interconnected database tables:
| Table | Purpose |
|---|---|
Agent | Agent configuration: name, LLM model, system prompt, RAG settings, enabled tools, rate limits. companyId is nullable (NULL for job seeker agents) |
AgentToolDefinition | Persistent tool definitions with pgvector embeddings for Tool RAG (semantic tool selection) |
AgentTaskConfig | Task-specific overrides (system prompt, temperature, RAG filters, tools, guardrails) |
AgentDatabaseConfig | Database access permissions: allowed/denied tables, PII masking, query templates |
AgentConversation | Conversation session between a user and an agent, with token tracking and feedback. companyId is nullable for job seeker conversations |
AgentMessage | Individual messages (USER, ASSISTANT, SYSTEM, TOOL) with token metrics and RAG context. See Message role semantics for how TOOL rows relate to ASSISTANT toolCalls. |
AgentDocument | Documents uploaded to an agent's knowledge base with RAG inclusion control |
AgentDocumentChunk | Text chunks with pgvector embeddings (1024 dimensions) for RAG retrieval |
AgentToolExecution | Execution log of tool calls with input, output, duration, and SQL query details |
AgentActivity | Audit log for agent operations (27 activity types) |
AgentBackgroundTask | Background task queue for document processing and embedding generation |
AgentTokenUsage | Token consumption tracking per period for billing |
Agent Configuration
| Field | Type | Default | Description |
|---|---|---|---|
name | String | -- | Display name (unique per company) |
type | AgentType | RECRUITMENT_ASSISTANT | RECRUITMENT_ASSISTANT, TALENT_SEARCH, JOB_MATCHING, INTERVIEW_PREP, DATA_ANALYST, CUSTOM (editable after creation) |
status | AgentStatus | ACTIVE | ACTIVE, INACTIVE, MAINTENANCE, DEPRECATED |
llmProvider | String | bedrock | LLM provider |
llmModel | String | eu.anthropic.claude-sonnet-4-6 | Bedrock model ID. Default is the cheapest reasoning-capable Claude — see AI Models for the supported set. Haiku/Nova IDs work but won't stream reasoning. |
temperature | Float | 0.7 | LLM temperature |
maxTokens | Int | 4096 | Max output tokens |
systemPrompt | String | null | Custom system prompt (overridden by TaskConfig if set) |
ragEnabled | Boolean | false | Enable RAG document search |
ragTopK | Int | 5 | Number of RAG chunks to retrieve |
ragMinScore | Float | 0.15 | Minimum similarity score for RAG |
embeddingModel | String | amazon.titan-embed-text-v2:0 | Embedding model for RAG |
embeddingDimension | Int | 1024 | Embedding vector dimensions |
enabledTools | AgentToolType[] | [] | Enabled tool types |
maxContextMessages | Int | 20 | Context window size (messages) |
maxMessagesPerMinute | Int | 20 | Rate limit per agent |
maxTokensPerDay | Int | 100000 | Daily token budget per agent |
Enums
All 27 tools are fully implemented and production-tested. The CUSTOM type is reserved for future company-specific tools.
Message Role Semantics
AgentMessage.role is one of USER, ASSISTANT, SYSTEM, TOOL. The interesting case is TOOL: tool results are persisted twice. Each tool execution writes a standalone TOOL row with the serialized output as its content, and the same output is embedded inside the parent ASSISTANT message's toolCalls[].output JSON column. The duplication is intentional — the TOOL rows exist for transcript/analytics exports that want a flat row-per-event view, while the embedded toolCalls[] is what the frontend actually renders.
Loader rule: any UI that maps conversationMessages(...) into a chat transcript must drop TOOL and SYSTEM rows at load time and render only USER + ASSISTANT. If TOOL rows leak through, every tool call will appear as a second "user" message with raw JSON content (because the frontend role switch is role === "assistant", so anything else falls through to the user visual). The assistant message already carries the complete toolCalls[] array with outputs, so dropping the TOOL rows loses nothing. See Loading Conversation History for the frontend implementation.
SYSTEM rows hold the rendered system prompt and are not meant for the UI either — drop them for the same reason.
History Reconstruction Invariants
get_conversation_history() in conversation_service.py rebuilds the Bedrock Converse messages array from the stored AgentMessage rows on every new turn. Bedrock has strict invariants and silent violations cause the next user turn to fail with ValidationException — the symptom users see is "after some turns the agent raises errors". The loader enforces:
- One user message per assistant
toolUseturn. When an ASSISTANT row has multipletoolUseblocks (parallel tool calls), the loader scans ahead and batches every contiguous TOOL row into a single user message containing all matchingtoolResultblocks. Bedrock rejects splittoolResults withExpected toolResult blocks at messages.X.content for the following Ids: …. The live continuation path (_build_tool_continuation_messages) batches correctly during streaming, so this only manifests on history replay. success → statuspropagation. Per-tool success/error from the assistant'stoolCalls[]jsonb is propagated totoolResult.status("success" or "error") on replay, mirroring the live path. Without this, replayed errors look like successes to the model and it hallucinates downstream.- No empty
textcontent blocks. Bedrock rejects[{"text": ""}]withThe text field in the ContentBlock object … is blank. Empty TOOL content is replaced with(no output). Assistant rows with empty content and notoolCalls(zombie STREAMING rows from interrupted streams) are dropped entirely. - No orphan
toolUseblocks. If an assistant emittedtoolUsebut no matching TOOL rows landed (e.g., the server crashed between the assistant emit and the tool persist), the entire assistant row is dropped. Otherwise Bedrock rejects the next user message because its toolResult IDs don't match. - Orphan TOOL rows are skipped. Legacy/corrupted TOOL rows with
toolCallIds that don't match any precedingtoolUseare skipped with a warning. Bedrock rejectstoolResultblocks without a precedingtoolUse.
The summarisation path (_summarize_context_if_needed) reads text from either content[0].text or content[0].toolResult.content[0].text so tool-result-only user messages (now common after the batching change) don't silently drop their content from the summary.
Stale STREAMING Janitor
ASSISTANT rows are created with status='STREAMING' and content='' before the LLM call begins, then transitioned to COMPLETED by update_content() once streaming finishes. If the stream is interrupted mid-flight (client disconnect, transient exception, OOM kill) the row can be stranded in STREAMING. On the next user turn the history loader would emit it as [{"text": ""}] and Bedrock would reject (see invariant 3 above).
Two layers prevent this:
try / except (GeneratorExit, asyncio.CancelledError) / except Exception / finallyaround the entiresend_message_streambody. Strawberry/Starlette can raise eitherGeneratorExit(whenaclose()is called on the generator) orasyncio.CancelledError(when the task is cancelled). Both areBaseExceptionsubclasses in Python 3.8+, so a bareexcept Exceptiondoes not catch them. Each branch updates the row toFAILEDwith a specificerrorCode(CANCELLED, the exception text, orUNKNOWNfor thefinallyfallback). Don'tyield ChatErrorfrom inside the generator —@subscription_error_handleralready converts the raised exception into aChatErrorevent, double-emission produces duplicate frames on the wire.- Startup janitor in
app/main.pylifespan callsAgentMessageModel.cleanup_stale_streaming(max_age_seconds=300)after schema validation. It marks everySTREAMINGrow older than 5 minutes asFAILEDwitherrorCode='STREAM_TIMEOUT'. TheWHEREclause hits the existingAgentMessage_status_idxand the operation is idempotent, so it is safe to run on a hot system. Wrapped in its owntry/exceptso a janitor failure cannot block worker startup.
Message Processing Pipeline
When a user sends a message, the following pipeline executes:
Key Implementation Details
- Message validation: Messages are capped at 10,000 characters (~2,500 tokens). Longer messages are rejected with a
ValueError - RAG context injection: RAG results are injected into the user message as a
[context]...[/context]block (not the system prompt), giving the LLM better signal about provenance - System prompt augmentation: When RAG returns results, the system prompt gets appended with: "When answering, use the [context] block provided in the user message. Cite the source document when relevant."
- Multi-hop tool calls: The LLM can chain tool calls across up to 3 rounds with up to 10 tool calls per round. Each round tracks its own response text to avoid duplicating content in the conversation history
- Context summarization: When conversation history exceeds
maxContextMessages, older messages are summarized into bullet points using Nova Lite LLM. The summary is injected into the system prompt as[Previous conversation summary]and saved to thecontextSummaryfield in the database - Non-blocking title generation: After the first user-assistant exchange (and every 5 messages), a concise title is generated asynchronously using Nova Lite LLM via
asyncio.create_task(). The subscription closes immediately without waiting for the title. The frontend refetches the conversation list after a 3-second delay to pick up the new title - History reconstruction invariants: Parallel
toolUse/toolResultbatching, zombie-row drops, orphan handling, and per-tool error-status propagation are all enforced byformat_history()— see History Reconstruction Invariants above for the full set
RAG Pipeline
Document Ingestion
The Document Worker (30-second polling cycle, 5 documents per batch) processes documents in parallel via asyncio.gather():
- Text Extraction -- Extracts raw text from uploaded documents using format-specific handlers (see supported file types below)
- Chunking -- Splits text using configurable strategies:
recursive(default),token, ormarkdown. Uses LangChain text splitters when available, with custom fallback implementation - Embedding -- Generates vector embeddings using Amazon Titan Embed v2 (1024 dimensions), batches of 10, with TTL-based caching (configurable via
AGENT_EMBEDDING_CACHE_TTL, default 300s) - Storage -- Persists chunks and embeddings to the
AgentDocumentChunktable with pgvector
Each document has a configurable timeout (AGENT_DOCUMENT_TIMEOUT, default 600s). Timed-out documents are marked as FAILED automatically.
Document status progression: PENDING → PROCESSING → CHUNKING → EMBEDDING → COMPLETED (or FAILED)
Supported file types:
| Format | MIME Type | Handler | Notes |
|---|---|---|---|
application/pdf | Native text extraction + OCR fallback | OCR uses AWS Bedrock for scanned PDFs | |
| Word (.docx) | application/vnd.openxmlformats-officedocument.wordprocessingml.document | python-docx (paragraphs + tables) | Recommended Word format |
| Word (.doc) | application/msword | python-docx attempt + raw text extraction | Legacy format; best results with .docx |
| Excel (.xlsx) | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | openpyxl | All sheets extracted |
| Excel (.xls) | application/vnd.ms-excel | openpyxl | Legacy Excel format |
| Plain text | text/plain | Direct UTF-8 decode | Includes .txt files |
| Markdown | text/markdown | Direct UTF-8 decode | .md files |
| CSV | text/csv | Direct UTF-8 decode | Comma-separated values |
| HTML | text/html | Direct UTF-8 decode | Raw HTML content |
| Images | image/* | OCR via AWS Bedrock | JPEG, PNG, GIF, WebP, TIFF |
For best results with Word documents, use the modern .docx format. Legacy .doc files (pre-2007) undergo raw text extraction which may not preserve all formatting. If a .doc upload fails, save as .docx and re-upload.
Chunking strategies:
| Strategy | Separators | Best For |
|---|---|---|
recursive | \n\n, \n, . , , , , "" | General documents |
token | Token-based (4 chars/token estimate) | Fixed token budgets |
markdown | ## , ### , #### , ```, \n\n, \n, . | Markdown documents |
Hybrid Search
At query time, the system performs hybrid search combining two retrieval strategies:
- Vector similarity (weight: 0.7) -- Cosine similarity between the query embedding and stored chunk embeddings via pgvector
- Full-text search (weight: 0.3) -- PostgreSQL
tsvectorfull-text search using thesimpletokenizer (language-agnostic, notenglish) so non-English queries aren't penalized
When text_score > 0, the combined score is 0.7 * vector_score + 0.3 * text_score. Otherwise, falls back to pure vector score.
Diversity filter: After scoring, adjacent chunks from the same document (within 2 chunk indices) are deduplicated to avoid returning overlapping content.
Query embedding cache: A TTL-based LRU cache (100 entries, 300s TTL) avoids redundant embedding calls for repeated queries.
Tool System
Agents can be configured with tools that allow them to take actions during conversations. When the LLM determines a tool call is needed, it emits a structured tool call request via the Bedrock Converse API, the system executes it, and the result is fed back into the conversation.
Tool Definitions (Persistent)
Tool definitions are stored in the AgentToolDefinition database table with pre-computed pgvector embeddings for efficient tool selection. This replaces the previous hardcoded tool registry.
| Field | Type | Description |
|---|---|---|
toolType | AgentToolType | Enum value matching the tool |
toolCode | String | Unique identifier (e.g., rag_search) |
name | String | Display name |
description | Text | Natural language description for LLM and UI |
searchQueries | Text | Comma-separated search terms for embedding generation |
category | String | Grouping: data, recruitment, scheduling, ai_pipeline, insights |
alwaysInclude | Boolean | If true, tool is always passed to LLM regardless of relevance |
embedding | vector(1024) | Pre-computed Titan Embed v2 embedding for Tool RAG |
parametersSchema | JSON | JSON Schema for tool parameters |
isSystem | Boolean | System-provided (true) vs company-custom (false) |
isActive | Boolean | Soft-delete flag |
version | Int | Bumped on update; triggers re-embedding |
embeddingVersion | Int | Tracks last embedded version (0 = never embedded) |
On startup, background-tasks runs ensure_tool_embeddings() which embeds any tool definitions where embedding IS NULL or embeddingVersion < version.
Tool RAG (Semantic Tool Selection)
When an agent has 12+ tools enabled, the system uses Tool RAG to select the most relevant tools for each message instead of passing all tools to the LLM:
- Embed the user's message using Titan Embed v2
- Query
AgentToolDefinitionvia pgvector cosine similarity (1 - (embedding <=> query)) - Filter by the agent's
enabledToolslist and minimum similarity score (0.25) - Always include tools marked
alwaysInclude(e.g.,RAG_SEARCH) - Return top-K relevant tools (default: 5) plus always-included tools
Below the threshold (fewer than 12 tools), all enabled tools are passed directly.
Tool Categories
| Category | Tools | Description |
|---|---|---|
| Data | RAG_SEARCH, DATABASE_QUERY | Knowledge base and database access |
| Recruitment | TALENT_SEARCH, JOB_SEARCH, CANDIDATE_SEARCH, COMPANY_INFO, PIPELINE_OVERVIEW, CREATE_TALENT, UPDATE_TALENT, UPDATE_CANDIDATE_STATUS, ADD_CANDIDATE_TO_JOB, ADD_TALENT_NOTE | Recruitment data and write actions |
| Scheduling | INTERVIEW_SCHEDULE, MEETING_LIST | Calendar and meeting data |
| AI Pipeline | CV_EXTRACTION, JOB_PARSING, RECOMMEND_TALENTS_FOR_JOB, RECOMMEND_JOBS_FOR_TALENT, CV_ANALYSIS, GENERATE_JOB_DESCRIPTION, COMPARE_CANDIDATES | AI-powered document processing, matching, and analysis |
| CV Operations | CV_READ, CV_UPDATE | Read and modify structured CV data (available to both employers and job seekers) |
| Insights | HIRING_ANALYTICS, TALENT_POOL_INSIGHTS, GET_MATCH_SCORES, GET_TALENT_CV_DETAILS, SHORTLIST_CANDIDATES, GET_MEETING_INSIGHT | Analytics, reporting, and workflow automation |
Implemented Tools (27 Total)
The tool system is divided into Read, Write, AI-Powered, and Analytics tools. All tools are company-scoped and include multi-tenant isolation.
Read Tools
These tools query data without modifying it.
| Tool | Function Name | Description | Parameters | Returns |
|---|---|---|---|---|
RAG_SEARCH | rag_search | Search company knowledge base via hybrid vector + full-text search | query (required), top_k (default 5), document_tags (array) | Matching document chunks with content, filename, category, and similarity score |
DATABASE_QUERY | database_query | Execute read-only SQL against whitelisted tables | query (required, SELECT only), template_name, params | Query result rows (max 500), row count, truncation flag |
TALENT_SEARCH | search_talents | Search talent pool by skills, experience, location | skills (array, max 20), experience_years, location, availability, limit (max 50) | Talent profiles with name, title, location, skills, and CV info |
JOB_SEARCH | search_jobs | Search active job openings (excludes DRAFT/CLOSED) | keywords, job_type (FREELANCE_GIG/DIRECT_HIRE/TRIAL_AND_HIRE), location, limit (max 50) | Job listings with title, status, type, location, and skill requirements |
COMPANY_INFO | get_company_info | Get company details, team members, and statistics | include_members (default false), include_stats (default true) | Company info, job/talent/candidate counts, team member list with roles |
CANDIDATE_SEARCH | search_candidates | Find candidates across pipelines with status filtering | job_id, status (NEW/IN_REVIEW/INTERVIEW/APPROVED/REJECTED/WITHDRAWN), keywords, limit (max 50) | Candidates with name, status, job title, pipeline position, plus job-preference fields from CvJobPreference — expected_salary, salary_currency, salary_frequency, preferred_work_site, available_in, desired_job_titles (LEFT JOIN, null when the talent hasn't filled them in) |
PIPELINE_OVERVIEW | get_pipeline_overview | View candidate stage breakdown and funnel metrics | job_id (optional, omit for company-wide) | Candidate counts per status, grouped by job |
INTERVIEW_SCHEDULE | get_interviews | List interviews with scheduling info | status (PENDING/SCHEDULED/COMPLETED/CANCELLED/NO_SHOW), upcoming_only (boolean), limit (max 50) | Interviews with date, participants, job context, and scores |
MEETING_LIST | get_meetings | View meetings with insight availability | status (SCHEDULED/ACTIVE/COMPLETED/CANCELLED), upcoming_only (boolean), limit (max 50) | Meetings with type, organizer, transcription and insight status |
GET_TALENT_CV_DETAILS | get_talent_cv | Get full structured CV data for a talent — including CvJobPreference (expected salary, currency/frequency, preferred work site, availability, desired job titles). The tool description explicitly mentions salary so the LLM picks this tool for compensation queries. | talent_id (required) | Complete structured CV: profile (PII masked), work experience, education, skills, certifications, languages, projects, publications, awards, job preferences, and metadata. Every list item carries its row-level id (used by update_cv to target rows for update/remove). |
CV_READ | read_cv | Read full structured CV data by CV, user, or talent ID | cv_id, user_id, or talent_id (at least one) | Structured CV with profile, skills, experience, education, certifications, languages. Company-scoped for employer access, user-scoped for job seekers |
GET_MATCH_SCORES | get_match_scores | Retrieve pre-computed match scores from the background matching engine | talent_id, job_id, min_score (0-100, default 0), limit (max 50) | Talent-job scores with breakdown (skills, experience, salary, location), calculated timestamp |
GET_MEETING_INSIGHT | get_meeting_insight | Retrieve AI-generated meeting/interview insights | meeting_id, interview_id, candidate_id (provide at least one) | Skills assessment, communication analysis, key topics summary, red flags, full report |
Write Tools
These tools modify data in the database. All write operations verify company ownership and create audit trails. Detail-page write tools support entity fallback — on the relevant detail page, the entity UUID can be omitted and the handler uses self.context.entity.entity_id automatically. See Entity Fallback Resolution.
| Tool | Function Name | Description | Parameters | Returns |
|---|---|---|---|---|
CREATE_TALENT | create_talent | Create new talent in the company's talent pool | name (required), email (required, regex validated), phone, location, title, linkedin_url, skills (array), biography | Created talent ID, name confirmation. Creates User + TalentPool link + Talent (MANUAL_ENTRY source) |
UPDATE_TALENT | update_talent | Update an existing talent's profile information | talent_id (optional on talent_detail), first_name, last_name, title, location, phone, linkedin_url, biography | Updated talent ID, list of changed fields |
UPDATE_CANDIDATE_STATUS | update_candidate_status | Move a candidate through the recruitment pipeline | candidate_id (optional on candidate_detail), status (required: NEW/IN_REVIEW/INTERVIEW/APPROVED/REJECTED/WITHDRAWN), reason (for REJECTED/WITHDRAWN) | Old status, new status, candidate name, job title |
EDIT_CANDIDATE | edit_candidate | Edit non-status candidate fields (motivation, report, expected salary). Status changes go through update_candidate_status. | candidate_id (optional on candidate_detail), motivation, report, withdrawal_reason, expected_salary, salary_frequency (HOURLY/DAILY/WEEKLY/MONTHLY/YEARLY), order | Updated candidate ID, changed fields |
ADD_CANDIDATE_TO_JOB | add_candidate_to_job | Add an existing talent from the pool to a specific job as a candidate | talent_id (optional on talent_detail), job_id (optional on job_detail), motivation (notes) | Created candidate ID, name, job title, initial status (NEW). Prevents duplicate candidacy |
ADD_TALENT_NOTE | add_talent_note | Add a note to a talent's profile (job-independent observations) | talent_id (optional on talent_detail), content (required, markdown supported), title (optional) | Created note ID, talent name. Author is the current user |
CONTACT_TALENT | contact_talent | Open (or reuse) a DIRECT Conversation with the talent's user and post a Message. Creates ConversationParticipant rows and updates the denormalized lastMessageAt/lastMessageText/messageCount fields. | talent_id (optional on talent_detail), message (required) | Conversation ID, message ID, recipient name |
EDIT_JOB | edit_job | Update fields on an existing Job posting via a strict allowlist | job_id (optional on job_detail), title, description, location, salary, currency, job_type (enum), work_site (REMOTE/ONSITE/HYBRID), status (enum), priority, published_online | Updated job ID, changed fields |
DUPLICATE_JOB | duplicate_job | Clone a Job as a new DRAFT with " (Copy)" appended to the title. The new job has zero candidates. | job_id (optional on job_detail) | New job ID, source job ID |
CV_UPDATE | update_cv | Additive updates to a CV via direct SQL writes — never deletes existing rows unless explicitly listed in remove[]. Each list section takes an envelope { add: [...], update: [{id, ...}], remove: [<id>, ...] }; the 1:1 sections (profile, job_preference) take a flat object that is upserted. | cv_id (optional when the caller owns the active CV), updates (object with optional keys: profile, job_preference, skills, work_experiences, educations, certifications, languages — singular aliases work_experience / education / certification / language / jobPreference / preferences are also accepted) | Updated section list, section_summary ({added, updated, removed} per section), ignored_keys, CV ID. Writes inside a single transaction across CvProfile, CvJobPreference, CVSkill, CvWorkExperience, CvEducation, CvCertification, CvLanguage. |
update_cv — additive envelope (SUP-00153, 2026-04-24)The previous implementation replaced every list section wholesale (DELETE FROM "X" WHERE "cvId" = $1 followed by inserts of whatever the LLM sent back). When the model regenerated a section but omitted any pre-existing row — a common LLM behaviour — that row was silently lost. The handler now consumes an explicit envelope per list section:
{
"updates": {
"profile": { "firstName": "...", "bio": "..." },
"job_preference": {
"expectedSalary": 80000, "salaryCurrency": "EUR", "salaryFrequency": "MONTHLY",
"preferredJobTypes": ["FULL_TIME"], "preferredWorkSite": "REMOTE",
"availableIn": "IMMEDIATELY", "desiredJobTitles": ["Senior Backend Engineer"]
},
"skills": { "add": [{"name":"Python"}], "update": [{"id":"...", "proficiency":"EXPERT"}], "remove": ["..."] },
"work_experiences": { "add": [...], "update": [{"id":"...", ...}], "remove": ["..."] },
"educations": { "add": [...], "update": [{"id":"...", ...}], "remove": ["..."] },
"certifications": { "add": [...], "update": [{"id":"...", ...}], "remove": ["..."] },
"languages": { "add": [...], "update": [{"id":"...", ...}], "remove": ["..."] }
}
}
add[]only inserts; existing rows are never touched (CVSkilladds useON CONFLICT (cvId, skillId) DO UPDATEso re-adding a skill is idempotent and can also bump proficiency).update[]requires the row'sid(callread_cvfirst if the LLM doesn't have it). Updates are scoped byWHERE id = $row_id AND "cvId" = $cv_idso a hallucinated id can't write across CVs.remove[]is a list of row ids, also scoped by cvId.job_preferenceis a new section that closes the previous CvJobPreference write gap (expected salary, currency/frequency, preferred work site, availability, desired job titles).- Backwards compatibility shim: if a list section arrives as a bare list (legacy shape — old prompt cached in an in-flight conversation) the handler treats it as
{"add": list}and logs a warning. To be removed after one release.
update_cv — surfacing LLM key drift (SUP-00130, 2026-04-21)The handler normalises common LLM mistakes (work_experience → work_experiences, education → educations, certification → certifications, language → languages) and echoes an ignored_keys array on every result — both successes and failures — so the caller can see exactly which sections the agent tried to update but the handler couldn't interpret. When every supplied key is unrecognised, the tool returns a structured error with the supported_sections list instead of the former silent success=False / "No valid sections".
Self Tools
Broad user context tools that operate on the caller. These need no entity UUID — the handler reads everything from self.context.user, which is populated by context_builder.build_agent_context at the top of every turn.
| Tool | Function Name | Description | Parameters | Returns |
|---|---|---|---|---|
GET_ME | get_me | Return the caller's full user profile | — | user_id, name, email, view_mode, company_id, company_name, role_in_company, plan, billing_mode, subscription_status, credit_balance, active_cv_id, is_super_admin |
GET_MY_STATS | get_my_stats | Summary counters scoped to the caller. Employers: jobs, talents, candidates, upcoming interviews. Job seekers: applications and upcoming interviews. | — | scope (company/self) + counter fields |
GET_MY_SUBSCRIPTION | get_my_subscription | Plan, billing mode, status, price, credit balance, trial/renewal dates. Resolves via Company.billingOwnerId for employers or Subscription.userId for job seekers. | — | plan, billing_mode, subscription_status, credits_per_month, price, currency, interval, trial_end, end_date, credit_balance |
GET_MY_RECENT_ACTIVITY | get_my_recent_activity | Recent notifications and credit transactions for the caller | limit (default 10, max 50) | notifications[], credit_transactions[] |
UPDATE_MY_PROFILE | update_my_profile | Update the caller's own User row via a strict allowlist. Email is validated against a regex. | first_name, last_name, email, phone, title, location, biography, linkedin_url, portfolio_url (all optional, at least one required) | updated_fields list |
AI-Powered Tools
These tools invoke LLM inference or the matching engine. They use Haiku 4.5 for fast responses and structured JSON output.
| Tool | Function Name | Description | Parameters | Returns |
|---|---|---|---|---|
CV_EXTRACTION | extract_cv | Extract structured data from CV documents via full OCR pipeline | file_url (required, S3 path or URL) | Parsed CV with structured sections. Calls CVExtractionService with 120s timeout |
JOB_PARSING | parse_job | Parse job descriptions into structured requirements | text or file_url (one required) | Structured job data: title, skills, requirements, qualifications. Calls JobParsingService with 90s timeout |
RECOMMEND_TALENTS_FOR_JOB | recommend_talents_for_job | Find and rank best-matching talents from the talent pool for a job | job_id (required), min_score (0-100, default 40), limit (max 20) | Ranked talent list with match scores and breakdown (skills, experience, salary, location) from UserJobMatchScore |
RECOMMEND_JOBS_FOR_TALENT | recommend_jobs_for_talent | Find and rank best-matching active jobs for a specific talent | talent_id (required), min_score (0-100, default 40), limit (max 20) | Ranked job list with match scores and breakdown. Excludes DRAFT/CLOSED jobs |
CV_ANALYSIS | analyze_cv | Deep AI analysis of a talent's CV: career trajectory, strengths, concerns, and skill gaps | talent_id (reads active CV), cv_id (alternative direct CV reference), job_id (optional, for gap analysis) | JSON analysis: career_trajectory, seniority_level, key_strengths (3-5), potential_concerns, skill_gaps (if job context), recruiter_recommendations, overall_assessment |
GENERATE_JOB_DESCRIPTION | generate_job_description | AI-generate a professional job description from parameters | title (required), skills (array, max 15), seniority_level (Entry/Junior/Mid/Senior/Lead/Principal), job_type, work_site (REMOTE/HYBRID/ONSITE), location, additional_requirements | JSON: title, summary, responsibilities (5-8), requirements (5-8), nice_to_have (3-5), benefits (4-6). Includes company context from DB |
COMPARE_CANDIDATES | compare_candidates | Side-by-side AI comparison of 2-5 candidates for a job | candidate_ids (required, array of 2-5 UUIDs), job_id (required) | Candidate profiles with match scores, plus AI analysis: comparison_summary, recommendation, key_differences (3-5) |
Analytics Tools
These tools provide aggregated insights and support workflow automation.
| Tool | Function Name | Description | Parameters | Returns |
|---|---|---|---|---|
HIRING_ANALYTICS | get_hiring_analytics | Hiring funnel analytics: conversion rates, time-to-hire, velocity | job_id (optional, omit for company-wide), days_back (7-365, default 90) | total_candidates, candidates_by_status, conversion_rates_pct (new→review, review→interview, interview→approved), hiring_velocity_per_month, candidates_per_job_avg, active_jobs_with_candidates |
TALENT_POOL_INSIGHTS | get_talent_pool_insights | Talent pool health: skill distribution, source breakdown, growth trends | talent_pool_id (optional, omit for all pools) | total_talents, by_source (MANUAL_ENTRY/INVITED/SHARED/CV_UPLOAD), top_skills (top 20), with_cv/without_cv counts, growth (30/60/90 day new talent counts) |
SHORTLIST_CANDIDATES | shortlist_candidates | Bulk advance candidates to IN_REVIEW status | job_id (required), min_score (auto-shortlist above threshold), candidate_ids (explicit selection), max_candidates (1-25, default 10) | Number shortlisted, candidate names. Only advances candidates currently in NEW status |
Tool Management API
Tool definitions can be managed via GraphQL:
# Query available tools for a company
query ToolDefinitions($companyId: String!) {
toolDefinitions(companyId: $companyId) {
success
tools { id toolType toolCode name description category alwaysInclude isSystem isActive }
}
}
# Update a tool definition (bumps version, triggers re-embedding)
mutation UpdateToolDefinition($input: UpdateToolDefinitionInput!) {
updateToolDefinition(input: $input) { id name description isActive }
}
# Toggle a tool active/inactive
mutation ToggleToolDefinition($toolId: String!, $isActive: Boolean!) {
toggleToolDefinition(toolId: $toolId, isActive: $isActive) { success message }
}
Tool Resilience
All tool handlers are wrapped with retry and circuit breaker protection:
- Retry with backoff: Up to 5 retries with exponential backoff (1s to 15s) for transient errors (DB connection drops, timeouts, network errors)
- Circuit breaker: Module-level per-tool-type circuit breakers (shared across all conversations). After 8 consecutive failures, the circuit opens and returns fast errors for 30 seconds before testing recovery. Configurable:
failure_threshold=8,reset_timeout=30s,call_timeout=90s - Transient exceptions:
asyncpg.PostgresConnectionError,asyncpg.InterfaceError,asyncio.TimeoutError,ConnectionError,OSError
Non-transient errors (validation failures, permission denied) are returned immediately without retry.
Tool Security Hardening
All tools include security measures to prevent abuse and ensure data integrity:
| Protection | Scope | Details |
|---|---|---|
| Limit bounds checking | All search tools | max(1, min(limit, 50)) — prevents negative or extreme LIMIT values |
| Recommendation limits | RECOMMEND_TALENTS_FOR_JOB, RECOMMEND_JOBS_FOR_TALENT | Max 20 results per request |
| Shortlist cap | SHORTLIST_CANDIDATES | Max 25 candidates per bulk operation |
| Candidate comparison cap | COMPARE_CANDIDATES | Exactly 2-5 candidates required |
| Skills array cap | TALENT_SEARCH, GENERATE_JOB_DESCRIPTION | Max 20 and 15 skills respectively |
| Score bounds | All tools with min_score | Clamped to 0-100 range |
| Timeout protection | CV_EXTRACTION, JOB_PARSING | asyncio.wait_for() with 120s and 90s timeouts respectively |
| SQL safety | DATABASE_QUERY | SELECT-only, CTE blocking, subquery blocking, error messages sanitized |
| Row cap | DATABASE_QUERY | Max 500 rows returned regardless of LIMIT |
| Output truncation | All tools | Tool output truncated to 50KB before database save (json.dumps(default=str)) |
| Email validation | CREATE_TALENT | Regex check on email format before user creation |
| Duplicate check | ADD_CANDIDATE_TO_JOB | Prevents duplicate candidacy for the same user+job pair |
| Status validation | UPDATE_CANDIDATE_STATUS | Only allows valid CandidateStatus enum values |
| PII masking | GET_TALENT_CV_DETAILS, DATABASE_QUERY | PII fields (email, phone, DOB, address) masked as ***MASKED*** |
| Ownership verification | All write tools | Verifies talent/candidate/job belongs to the agent's company before modification |
| CV access helper | CV_READ, CV_UPDATE | _assert_cv_access(cv_id) is the single source of truth for CV authorization. Company agents must own the underlying talent-pool or be in a UserCompanyRole with the CV owner; jobseeker agents must be the CV's owning user; missing caller identity is always denied. Both read and update paths go through this helper so they can't drift apart. |
| Audit trail | All write tools | user_id recorded as author for notes, the updatedAt timestamp is set |
| Parameterized queries | All tools | No string interpolation in SQL — all values passed as parameters |
| Company scoping | All tools | Every query filtered by company_id for multi-tenant isolation |
Tool Name→Type Mapping
The AgentToolService.get_name_to_type_mapping() method provides a single source of truth for mapping tool names to their types. This mapping is used by conversation_service.py when parsing streaming events, eliminating the need for a hardcoded duplicate dictionary.
Database Access Configuration
The DATABASE_QUERY tool requires an AgentDatabaseConfig record. Queries are validated by _validate_sql_safety():
- Comment stripping -- Block comments (
/* ... */) and line comments (-- ...) are removed before validation - Semicolon blocking -- Multiple statements rejected to prevent injection chaining
- SELECT only -- Query must start with
SELECTafter comment stripping - CTE blocking -- Common Table Expressions (
WITH ... AS (...)) are rejected to prevent restriction bypass - Subquery blocking -- Nested
(SELECT ...)expressions are rejected to prevent exfiltration - Forbidden keywords -- Word-boundary regex matching for:
DROP,DELETE,UPDATE,INSERT,ALTER,TRUNCATE,EXEC,EXECUTE,CREATE,GRANT,REVOKE,UNION,INTO OUTFILE,INTO DUMPFILE,COPY,SET,LOAD - Table restrictions -- Denied tables checked first (blocklist priority), then allowed tables whitelist
- Row limits -- Auto-appends
LIMITif not present - PII masking -- Fields containing
email,phone,ssn,address,date_of_birthmasked as***MASKED*** - Salary masking -- Fields containing
salary,compensation,pay,wage,incomemasked as***CONFIDENTIAL***
| Config Field | Type | Default | Description |
|---|---|---|---|
accessLevel | DatabaseAccessLevel | READ_ONLY | NONE, READ_ONLY, READ_WRITE |
allowedTables | String[] | [] | Whitelist of table names |
deniedTables | String[] | [] | Blacklist of table names (takes priority) |
maxRowsPerQuery | Int | 100 | Max rows returned per query |
queryTimeoutMs | Int | 5000 | Query timeout in milliseconds |
allowJoins | Boolean | true | Allow JOIN operations |
allowAggregations | Boolean | true | Allow GROUP BY / aggregations |
allowSubqueries | Boolean | false | Allow subqueries |
tablePermissions | JSON | {} | Per-table access rules: { "tableName": { "access": "read", "columns": ["col1"] } } |
columnRestrictions | JSON | {} | Column-level rules: { "table.column": "mask|hide|allow" } |
maskPII | Boolean | true | Mask personally identifiable information |
hideSalaryData | Boolean | true | Hide salary fields |
hidePersonalNotes | Boolean | true | Hide personal notes |
queryTemplates | JSON | [] | Pre-approved queries: [{ "name": "get_candidates", "query": "SELECT...", "params": ["jobId"] }] |
logAllQueries | Boolean | true | Log all queries for audit |
Each tool execution is logged in AgentToolExecution with input parameters, output, duration, SQL query details, RAG search metadata, and success/failure status.
Tool Auto-Seeding
Tool definitions are automatically synchronized from code to the AgentToolDefinition database table on every application startup. This ensures that newly added tools are immediately available without manual database operations.
How it works:
-
AgentToolService.seed_system_tools()runs first. It creates a temporary instance, calls_register_fallback_tools()to get the 27 hardcoded tool definitions, then upserts each one into theAgentToolDefinitiontable bytoolType. If a tool already exists in DB, only fields that actually changed are updated (preserving existing embeddings). Stale system tools (tools removed from code) are cleaned up automatically. -
ensure_tool_embeddings()runs second. It queries for any tool definitions whereembedding IS NULLorembeddingVersion < version. For each, it generates an embedding from the concatenation ofname + description + searchQueriesusing Amazon Titan Embed v2, stores the 1024-dimension vector, and updatesembeddingVersionto matchversion.
Loading priority: When an agent processes a message, _ensure_tools_loaded() attempts to load tool definitions from the database first (respecting any customizations made via the GraphQL API). The hardcoded fallback definitions are only used if the database load fails, ensuring new tools work immediately even before the auto-seeder runs.
Category mapping during seeding:
| Category | Tool Types |
|---|---|
data | RAG_SEARCH, DATABASE_QUERY |
recruitment | TALENT_SEARCH, JOB_SEARCH, CANDIDATE_SEARCH, COMPANY_INFO, PIPELINE_OVERVIEW, CREATE_TALENT, UPDATE_TALENT, UPDATE_CANDIDATE_STATUS, ADD_CANDIDATE_TO_JOB, ADD_TALENT_NOTE |
ai_pipeline | RECOMMEND_TALENTS_FOR_JOB, RECOMMEND_JOBS_FOR_TALENT, CV_ANALYSIS, GENERATE_JOB_DESCRIPTION, CV_EXTRACTION, JOB_PARSING, COMPARE_CANDIDATES |
cv_operations | CV_READ, CV_UPDATE |
insights | HIRING_ANALYTICS, TALENT_POOL_INSIGHTS, GET_MATCH_SCORES, GET_TALENT_CV_DETAILS, SHORTLIST_CANDIDATES, GET_MEETING_INSIGHT |
scheduling | INTERVIEW_SCHEDULE, MEETING_LIST |
Streaming Protocol
The agentChat GraphQL subscription emits a sequence of typed events as processing progresses:
Title generation is non-blocking. The subscription closes at ChatComplete without waiting for the title. The frontend refetches conversations after a 3-second delay to pick up the new title from the database.
Event Types
| Event | Fields | Description |
|---|---|---|
ChatProcessing | id, status, message | Request acknowledged, processing started |
ChatContextLoaded | id, contextChunks, contextTokens | RAG context retrieved (only if RAG enabled and results found) |
ChatChunk | id, delta, chunkIndex | Streaming text delta of the final response |
ChatReasoningChunk | id, delta, chunkIndex | Streaming extended-thinking delta from a reasoning-capable model. See Reasoning Streaming below. |
ChatToolCall | id, toolName, toolType, inputData | Agent invoked a tool (may occur multiple times across multi-hop rounds) |
ChatToolResult | id, toolCallId, output, success, executionTimeMs | Tool execution result |
ChatComplete | id, messageId, content, inputTokens, outputTokens, totalTimeMs | Full response with final content and token metrics |
ChatError | id, code, message, recoveryHint | Error occurred during processing |
Reasoning Streaming
When the agent's llmModel supports extended thinking (Claude Sonnet 4.x, Opus 4.x, Claude 3.7 Sonnet — see AI Models), the service enables Bedrock's additionalModelRequestFields.thinking and the Converse Stream returns a separate reasoningContent block alongside the usual text block. The agent service routes those deltas to a dedicated ChatReasoningChunk event so the frontend can render thinking in a collapsible "Show reasoning" surface — separate from the final answer chunks.
Frontend invariants:
ChatChunk.deltais appended tomsg.content(the canonical assistant message content shown in conversation history previews).ChatReasoningChunk.deltais never appended tomsg.content. It accumulates into a{ type: "reasoning", text }segment onmsg.contentSegments. This way conversation list previews show the answer, not the chain of thought.- The frontend coalesces consecutive reasoning chunks into one segment so the UI shows a single collapsible block per thinking pass rather than a fragmented stream.
- The subscription must select the
ChatReasoningChunkinline fragment explicitly (... on ChatReasoningChunk { id delta chunkIndex }). Without it, graphql-ws still routes the events but every field arrives asundefinedand the UI ends up with empty/NaN-looking blocks.
Tool-handler reasoning pipe
A few tool handlers (_handle_cv_analysis, _handle_generate_job_description, _handle_compare_candidates) ALSO call Bedrock with extended thinking enabled — to improve the structured-output quality of their assessments. Their reasoning would normally be invisible (the tool returns only its final structured result), but the agent loop forwards those deltas to the same ChatReasoningChunk event stream so the UI shows two waves of "Thinking…" per turn (the agent reasoning about which tool to call, then the tool reasoning while it executes).
Implementation: a per-task ContextVar pipe (app/services/agent/reasoning_pipe.py::REASONING_PIPE) holds an asyncio.Queue[str]. The tool helper _stream_thinking_converse in tool_service.py runs client.converse_stream() inside run_in_executor and pushes each reasoningContent.text delta back to the asyncio thread via loop.call_soon_threadsafe(pipe.put_nowait, …). send_message_stream runs _execute_tool_calls as a background task and concurrently drains the queue with asyncio.wait(FIRST_COMPLETED), yielding each delta as a ReasoningChunk.
Two non-obvious correctness rules apply:
- Cross-thread:
ContextVars do NOT propagate acrossrun_in_executorthread boundaries, andasyncio.Queue.put_nowait()is not thread-safe. The handler must read(loop, pipe)in the asyncio thread and pass them as plain arguments to the executor body. - Async-generator context: use
REASONING_PIPE.set(pipe)+set(previous)to save/restore — never.reset(token). Strawberry can resume the agent generator in a differentContextacrossyieldboundaries, which makesreset(token)raise "Token was created in a different Context".
Attachments
Users can attach files to messages via the attachments field on the agentChat subscription input. The agent service turns each attachment into a Bedrock Converse multimodal content block (image or document) so a reasoning-capable Claude model can read the file directly without calling a tool.
Supported MIME types and limits live in app/services/agent/attachment_processor.py:
| Category | MIME types | Per-file cap | Notes |
|---|---|---|---|
| Image | image/jpeg, image/png, image/gif, image/webp | 5 MB | Bedrock image cap |
| Document | application/pdf, application/msword (.doc), …wordprocessingml.document (.docx), application/vnd.ms-excel (.xls), …spreadsheetml.sheet (.xlsx), text/plain, text/markdown, text/html, text/csv | 4.5 MB | Bedrock document cap (tighter than images) |
Total per message: 20 MB across at most 5 attachments. Frontend pre-checks the same limits — see aiqlick-frontend/lib/config/attachmentLimits.ts.
Document name dedupe (Bedrock invariant)
Bedrock rejects requests where any two document.name fields collide with Messages can't contain duplicate document names. This trips up the obvious case (user attaches the same filename in two consecutive turns — both turns get rebuilt into the request on the second send) AND a less obvious case (frontend uploads two files at once with the same name). The processor enforces these rules:
_safe_doc_name(filename, used)strips the extension, replaces every char outside[A-Za-z0-9 \-()\[\]]with a space, collapses runs of whitespace, truncates to 180 chars, and appends-2/-3/… on collision. Mutatesusedto record the chosen name.- The
usedset is built once at the top ofsend_message_streamand threaded through bothget_conversation_history(for prior turns' attachments) ANDprocess_attachments(for the new turn's uploads), so dedupe is per-Bedrock-request, not per-call.
Bedrock's actual allowed character class is alphanumeric + whitespace + hyphens + parentheses + square brackets, with no consecutive whitespaces. Underscores, periods, slashes, and non-ASCII chars are NOT allowed in the name field — that's why Senior_Software_Developer_CV.pdf and top 20 i.t companies.pdf both used to fail until we replaced the disallowed chars with spaces.
Failure surface — fail loud, not silent
When a new-turn attachment fails to download from S3 or its actual bytes exceed the size cap, process_attachments raises AttachmentProcessingError(filename, reason). send_message_stream catches this BEFORE creating the user message + assistant placeholder rows (so neither becomes a zombie), yields a service-layer AttachmentFailed(filename, reason) event, and returns. The subscription handler maps AttachmentFailed → ChatError(code="ATTACHMENT_FAILED", recoveryHint="Re-upload the file and try again. If the problem persists, the file format may not be supported.").
The frontend (ChatPanel.tsx, ChatModal.tsx) restores pendingAttachments from a snapshot when ATTACHMENT_FAILED is observed so the user can retry without re-selecting the files. See Frontend AI Agent for the full UX.
For the history-replay path (rebuild_attachment_content_blocks), failure mode is different — an old file that was deleted from S3 should not break ongoing conversations, so the replay path falls back to a [Previously attached: …] text placeholder rather than raising.
URL-decode S3 key — backward compat
An older version of the aiqlick-frontend/app/api/upload/route.ts route stored urlObj.pathname directly as the S3 key, which is URL-percent-encoded (spaces → %20, parens → %28/%29, non-ASCII → %CX%XX). The actual S3 object lives at the literal key, so a backend GetObject with the encoded form returned 404 and the attachment was silently dropped. Two fixes shipped:
- The upload route now
decodeURIComponents the path before persisting (commitfcbf020bin aiqlick-frontend). _download_fileretries withunquote(key)on initial failure as a belt-and-braces fallback for any historical rows still in flight.
A one-shot Python migration (urllib.parse.unquote per row) backfilled the 5 historical AgentMessageAttachment.key rows in dev RDS that were stored encoded.
Error Codes
| Code | Description | Recovery |
|---|---|---|
CONVERSATION_NOT_FOUND | Invalid conversation ID | Create a new conversation |
CONVERSATION_INACTIVE | Conversation archived or deleted | Start a new conversation |
UNAUTHORIZED | No access to this conversation | Check JWT and company scope |
RATE_LIMITED | Too many messages per minute | Wait and retry |
TOKEN_LIMIT_EXCEEDED | Daily token budget exhausted | Wait for reset or increase limit |
CHAT_ERROR | LLM processing error | Retry the message |
TOOL_ERROR | Tool execution failed | Check tool configuration |
INSUFFICIENT_CREDITS | Credit balance too low | Purchase more credits |
SUBSCRIPTION_INACTIVE | Subscription not active | Activate subscription |
PLAN_LIMIT_EXCEEDED | Plan limit reached | Upgrade plan |
ATTACHMENT_FAILED | A user-provided attachment couldn't be downloaded from S3 or its actual bytes exceeded the size limit. Surfaced BEFORE the LLM call so the model never sees an incomplete request. | Re-upload the file and try again. The frontend restores pendingAttachments from a snapshot so the user keeps their selections. |
Billing Integration
The agent chat is fully integrated with the billing system:
- Pre-check (
BillingGate.pre_check): Before LLM call, validates subscription status (ACTIVE/TRIALING), plan limits, and credit balance - Consumption (
BillingGate.check_and_consume): After response completes, calculates cost from token usage and deducts from credit balance - Failure logging (
BillingGate.log_failure): On errors, logs the failure for audit trail - Enterprise bypass: POSTPAID plans skip credit checks; usage is logged for monthly invoicing
Billing errors emit specific ChatError codes (INSUFFICIENT_CREDITS, SUBSCRIPTION_INACTIVE, PLAN_LIMIT_EXCEEDED) that the frontend handles via BillingErrorToastHandler.
Task Configurations
Agents support task-specific configurations via AgentTaskConfig. A single agent can be optimized for different workflows by linking conversations to task codes.
| Field | Description |
|---|---|
taskCode | Unique identifier per agent (e.g., recruitment_chat, talent_search) |
systemPrompt | Override agent's default system prompt |
temperature / maxTokens | Override LLM parameters |
ragEnabled / ragTopK / ragMinScore | Override RAG settings |
ragDocumentTags | Filter RAG search to specific document tags |
enabledTools | Override enabled tools for this task |
fewShotExamples | JSON few-shot examples for better performance |
outputFormat / outputSchema | Output formatting constraints |
maxResponseLength | Limit response length |
requiredFields / forbiddenTopics | Content guardrails |
Workflow Service (LangGraph)
The workflow service provides graph-based agent orchestration for complex multi-step tasks using LangGraph (optional dependency):
- LangGraph mode: Uses
StateGraphwith conditional edges for analyze → tool execution → synthesize flow - Fallback mode: Simple sequential execution when LangGraph is not installed
- Max iterations: Configurable limit (default: 10) to prevent infinite loops
Voice Integration
The agent system supports voice conversations via Amazon Nova Sonic 2.0:
| Parameter | Value |
|---|---|
| Endpoint | wss://{host}/voice/ws/{conversation_id} |
| Audio Format | Binary PCM, 16kHz sample rate, 16-bit depth, mono channel |
| Audio Buffering | MIN=16000 bytes (~0.5s), PROCESS=32000 (~1s), MAX=960000 (~30s) |
| Control Messages | JSON (session config, turn signals, interrupts) |
| Model | amazon.nova-2-sonic-v1:0 |
| Languages | 10 supported (en-US/GB/IN/AU, es-ES, de-DE, fr-FR, it-IT, pt-BR, hi-IN) |
| Voices | tiffany (female), matthew (male) -- both polyglot |
| Session Timeout | 600 seconds (10 minutes) |
Swedish (sv-SE) is NOT supported by Nova Sonic despite being the platform's primary market.
The frontend connects via the NestJS voice WS proxy at ${NEXT_PUBLIC_API_URL}/voice/ws/<conversationId>. The gateway terminates the user JWT, mints an internal-service JWT, and opens an upstream WS to background-tasks. Audio is streamed bidirectionally -- the user's microphone input is sent as binary frames, and the agent's spoken response is received as binary PCM for playback.
Frontend Integration
The AI agent system runs on the background-tasks service, not the main backend. The frontend uses dedicated environment variables to connect:
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_API_URL | Backend HTTP endpoint (also AI gateway HTTP via queryAi/mutateAi and voice WS path) |
NEXT_PUBLIC_GRAPHQL_WS_URL | Backend WS endpoint (also agentChat and other AI subscriptions via subscribeAi) |
The NEXT_PUBLIC_AI_* env vars were removed in the Phase 3.7 cutover — the frontend now talks only to the backend, which proxies AI traffic to background-tasks.
This separation allows the AI service to scale independently and prevents heavy LLM workloads from affecting the core API.
Activity Tracking
All agent operations are logged to AgentActivity with 27 activity types:
| Category | Activity Types |
|---|---|
| Agent lifecycle | AGENT_CREATED, AGENT_UPDATED, AGENT_ENABLED, AGENT_DISABLED |
| Configuration | TASK_CONFIG_CREATED, TASK_CONFIG_UPDATED, DATABASE_CONFIG_UPDATED |
| Conversations | CONVERSATION_STARTED, CONVERSATION_RESUMED, CONVERSATION_ARCHIVED |
| Messages | MESSAGE_SENT, MESSAGE_RECEIVED |
| Tools | TOOL_EXECUTED, TOOL_FAILED |
| Documents | DOCUMENT_UPLOADED, DOCUMENT_PROCESSED, DOCUMENT_DELETED, DOCUMENT_RAG_ENABLED, DOCUMENT_RAG_DISABLED |
| Search | RAG_SEARCH_PERFORMED, DATABASE_QUERY_EXECUTED |
| System | CONTEXT_SUMMARIZED, STREAMING_STARTED, STREAMING_COMPLETED, ERROR_OCCURRED, FEEDBACK_RECEIVED |
Additional GraphQL Operations
Beyond the core CRUD and agentChat subscription, the following queries and mutations are available:
Clone Agent
mutation CloneAgent($agentId: String!, $newName: String!) {
cloneAgent(agentId: $agentId, newName: $newName) {
success
message
agent { id name type status }
}
}
Copies an agent's full configuration (LLM settings, system prompt, RAG config, enabled tools, rate limits) into a new agent. Usage statistics and conversations are not copied. Requires access to the source agent's company.
Export Conversation
query ExportConversation($conversationId: String!) {
exportConversation(conversationId: $conversationId) {
conversationId
title
agentName
messages {
role
content
timestamp
toolCalls
}
totalInputTokens
totalOutputTokens
createdAt
exportedAt
}
}
Returns all messages in a conversation as structured data. Messages are ordered chronologically (ascending). Tool calls are included as JSON when present.
Agent Analytics
query AgentAnalytics($agentId: String!, $days: Int = 30) {
agentAnalytics(agentId: $agentId, days: $days) {
agentId
agentName
totalConversations
totalMessages
totalTokensUsed
toolUsage {
toolType
executionCount
avgExecutionTimeMs
successRate
}
dailyUsage {
date
messageCount
tokenCount
}
}
}
Aggregates usage statistics for an agent over the specified period. Tool usage stats are sourced from AgentToolExecution and daily usage from AgentTokenUsage.
Environment Variables
| Variable | Default | Description |
|---|---|---|
AGENT_DEFAULT_LLM_MODEL | eu.anthropic.claude-haiku-4-5-20251001-v1:0 | Default LLM model for agents (upgraded from Nova Lite for better quality) |
AGENT_LLM_REGION | eu-north-1 | AWS region for agent LLM calls |
AGENT_EMBEDDING_MODEL | amazon.titan-embed-text-v2:0 | Embedding model for RAG |
AGENT_DOCUMENT_WORKER_ENABLED | true | Enable document processing worker |
AGENT_DOCUMENT_POLL_INTERVAL | 30 | Document processing poll interval (seconds) |
AGENT_DOCUMENT_BATCH_SIZE | 5 | Documents to process per batch |
AGENT_DOCUMENT_MAX_RETRIES | 3 | Max retries for failed documents |
AGENT_DOCUMENT_TIMEOUT | 600 | Per-document processing timeout (seconds) |
AGENT_EMBEDDING_CACHE_TTL | 300 | Embedding query cache TTL (seconds) |