algos crm.
CRM Documentation
The same product docs available in the dashboard—guides for setup, architecture, modules, and day-to-day use.
AI foundation
Provider-agnostic AI layer for Algos CRM. Phase 0 is infrastructure. Phase 1 is Inbox/Lead copilot. Phase 2 is Ask Algos natural-language search. Do not implement Reports AI or Task AI here.
Architecture
Controller / future feature job
│
▼
AIContextBuilder ← tenancy + existing policies
│
▼
PromptAssembler ← system/user prompts (not in controllers)
│
▼
AIService ← never throws to callers
│
├── AIProviderInterface (OpenAI today, swap later)
├── AIResponseParser
└── AIUsageLogger → ai_usage_logs
| Piece | Path |
|---|---|
| Config | config/ai.php |
| Service | app/Services/AI/AIService.php |
| Provider contract | app/Services/AI/Contracts/AIProviderInterface.php |
| OpenAI adapter | app/Services/AI/Providers/OpenAIProvider.php |
| Null adapter | app/Services/AI/Providers/NullAIProvider.php |
| Context / authz | app/Services/AI/Context/AIContextBuilder.php |
| Prompts | app/Services/AI/Prompts/PromptAssembler.php |
| Parser | app/Services/AI/AIResponseParser.php |
| Usage log model | app/Models/AiUsageLog.php |
| Queue job | app/Jobs/AI/ProcessAIRequestJob.php |
| Provider registration | app/Providers/AIServiceProvider.php |
AIService always returns an AIOutcome. Provider timeouts, HTTP errors, missing keys, and malformed JSON are caught, logged, and turned into a generic user message. CRM screens keep working.
Phase 1 — Sales copilot
User-requested actions only (never on page load):
| Action | Route | Permission | Sends messages? |
|---|---|---|---|
| Inbox summary | POST inbox.ai.summary |
view.inbox |
No |
| Suggested reply | POST inbox.ai.reply |
reply.inbox |
No — fills a draft only |
| Lead summary | POST leads.ai.summary |
view on that lead |
No |
| Lead score | POST leads.ai.score |
view on that lead |
No |
| Next best action | POST leads.ai.next-action |
view on that lead |
No |
| Follow-up draft | POST leads.ai.follow-up |
view on that lead |
No |
Results are cached on ai_assist_results. Lead score is also stored on leads.ai_score / ai_score_generated_at / ai_score_model when the user generates it. Identical context is reused until Regenerate.
Prompt classes live in app/Services/AI/Prompts/. Context is built in ConversationAiContext / LeadAiContext and only includes records the user can already view.
Phase 2 — Ask Algos
Natural-language search on the existing Global Search page. Keyword search is unchanged.
The model returns an allowlisted filter payload (entity, filters, sort, limit). AskAlgosQueryValidator rejects unknown fields/operators and SQL-like output. AskAlgosQueryExecutor runs Eloquent with existing scopes (Lead::visibleTo, Task::visibleTo, CompanyScope) and Gate::view after fetch. AI-generated SQL is never executed.
| Item | Detail |
|---|---|
| Route | POST search.ask (view.leads / customers / tasks / inbox / activity logs) |
| Entities | leads, customers, tasks, activities (ActivityLog), conversations |
| Dates | Resolved in PHP (today, this_month, last_7_days, …) |
| Logs | ai_search_logs stores question + structured interpretation + status (not conversation bodies). Pruned after AI_SEARCH_LOG_RETENTION_DAYS (90). |
Explanations are optional and grounded in authorized result titles/counts. If the model invents numbers, the app falls back to “Found N leads matching your request.”
Configuration
Master switch is off by default (AI_ENABLED=false). No provider HTTP calls happen until you enable it and set a key.
| Variable | Default | Purpose |
|---|---|---|
AI_ENABLED |
false | Master switch |
AI_PROVIDER |
openai | openai or null |
AI_API_KEY |
(empty) | Active provider API key — never commit |
AI_MODEL |
gpt-4o-mini | Default model |
AI_TIMEOUT |
20 | HTTP timeout (seconds) |
AI_MAX_TOKENS |
1024 | Completion cap |
AI_TEMPERATURE |
0.2 | Sampling temperature |
AI_QUEUE |
ai | Queue name for ProcessAIRequestJob |
AI_JOB_TIMEOUT |
90 | Queue worker timeout (seconds) |
AI_OPENAI_API_KEY |
(falls back to AI_API_KEY) |
OpenAI-specific key |
AI_OPENAI_BASE_URL |
https://api.openai.com/v1 | Override for compatible gateways |
AI_OPENAI_MODEL / AI_OPENAI_TIMEOUT / AI_OPENAI_MAX_TOKENS / AI_OPENAI_TEMPERATURE |
(shared defaults) | Per-provider overrides |
AI_OPENAI_ORGANIZATION |
(empty) | Optional OpenAI org header |
AI_LOG_USAGE |
true | Persist ai_usage_logs |
Tests force AI_ENABLED=false and AI_PROVIDER=null in phpunit.xml. Automated tests must mock the provider / Http::fake() — they must not call a real API.
Provider abstraction
Callers depend on AIProviderInterface:
generateText(AIRequest $request): AIGenerationResultgenerateStructuredResponse(AIRequest $request, array $schema = []): AIGenerationResult
To add a provider later:
- Implement the interface under
app/Services/AI/Providers. - Add a case to
App\Enums\AI\AIProvider. - Register it in
AIServiceProvider. - Add config under
config/ai.phpproviders.
Do not put HTTP or SDK calls in controllers.
Tenancy and RBAC
AI is not a permission bypass.
AIContextBuilder::forAuthenticatedUser($user)setsCurrentCompanyfrom$user->company_id. It never trusts a client-supplied company id.- Model loads use Eloquent +
CompanyScope. Another tenant’s row is invisible (findAuthorized→ not found). authorize()/findAuthorized()go through existing policies (LeadPolicy,ConversationPolicy, …) andChecksSameCompany.AIServicerefuses to run ifAIRequest.companyIddoes not matchCurrentCompany.- Super Admins have no company — tenant AI context is refused.
- Usage logs are
BelongsToCompany. They are not written if tenant context is missing or mismatched.
Queued work must restore context the same way channel jobs do:
$context->forQueuedOperation($request->companyId, $request->userId);
ProcessAIRequestJob already does this and clears context in finally.
Usage logging
ai_usage_logs stores metadata only:
company_id,user_id,feature,provider,modeloperation(text/structured/dispatch)provider_request_id, token counts,status, sanitizederror_message,duration_ms
It does not store prompts, completions, or conversation bodies. Error strings are redacted (API keys, sk-…, Bearer tokens) and never shown to end users.
How future features should call AI
Keep prompts out of controllers. Example for a later Inbox or Lead feature:
$context = app(AIContextBuilder::class)->forAuthenticatedUser($request->user());
$lead = $context->findAuthorized(Lead::class, $leadId, 'view');
$prompts = app(PromptAssembler::class)->compose(
featureInstructions: 'Summarize this lead for the assigned salesperson.',
userTemplate: "Name: {{name}}\nStatus: {{status}}",
variables: ['name' => $lead->name, 'status' => $lead->status],
);
$request = $context->textRequest(
feature: 'leads.summary',
systemPrompt: $prompts['system'],
userPrompt: $prompts['user'],
metadata: ['lead_id' => $lead->id],
);
$outcome = app(AIService::class)->generateText($request);
// or: app(AIService::class)->dispatch($request); // expensive work
Rules for feature work:
- Add a prompt class under
app/Services/AI/Prompts/(e.g.LeadSummaryPrompt). - Authorize with
AIContextBuilder+ existing abilities (view,reply, …). Add new permission slugs only if a new AI action needs its own gate. - Treat
$outcome->ok === falseas a soft failure (flash theuserMessage, keep the page working). - Put slow calls on
AI_QUEUE(php artisan queue:work --queue=channels,ai,default). - Never log raw chat transcripts onto
ai_usage_logs. - Mock
AIProviderInterfacein tests; do not hit the network.