Chapter 19 Every Module Gets a Bot
Work in Progress — This chapter is not yet published.
Chapter 19 — Every Module Gets a Bot
Chapter 18 established the complete pattern with the Invoice module. A QueryService handles data access. A QueryTool wraps it with OpenAI function definitions. The ToolExecutor dispatches calls through the SAFE_METHODS allowlist. AiService runs the loop.
This chapter applies that pattern to the other thirteen modules in the FOSM application. We’ll do full implementations for CRM and Hiring — they’re complex enough to illustrate the non-obvious decisions — then enumerate the complete query taxonomy for all remaining modules. By the end, every FOSM object in your system will be queryable by a bot.
We’ll also cover the admin infrastructure: how to enable and disable tools per bot, how to enable and disable entire modules globally, and the home page tile system that reflects your module configuration.
The Module Taxonomy
Here is every module, its tool key prefix, and its key queries. This table is your implementation roadmap.
| Module | Tool Key | Key Query Methods |
|---|---|---|
| NDA | nda_query |
get_summary, get_all_ndas, get_nda_details |
| Partnership | partnership_query |
get_summary, get_all_agreements, get_agreement_details, get_all_referrals |
| CRM | crm_query |
get_summary, get_all_contacts, get_contact_details, get_all_deals, get_pipeline_summary |
| Expense | expense_query |
get_summary, get_all_expenses, get_expense_details, get_all_reports |
| Invoice | invoice_query |
(built in Chapter 18) |
| Project | project_query |
get_summary, get_all_projects, get_project_details |
| Time Tracking | time_query |
get_summary, get_all_entries, get_entry_details |
| Leave | leave_query |
get_summary, get_all_requests, get_request_details |
| Hiring | candidate_query |
get_summary, get_all_candidates, get_candidate_details, get_pipeline |
| Vendor | vendor_query |
get_summary, get_all_vendors, get_vendor_details |
| Inventory | inventory_query |
get_summary, get_all_items, get_item_details |
| Knowledge Base | kb_query |
get_summary, get_all_articles, get_article_details, search_articles |
| OKR | okr_query |
get_summary, get_all_objectives, get_objective_details |
| Payroll | payroll_query |
get_summary, get_all_pay_runs, get_pay_run_details |
Notice that the Knowledge Base module gets a search_articles method — that’s a semantic search rather than a structured query, and we’ll handle it as a special case. Everything else follows the same structured pattern.
Full Implementation: CrmQueryService and CrmQueryTool
CRM is the most relationship-rich module. Contacts have deals. Deals have stages. The pipeline summary is a computed view across many records. This makes CRM a good stress test for the query pattern.
Listing 19.1 — app/services/query_services/crm_query_service.rb
module QueryServices
class CrmQueryService
def initialize(current_user:)
@current_user = current_user
end
def get_summary
contacts = Contact.accessible_by(@current_user)
deals = Deal.accessible_by(@current_user)
{
total_contacts: contacts.count,
total_deals: deals.count,
open_deals: deals.open.count,
won_deals: deals.won.count,
lost_deals: deals.lost.count,
pipeline_value: deals.open.sum(:value).to_f,
weighted_value: deals.open.sum("value * (probability / 100.0)").to_f,
avg_deal_size: deals.won.average(:value).to_f.round(2)
}
end
def get_all_contacts(search: nil, tag: nil, limit: 20)
scope = Contact.accessible_by(@current_user).includes(:deals)
scope = scope.search(search) if search.present?
scope = scope.tagged_with(tag) if tag.present?
scope = scope.order(updated_at: :desc).limit(limit)
scope.map do |c|
{
id: c.id,
name: c.full_name,
email: c.email,
company: c.company,
phone: c.phone,
tags: c.tag_list,
open_deals: c.deals.open.count,
last_contact: c.last_contacted_at&.to_date&.iso8601
}
end
end
def get_contact_details(id:)
contact = Contact.accessible_by(@current_user).find_by(id: id)
return { error: "Contact #{id} not found" } unless contact
{
id: contact.id,
name: contact.full_name,
email: contact.email,
company: contact.company,
phone: contact.phone,
address: contact.address,
tags: contact.tag_list,
notes: contact.notes,
last_contacted: contact.last_contacted_at&.iso8601,
deals: contact.deals.map { |d|
{ id: d.id, title: d.title, stage: d.stage, value: d.value.to_f, probability: d.probability }
},
recent_activity: contact.activities.order(occurred_at: :desc).limit(5).map { |a|
{ type: a.activity_type, note: a.note, at: a.occurred_at.iso8601 }
}
}
end
def get_all_deals(stage: nil, min_value: nil, owner_email: nil, limit: 20)
scope = Deal.accessible_by(@current_user).includes(:contact, :owner)
scope = scope.where(stage: stage) if stage.present?
scope = scope.where("value >= ?", min_value) if min_value.present?
scope = scope.joins(:owner).where(users: { email: owner_email }) if owner_email.present?
scope = scope.order(value: :desc).limit(limit)
scope.map do |d|
{
id: d.id,
title: d.title,
contact: d.contact&.full_name,
stage: d.stage,
value: d.value.to_f,
probability: d.probability,
expected_close: d.expected_close_date&.iso8601,
owner: d.owner&.email
}
end
end
def get_pipeline_summary
Deal.accessible_by(@current_user)
.group(:stage)
.order(:stage)
.pluck(:stage, Arel.sql("COUNT(*) as count"), Arel.sql("SUM(value) as total_value"))
.map do |stage, count, total|
{ stage: stage, count: count, total_value: total.to_f }
end
end
end
end
Listing 19.2 — app/services/query_tools/crm_query_tool.rb
module QueryTools
class CrmQueryTool
TOOL_KEY = "crm_query"
def initialize(service)
@service = service
end
def function_definitions
[
{
type: "function",
function: {
name: "crm_get_summary",
description: "Get a high-level summary of CRM data: contact counts, deal counts, pipeline value. Call this first to orient yourself before answering CRM questions.",
parameters: { type: "object", properties: {}, required: [] }
}
},
{
type: "function",
function: {
name: "crm_get_all_contacts",
description: "List CRM contacts with optional search or tag filters.",
parameters: {
type: "object",
properties: {
search: { type: "string", description: "Search by name, email, or company" },
tag: { type: "string", description: "Filter by contact tag" },
limit: { type: "integer", description: "Maximum results, default 20" }
},
required: []
}
}
},
{
type: "function",
function: {
name: "crm_get_contact_details",
description: "Get full details for a specific contact including their deals and recent activity.",
parameters: {
type: "object",
properties: { id: { type: "integer", description: "Contact ID" } },
required: ["id"]
}
}
},
{
type: "function",
function: {
name: "crm_get_all_deals",
description: "List deals with optional stage, value, or owner filters.",
parameters: {
type: "object",
properties: {
stage: { type: "string", enum: ["prospect", "qualified", "proposal", "negotiation", "won", "lost"] },
min_value: { type: "number", description: "Minimum deal value" },
owner_email: { type: "string", description: "Filter by deal owner email" },
limit: { type: "integer", description: "Maximum results, default 20" }
},
required: []
}
}
},
{
type: "function",
function: {
name: "crm_get_pipeline_summary",
description: "Get the deal pipeline broken down by stage: count and total value per stage.",
parameters: { type: "object", properties: {}, required: [] }
}
}
]
end
def build_context
summary = @service.get_summary
<<~TEXT
CRM Summary: #{summary[:total_contacts]} contacts, #{summary[:total_deals]} deals.
Open pipeline value: $#{summary[:pipeline_value].round(2)}.
Weighted pipeline: $#{summary[:weighted_value].round(2)}.
TEXT
end
def execute(function_name, arguments)
case function_name
when "crm_get_summary" then @service.get_summary
when "crm_get_all_contacts" then @service.get_all_contacts(**arguments.symbolize_keys)
when "crm_get_contact_details" then @service.get_contact_details(**arguments.symbolize_keys)
when "crm_get_all_deals" then @service.get_all_deals(**arguments.symbolize_keys)
when "crm_get_pipeline_summary" then @service.get_pipeline_summary
else { error: "Unknown CRM function: #{function_name}" }
end
end
end
end
And the SAFE_METHODS entries to add to ToolExecutor:
# Add to ToolExecutor::SAFE_METHODS
"crm_get_summary" => { tool_key: "crm_query", method: :get_summary },
"crm_get_all_contacts" => { tool_key: "crm_query", method: :get_all_contacts },
"crm_get_contact_details" => { tool_key: "crm_query", method: :get_contact_details },
"crm_get_all_deals" => { tool_key: "crm_query", method: :get_all_deals },
"crm_get_pipeline_summary" => { tool_key: "crm_query", method: :get_pipeline_summary },
Full Implementation: CandidateQueryService and CandidateQueryTool
Hiring is operationally time-sensitive. Recruiters need to know pipeline health at a glance — how many candidates are at each stage, which offers are pending, which requisitions are stalled. The query model reflects this.
Listing 19.3 — app/services/query_services/candidate_query_service.rb
module QueryServices
class CandidateQueryService
def initialize(current_user:)
@current_user = current_user
end
def get_summary
scope = Candidate.accessible_by(@current_user)
{
total_candidates: scope.count,
active_candidates: scope.active.count,
offers_extended: scope.where(status: "offer_extended").count,
offers_accepted: scope.where(status: "offer_accepted").count,
open_requisitions: JobRequisition.accessible_by(@current_user).open.count,
avg_days_to_offer: scope.hired.average(:days_to_offer).to_f.round(1)
}
end
def get_all_candidates(status: nil, role: nil, source: nil, limit: 20)
scope = Candidate.accessible_by(@current_user).includes(:job_requisition, :recruiter)
scope = scope.where(status: status) if status.present?
scope = scope.joins(:job_requisition).where(job_requisitions: { title: role }) if role.present?
scope = scope.where(source: source) if source.present?
scope = scope.order(updated_at: :desc).limit(limit)
scope.map do |c|
{
id: c.id,
name: c.full_name,
email: c.email,
role: c.job_requisition&.title,
status: c.status,
source: c.source,
recruiter: c.recruiter&.email,
applied_at: c.applied_at&.to_date&.iso8601,
last_action_at: c.updated_at.to_date.iso8601
}
end
end
def get_candidate_details(id:)
candidate = Candidate.accessible_by(@current_user).find_by(id: id)
return { error: "Candidate #{id} not found" } unless candidate
{
id: candidate.id,
name: candidate.full_name,
email: candidate.email,
phone: candidate.phone,
role: candidate.job_requisition&.title,
status: candidate.status,
source: candidate.source,
resume_url: candidate.resume_url,
recruiter: candidate.recruiter&.email,
applied_at: candidate.applied_at&.iso8601,
offer: candidate.offer ? {
amount: candidate.offer.amount.to_f,
equity: candidate.offer.equity_percentage,
start_date: candidate.offer.proposed_start_date&.iso8601,
status: candidate.offer.status
} : nil,
interviews: candidate.interviews.order(scheduled_at: :asc).map { |i|
{ round: i.round, interviewer: i.interviewer&.email, scheduled: i.scheduled_at&.iso8601, outcome: i.outcome }
},
notes: candidate.recruiter_notes,
transition_log: candidate.transition_logs.last(5).map { |tl|
{ event: tl.event, from: tl.from_state, to: tl.to_state, actor: tl.actor&.email, at: tl.created_at.iso8601 }
}
}
end
def get_pipeline(requisition_id: nil)
scope = Candidate.accessible_by(@current_user)
scope = scope.where(job_requisition_id: requisition_id) if requisition_id.present?
scope.group(:status)
.order(:status)
.pluck(:status, Arel.sql("COUNT(*) as count"))
.map { |status, count| { stage: status, count: count } }
end
end
end
Listing 19.4 — app/services/query_tools/candidate_query_tool.rb
module QueryTools
class CandidateQueryTool
TOOL_KEY = "candidate_query"
def initialize(service)
@service = service
end
def function_definitions
[
{
type: "function",
function: {
name: "candidate_get_summary",
description: "Get hiring pipeline summary: total candidates, active pipeline, open requisitions, and time-to-offer metrics.",
parameters: { type: "object", properties: {}, required: [] }
}
},
{
type: "function",
function: {
name: "candidate_get_all_candidates",
description: "List candidates with optional filters by status, role, or source channel.",
parameters: {
type: "object",
properties: {
status: {
type: "string",
enum: ["applied", "screening", "interview", "offer_extended", "offer_accepted", "hired", "rejected", "withdrawn"],
description: "Filter by candidate status"
},
role: { type: "string", description: "Filter by job title or role name" },
source: { type: "string", description: "Filter by source channel (LinkedIn, referral, etc.)" },
limit: { type: "integer", description: "Maximum results, default 20" }
},
required: []
}
}
},
{
type: "function",
function: {
name: "candidate_get_candidate_details",
description: "Get full details for a specific candidate including interviews, offer terms, and transition history.",
parameters: {
type: "object",
properties: { id: { type: "integer", description: "Candidate ID" } },
required: ["id"]
}
}
},
{
type: "function",
function: {
name: "candidate_get_pipeline",
description: "Get the hiring pipeline broken down by stage. Optionally filter to a specific job requisition.",
parameters: {
type: "object",
properties: {
requisition_id: { type: "integer", description: "Optional: filter to a specific job requisition" }
},
required: []
}
}
}
]
end
def build_context
summary = @service.get_summary
<<~TEXT
Hiring Summary: #{summary[:active_candidates]} active candidates.
#{summary[:offers_extended]} offers extended, #{summary[:offers_accepted]} accepted.
#{summary[:open_requisitions]} open requisitions.
TEXT
end
def execute(function_name, arguments)
case function_name
when "candidate_get_summary" then @service.get_summary
when "candidate_get_all_candidates" then @service.get_all_candidates(**arguments.symbolize_keys)
when "candidate_get_candidate_details" then @service.get_candidate_details(**arguments.symbolize_keys)
when "candidate_get_pipeline" then @service.get_pipeline(**arguments.symbolize_keys)
else { error: "Unknown hiring function: #{function_name}" }
end
end
end
end
get_invoice_details and get_candidate_details include the last five entries from transition_logs. This is intentional. When a bot is answering "why is this candidate still in interview stage after 3 weeks?", the transition log tells the story without the user needing to navigate to a separate audit view. The FOSM audit trail pays dividends in the bot layer — it's not just for compliance, it's conversationally valuable.
The Pattern for All Remaining Modules
The CRM and Hiring implementations above show every decision you’ll make for the remaining modules. The QueryService is always the same shape. The QueryTool is always the same shape. SAFE_METHODS always gets the entries. Here is the complete enumeration.
NDA Module
# NdaQueryService methods:
# get_summary → counts by status, expiring_soon count
# get_all_ndas → filters: status, counterparty_name, expiring_within_days
# get_nda_details → parties, signed_at dates, expiry_date, document_url, transition_log
# NdaQueryTool function names:
# nda_get_summary, nda_get_all_ndas, nda_get_nda_details
The NDA details response is particularly valuable to bot users because it includes the full signing history from the transition log — who signed when, in what order. A bot can answer “Has Acme Corp signed our NDA yet?” with a precise answer.
Partnership Module
# PartnershipQueryService methods:
# get_summary → agreement counts, active referral count, total referral revenue
# get_all_agreements → filters: status, partner_name
# get_agreement_details → terms, commission_rate, expiry, signed parties
# get_all_referrals → filters: status, partner_id, date_range
# PartnershipQueryTool function names:
# partnership_get_summary, partnership_get_all_agreements,
# partnership_get_agreement_details, partnership_get_all_referrals
Expense Module
# ExpenseQueryService methods:
# get_summary → total submitted/approved/rejected, total amount by status
# get_all_expenses → filters: status, submitter_email, category, date_range
# get_expense_details → line items, approval history, receipts count
# get_all_reports → expense report summaries by submitter
# ExpenseQueryTool function names:
# expense_get_summary, expense_get_all_expenses,
# expense_get_expense_details, expense_get_all_reports
Project Module
# ProjectQueryService methods:
# get_summary → total projects, by status, overdue milestones count
# get_all_projects → filters: status, owner_email, overdue
# get_project_details → milestones, team members, budget vs actual, transition_log
# ProjectQueryTool function names:
# project_get_summary, project_get_all_projects, project_get_project_details
Time Tracking Module
# TimeQueryService methods:
# get_summary → total hours this period, by project, billable vs non-billable
# get_all_entries → filters: user_email, project_id, date_range, billable
# get_entry_details → full entry with project, task, notes
# TimeQueryTool function names:
# time_get_summary, time_get_all_entries, time_get_entry_details
Leave Module
# LeaveQueryService methods:
# get_summary → pending requests count, approved this month, by leave_type
# get_all_requests → filters: status, employee_email, leave_type, date_range
# get_request_details → dates, approver, reason, overlap check result
# LeaveQueryTool function names:
# leave_get_summary, leave_get_all_requests, leave_get_request_details
Vendor Module
# VendorQueryService methods:
# get_summary → total vendors, by status, total contracted value
# get_all_vendors → filters: status, category, name_search
# get_vendor_details → contracts, payment terms, contacts, recent invoices
# VendorQueryTool function names:
# vendor_get_summary, vendor_get_all_vendors, vendor_get_vendor_details
Inventory Module
# InventoryQueryService methods:
# get_summary → total items, low stock items, out_of_stock count, total value
# get_all_items → filters: category, below_reorder_point, search
# get_item_details → stock history, suppliers, reorder settings
# InventoryQueryTool function names:
# inventory_get_summary, inventory_get_all_items, inventory_get_item_details
Knowledge Base Module (with Search)
The Knowledge Base module is the one place where semantic search makes sense. Articles are unstructured text. The bot should be able to find relevant articles, not just filter by metadata.
Listing 19.5 — app/services/query_services/kb_query_service.rb (search method)
module QueryServices
class KbQueryService
def initialize(current_user:)
@current_user = current_user
end
def get_summary
scope = KbArticle.accessible_by(@current_user)
{
total_articles: scope.count,
published: scope.published.count,
draft: scope.draft.count,
categories: scope.distinct.pluck(:category)
}
end
def get_all_articles(category: nil, search: nil, published_only: true, limit: 20)
scope = KbArticle.accessible_by(@current_user)
scope = scope.published if published_only
scope = scope.where(category: category) if category.present?
scope = scope.where("title ILIKE ? OR content ILIKE ?", "%#{search}%", "%#{search}%") if search.present?
scope = scope.order(updated_at: :desc).limit(limit)
scope.map { |a| { id: a.id, title: a.title, category: a.category, updated_at: a.updated_at.to_date.iso8601 } }
end
def get_article_details(id:)
article = KbArticle.accessible_by(@current_user).find_by(id: id)
return { error: "Article #{id} not found" } unless article
{
id: article.id,
title: article.title,
category: article.category,
content: article.content.truncate(2000), # Limit content size in context
author: article.author&.email,
published_at: article.published_at&.iso8601,
tags: article.tag_list
}
end
# Full-text search across article titles and content
def search_articles(query:, limit: 5)
KbArticle.accessible_by(@current_user)
.published
.where("to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', ?)", query)
.order(Arel.sql("ts_rank(to_tsvector('english', title || ' ' || content), plainto_tsquery('english', #{ActiveRecord::Base.connection.quote(query)})) DESC"))
.limit(limit)
.map { |a| { id: a.id, title: a.title, excerpt: a.content.truncate(300), category: a.category } }
end
end
end
The search_articles method uses PostgreSQL’s full-text search. The bot calls this when a user asks “What does our policy say about remote work?” — the model uses the semantic search function rather than metadata filtering.
# KbQueryTool function names:
# kb_get_summary, kb_get_all_articles, kb_get_article_details, kb_search_articles
OKR Module
# OkrQueryService methods:
# get_summary → total objectives, average progress, at-risk count
# get_all_objectives → filters: status, owner_email, at_risk (progress < 30%)
# get_objective_details → key results with progress, owner, check-ins
# OkrQueryTool function names:
# okr_get_summary, okr_get_all_objectives, okr_get_objective_details
The OKR module benefits enormously from bot access because the at-risk query cuts across departments. A bot can answer “Show me all OKRs that are behind by more than 30%” — a question that’s tedious to answer by browsing the UI manually.
Payroll Module
# PayrollQueryService methods:
# get_summary → total pay runs this year, total disbursed, pending runs
# get_all_pay_runs → filters: status, period (month/year), limit
# get_pay_run_details → employee count, total gross/net, deductions, run status
# PayrollQueryTool function names:
# payroll_get_summary, payroll_get_all_pay_runs, payroll_get_pay_run_details
accessible_by scope should check for a :payroll_admin role — not just authentication. When you implement the payroll service, verify the authorization check explicitly. A bot that exposes salary data to unauthorized users is a serious security incident, not just a bug.
Completing the SAFE_METHODS Allowlist
With all fourteen tools implemented, the ToolExecutor SAFE_METHODS hash grows to the full roster. Here is the complete structure:
Listing 19.6 — app/services/tool_executor.rb — Complete SAFE_METHODS
SAFE_METHODS = {
# Invoice (Chapter 18)
"invoice_get_summary" => { tool_key: "invoice_query", method: :get_summary },
"invoice_get_all_invoices" => { tool_key: "invoice_query", method: :get_all_invoices },
"invoice_get_invoice_details" => { tool_key: "invoice_query", method: :get_invoice_details },
# NDA
"nda_get_summary" => { tool_key: "nda_query", method: :get_summary },
"nda_get_all_ndas" => { tool_key: "nda_query", method: :get_all_ndas },
"nda_get_nda_details" => { tool_key: "nda_query", method: :get_nda_details },
# Partnership
"partnership_get_summary" => { tool_key: "partnership_query", method: :get_summary },
"partnership_get_all_agreements" => { tool_key: "partnership_query", method: :get_all_agreements },
"partnership_get_agreement_details" => { tool_key: "partnership_query", method: :get_agreement_details },
"partnership_get_all_referrals" => { tool_key: "partnership_query", method: :get_all_referrals },
# CRM
"crm_get_summary" => { tool_key: "crm_query", method: :get_summary },
"crm_get_all_contacts" => { tool_key: "crm_query", method: :get_all_contacts },
"crm_get_contact_details" => { tool_key: "crm_query", method: :get_contact_details },
"crm_get_all_deals" => { tool_key: "crm_query", method: :get_all_deals },
"crm_get_pipeline_summary" => { tool_key: "crm_query", method: :get_pipeline_summary },
# Expense
"expense_get_summary" => { tool_key: "expense_query", method: :get_summary },
"expense_get_all_expenses" => { tool_key: "expense_query", method: :get_all_expenses },
"expense_get_expense_details" => { tool_key: "expense_query", method: :get_expense_details },
"expense_get_all_reports" => { tool_key: "expense_query", method: :get_all_reports },
# Project
"project_get_summary" => { tool_key: "project_query", method: :get_summary },
"project_get_all_projects" => { tool_key: "project_query", method: :get_all_projects },
"project_get_project_details" => { tool_key: "project_query", method: :get_project_details },
# Time
"time_get_summary" => { tool_key: "time_query", method: :get_summary },
"time_get_all_entries" => { tool_key: "time_query", method: :get_all_entries },
"time_get_entry_details" => { tool_key: "time_query", method: :get_entry_details },
# Leave
"leave_get_summary" => { tool_key: "leave_query", method: :get_summary },
"leave_get_all_requests" => { tool_key: "leave_query", method: :get_all_requests },
"leave_get_request_details" => { tool_key: "leave_query", method: :get_request_details },
# Hiring / Candidates
"candidate_get_summary" => { tool_key: "candidate_query", method: :get_summary },
"candidate_get_all_candidates" => { tool_key: "candidate_query", method: :get_all_candidates },
"candidate_get_candidate_details" => { tool_key: "candidate_query", method: :get_candidate_details },
"candidate_get_pipeline" => { tool_key: "candidate_query", method: :get_pipeline },
# Vendor
"vendor_get_summary" => { tool_key: "vendor_query", method: :get_summary },
"vendor_get_all_vendors" => { tool_key: "vendor_query", method: :get_all_vendors },
"vendor_get_vendor_details" => { tool_key: "vendor_query", method: :get_vendor_details },
# Inventory
"inventory_get_summary" => { tool_key: "inventory_query", method: :get_summary },
"inventory_get_all_items" => { tool_key: "inventory_query", method: :get_all_items },
"inventory_get_item_details" => { tool_key: "inventory_query", method: :get_item_details },
# Knowledge Base
"kb_get_summary" => { tool_key: "kb_query", method: :get_summary },
"kb_get_all_articles" => { tool_key: "kb_query", method: :get_all_articles },
"kb_get_article_details" => { tool_key: "kb_query", method: :get_article_details },
"kb_search_articles" => { tool_key: "kb_query", method: :search_articles },
# OKR
"okr_get_summary" => { tool_key: "okr_query", method: :get_summary },
"okr_get_all_objectives" => { tool_key: "okr_query", method: :get_all_objectives },
"okr_get_objective_details" => { tool_key: "okr_query", method: :get_objective_details },
# Payroll
"payroll_get_summary" => { tool_key: "payroll_query", method: :get_summary },
"payroll_get_all_pay_runs" => { tool_key: "payroll_query", method: :get_all_pay_runs },
"payroll_get_pay_run_details" => { tool_key: "payroll_query", method: :get_pay_run_details },
}.freeze
Fifty-one safe methods across fourteen modules. The model can call any of them, subject to the bot’s enabled tool configuration and the current user’s authorization. That’s the complete scope of what the LLM can access.
The Cross-Module Query: The Conversational Superpower
Here’s where the architecture pays off in a way that no single-module bot could. Because AiService builds the tools list from all enabled tools at once, a single question can span modules.
Consider this conversation with a bot that has invoice_query, okr_query, and project_query enabled:
User: Give me a health check on the business — what’s our financial exposure and are we on track with goals?
The model receives this as a single message. Its tools list includes functions from all three modules. It proceeds to:
- Call
invoice_get_summary→ 12 overdue invoices, $47,000 outstanding - Call
invoice_get_all_invoices(overdue: true)→ full list with aging - Call
okr_get_summary→ 8 objectives, average progress 41% - Call
okr_get_all_objectives(at_risk: true)→ 3 objectives below 30% progress - Call
project_get_summary→ 2 projects overdue on milestones
Then synthesize all of that into a coherent business health summary.
No human had to write that query. No SQL joins were written. No report was designed. The model reasoned about what “health check” means, mapped it to the available tools, and assembled the answer. The FOSM data model provided the structure; the bot provided the synthesis.
flowchart TD
Q["User: 'Give me a business health check'"] --> AI[AiService]
AI --> T1[invoice_get_summary]
AI --> T2[invoice_get_all_invoices\n overdue: true]
AI --> T3[okr_get_summary]
AI --> T4[okr_get_all_objectives\n at_risk: true]
AI --> T5[project_get_summary]
T1 --> R[Results aggregated\nin OpenAI context]
T2 --> R
T3 --> R
T4 --> R
T5 --> R
R --> ANS["Synthesized health check:\n- $47k overdue receivables\n- 3 OKRs at risk\n- 2 project milestone delays"]
Module Management: Enabling and Disabling Modules
The bot tool toggles are per-bot configuration. But there’s a higher-level control: the Module Management panel in Admin, which enables and disables entire modules for the entire application.
When you disable a module in Module Management, two things happen: the home page tile for that module disappears, and the corresponding tool key is excluded from all bots regardless of their individual configuration.
Listing 19.7 — app/models/module_setting.rb
class ModuleSetting < ApplicationRecord
# module_name: string (unique), enabled: boolean
MODULES = %w[
nda partnership crm expense invoice project
time_tracking leave hiring vendor inventory
knowledge_base okr payroll
].freeze
validates :module_name, presence: true, uniqueness: true, inclusion: { in: MODULES }
validates :enabled, inclusion: { in: [true, false] }
def self.enabled?(module_name)
find_by(module_name: module_name)&.enabled? ?? true # Default enabled if no record
end
def self.enabled_modules
# Returns modules where no record exists (default enabled) or record.enabled = true
where(enabled: true).pluck(:module_name) |
MODULES.reject { |m| exists?(module_name: m) }
end
end
The ToolExecutor respects module settings when building the available tools:
# In ToolExecutor#build_all_function_definitions
def build_all_function_definitions
@bot.enabled_tools
.select { |tool_key| module_enabled_for_tool?(tool_key) }
.flat_map { |tool_key| load_tool(tool_key).function_definitions }
end
private
def module_enabled_for_tool?(tool_key)
# Maps tool_key to module_name (e.g., "invoice_query" → "invoice")
module_name = tool_key.gsub("_query", "").gsub("candidate", "hiring").gsub("kb", "knowledge_base")
ModuleSetting.enabled?(module_name)
end
The Home Page: Dynamic Tiles
The home page in a FOSM application is not a static dashboard — it’s a dynamic grid of tiles that reflects which modules are currently enabled. The StaticController#home action builds this list at render time.
Listing 19.8 — app/controllers/static_controller.rb
class StaticController < ApplicationController
before_action :authenticate_user!
ALL_TILES = [
{ key: "nda", title: "NDAs", icon: "file-contract", path: :ndas_path },
{ key: "partnership", title: "Partnerships", icon: "handshake", path: :partnerships_path },
{ key: "crm", title: "CRM", icon: "users", path: :contacts_path },
{ key: "expense", title: "Expenses", icon: "receipt", path: :expenses_path },
{ key: "invoice", title: "Invoices", icon: "file-invoice", path: :invoices_path },
{ key: "project", title: "Projects", icon: "clipboard-list", path: :projects_path },
{ key: "time_tracking", title: "Time Tracking", icon: "clock", path: :time_entries_path },
{ key: "leave", title: "Leave", icon: "calendar-alt", path: :leave_requests_path },
{ key: "hiring", title: "Hiring", icon: "user-plus", path: :candidates_path },
{ key: "vendor", title: "Vendors", icon: "truck", path: :vendors_path },
{ key: "inventory", title: "Inventory", icon: "boxes", path: :inventory_items_path },
{ key: "knowledge_base", title: "Knowledge Base", icon: "book-open", path: :kb_articles_path },
{ key: "okr", title: "OKRs", icon: "bullseye", path: :objectives_path },
{ key: "payroll", title: "Payroll", icon: "money-bill-wave",path: :payroll_runs_path },
].freeze
def home
enabled = ModuleSetting.enabled_modules.to_set
@tiles = ALL_TILES.select { |t| enabled.include?(t[:key]) }
@bots = Bot.where(system_bot: true).order(:name)
end
end
The view renders @tiles as a responsive grid. When a module is disabled in Admin → Module Management, its tile disappears from every user’s home page automatically. No deploy required. No cache flush. The next page load reflects the change.
build_system_prompt in AiService: "Modules available in this system: #{ModuleSetting.enabled_modules.map(&:humanize).join(', ')}."
The Bot Admin UI: Per-Bot Tool Toggles
With fourteen modules and the ability to create multiple bots for different teams, the admin UI needs to make tool configuration clear and fast.
A finance bot gets invoice, expense, and payroll tools. An operations bot gets project, time tracking, and vendor tools. An executive bot gets everything — OKRs, pipeline summary, hiring pipeline, and the ability to cross-module query.
The form from Chapter 18 scales without changes — it iterates over Bot::AVAILABLE_TOOLS and renders a checkbox for each. The key is that Bot::AVAILABLE_TOOLS is filtered at display time to only show tools whose modules are enabled:
<%# app/views/admin/bots/_form.html.erb — Module-aware tool list %>
<div class="tool-toggles">
<% Bot::AVAILABLE_TOOLS
.select { |t| ModuleSetting.enabled?(t.gsub("_query","").gsub("candidate","hiring")) }
.each do |tool_key| %>
<div class="form-check">
<input type="checkbox"
name="bot[tool_config][<%= tool_key %>]"
value="true"
class="form-check-input"
<%= "checked" if @bot.tool_enabled?(tool_key) %>>
<label class="form-check-label"><%= tool_key.humanize %></label>
</div>
<% end %>
</div>
If the payroll module is disabled globally, the payroll checkbox doesn’t appear in the bot configuration form. It’s consistent all the way down.
What You Built
In this chapter you completed the bot layer for the entire FOSM application:
-
The full module taxonomy: fourteen modules, each with a QueryService and QueryTool following the identical three-layer pattern from Chapter 18. Fifty-one safe methods in the ToolExecutor allowlist.
-
Full CRM implementation: contacts, deals, pipeline summary, with proper scoping, filtering, and a context snippet that orients the model before the first question.
-
Full Hiring implementation: candidate pipeline by stage, offer details, interview history, and transition log included in the details response.
-
Knowledge Base with semantic search: PostgreSQL full-text search via
search_articles, which gives the model the ability to find relevant articles by content rather than just metadata. -
Module Management: a global enable/disable switch that controls both home page tiles and bot tool availability consistently.
-
The home page tile system: a dynamic grid reflecting enabled modules, with system bot links for quick access to conversational interfaces.
-
Cross-module queries: the architectural insight that a bot with multiple tools enabled can answer questions that span modules — the model decides which tools to call based on what the question requires.
The application now has a complete conversational intelligence layer. Every business object is queryable. Every module exposes its data through a safe, typed function interface. The audit trail flows into bot responses naturally.
In Chapter 20, we zoom out and look at the full picture.