Aexy's agents are CRM Agents — LangGraph-based agents that live inside the main backend. Configured per workspace, invoked from automations / chat / comments / API. This is what most of this doc is about.
An agent with an email address also has an inbox: mail sent to it is triaged — classified, escalated or answered — by the backend itself (see Agent inbox below). That used to be the job of a separate mailagent/ microservice, which never ran end to end and has been retired; its triage rules now live in services/agent_inbox_triage.py.
CRM Agents#
Mental model#
Each agent is a LangGraph state machine with:
- A goal (high-level objective)
- A system prompt (persona, rules, format)
- A toolset drawn from a workspace-shared registry
- LLM provider + model + sampling parameters
- Behavior controls — confidence threshold, approval gate, daily cap, response delay, working hours
- Policy gates — per-tool block/approval/rate-limit/budget rules
When invoked, the agent runs until it returns a final output or hits max_iterations/timeout_seconds. Every tool call is logged and policy-checked.
Agent types#
AgentType enum (models/agent.py:20-26):
| Value | Purpose |
|---|---|
SALES_OUTREACH | Outreach drafting, lead follow-ups |
LEAD_SCORING | Score and qualify leads |
EMAIL_DRAFTER | Draft contextual replies |
DATA_ENRICHMENT | Fill in CRM record fields from web sources |
CUSTOM | Fully user-defined |
Each non-CUSTOM type has a prebuilt implementation in backend/src/aexy/agents/prebuilt/. CUSTOM agents are assembled by AgentBuilder from the system prompt + tool selection.
CRMAgent model#
models/agent.py:38-152:
| Field | Note |
|---|---|
name, description, agent_type | Identity |
mention_handle | @handle triggers the agent from chat |
is_system | System-defined agents that the workspace can't delete |
goal, system_prompt, custom_instructions | Prompting |
tools (JSONB list) | MCP catalogue names — e.g. ["aexy_crm_records", "get_record_by_id", "send_email"] (see Tool names) |
llm_provider, model, temperature, max_tokens | LLM config |
max_iterations (default 10), timeout_seconds (default 300) | Execution bounds |
confidence_threshold | Minimum confidence to act without approval |
require_approval_below | Force human approval below this score |
max_daily_responses (nullable) | Daily cap; null = unlimited |
response_delay_minutes | Pause before send (anti-robotic) |
working_hours (JSONB) | {enabled, timezone, start, end, days} |
escalation_email, escalation_slack_channel | Where to route human escalations |
email_address (unique nullable), email_enabled, auto_reply_enabled, email_signature | Email persona — when set, the agent owns an inbox |
is_active, created_by_id | Lifecycle |
total_executions, successful_executions, failed_executions, avg_duration_ms | Cached stats |
Backend code#
backend/src/aexy/agents/:
| File | Purpose |
|---|---|
base.py | BaseAgent abstract class — wraps LangGraph StateGraph, defines AgentState TypedDict (messages, record_id, record_data, context, steps, final_output, error), tool binding via llm.bind_tools(), async ToolNode execution with policy gating |
builder.py | CustomAgent + AgentBuilder; get_available_tools() lists the catalogue |
prebuilt/sales_outreach.py | Sales outreach agent (catalog_tool_names) |
prebuilt/lead_scoring.py | Lead scoring agent |
prebuilt/email_drafter.py | Email draft agent |
prebuilt/data_enrichment.py | Record enrichment agent |
tools/mcp_tools.py | The only tool module: catalogue operations, routines and per-capability tools as LangChain tools; attach_to_agent |
api/outreach.py | Email, Slack and SMS as CRM operations (crm/outreach/*), so agents send through the catalogue |
Tool names#
There is no hand-written tool registry any more. Every name in CRMAgent.tools
is an MCP catalogue name, resolved by agents/tools/mcp_tools.py and run
through McpToolExecutor — see One tool substrate below
for what that buys. Four shapes of name are accepted:
| Shape | Example | What it binds |
|---|---|---|
| A named routine | aexy_crm_records, aexy_sd_open_tickets | One operation with flat arguments |
| One capability's whole surface | aexy_crm, aexy_service_desk | action is an enum of that capability's operations |
| A bare action name | get_record_by_id, update_ticket | That one operation, with its own description |
| The two generic tools | aexy_discover, aexy_call | Search the catalogue; call anything reachable |
AgentBuilder.get_available_tools() (builder.py) is what the tool picker
renders: the two generic tools, the named routines, and one entry per
capability. Individual actions are not listed — ~1,900 checkboxes is not a
picker — but any action name typed into an agent's tool list still works.
A name is only attached if the actor running the agent holds the capability it belongs to; anything else is skipped with a log line rather than failing the run. So the tools a given agent actually holds depend on its principal's scope (or, with no principal, on the person who triggered the run).
The CRM tools the prebuilt agents use, as catalogue names:
| Purpose | Name | Capability |
|---|---|---|
| Search CRM records | aexy_crm_records (routine) | mcp.crm |
| Read one record | get_record_by_id | mcp.crm |
| Write back to a record | update_record_by_id | mcp.crm |
| Create a record | create_record | mcp.crm |
| A record's activity timeline | list_activities | mcp.crm |
| Send an email | send_email (POST crm/outreach/email) | mcp.crm |
| Prior thread with an address | get_email_history | mcp.crm |
| Post to Slack / send an SMS | send_slack_message, send_sms | mcp.crm |
| Match the persona's tone | get_writing_style | mcp.agents |
| Read / search / write documents | get_document, list_documents, create_document, aexy_docs_propose | mcp.docs |
There is no drafting tool. Under the default policy pack (see
workflows-and-automations.md)
an agent's send_email
is held in /review for a person to approve, which is the draft step —
create_draft is gone. Enrichment and web search are gone too: enrich_company,
enrich_person and web_search returned canned placeholder text and never
called a provider, so they were removed rather than ported. An agent fills gaps
from what the CRM already holds — the record, its activities, related records.
Note: words like
reply,escalateandno_actionare not tools — they are the decisions inbox triage makes about a message (see Agent inbox below). A CRM agent that needs to escalate callssend_slack_messageto its escalation channel, not an "escalate" tool.
Execution model#
When an agent is dispatched:
dispatch("execute_agent", ExecuteAgentInput(...), task_queue=TaskQueue.WORKFLOWS, workflow_id=...)from a service- The wrapping
SingleActivityWorkflowrunsexecute_agentwithLLM_RETRY(6 attempts, non-retryable onValueError/KeyError) and a 10-minute timeout (dispatch.py:97) - The activity loads the
CRMAgent, builds the LangGraph, and runs it - Every tool call goes through
AgentPolicyevaluation before execution (line 180-224 inbase.py) - The full run is persisted to
CRMAgentExecution
CRMAgentExecution (models/agent.py:154-236):
| Field | Note |
|---|---|
agent_id, conversation_id, record_id | Context |
triggered_by | automation / workflow / manual |
trigger_id | The thing that triggered it (automation ID, workflow execution ID, …) |
input_context, output_result (JSONB) | I/O |
steps (JSONB list) | Per-step log of LLM call + tool calls |
status (AgentExecutionStatus) | PENDING / RUNNING / COMPLETED / FAILED / CANCELLED |
error_message, started_at, completed_at, duration_ms | |
input_tokens, output_tokens | Cost tracking |
Agent policies#
A separate governance layer. See workflows-and-automations.md for the full doc.
AgentPolicy (models/agent_policy.py:39-85):
policy_type | Behavior |
|---|---|
TOOL_BLOCK | Disallow a tool entirely |
TOOL_REQUIRE_APPROVAL | Tool call pauses for human approval |
FIELD_RESTRICTION | Agent can read/write only listed attributes |
RATE_LIMIT | Cap tool calls per period |
TOKEN_BUDGET | Cap LLM token spend per period |
PolicyDecisionType: ALLOW / BLOCK / REQUIRE_APPROVAL / RATE_LIMITED. The most restrictive matching policy wins. Decisions are persisted to AgentPolicyDecision as an immutable audit log (agent_policy.py:87-134). Approval workflow: approval_status, approved_by_id, approved_at fields capture the human signoff (line 119-128).
AgentConfigAudit (agent_policy.py:136-164) is an append-only log of every config change — change_type (create/update/delete/toggle) + field_changes JSONB diff.
API#
api/agents.py — prefix /workspaces/{workspace_id}/crm/agents:
GET / list agents
POST / create agent
GET /{agent_id} fetch
PATCH /{agent_id} update
DELETE /{agent_id} delete
GET /{agent_id}/executions run history
POST /{agent_id}/run manual trigger
(The previous version of this doc listed a /check-handle endpoint — it doesn't exist in the current code. Use GET / with a name filter if you need uniqueness checking.)
api/automation_agents.py — agent triggers wired into automations. Each trigger config has trigger_point (ON_START/ON_CONDITION_MATCH/AS_ACTION), input_mapping (which automation context to pass), wait_for_completion (sync vs fire-and-forget), timeout_seconds. See workflows-and-automations.md.
api/agent_policies.py — prefix /workspaces/{workspace_id}/crm/agent-policies — policy CRUD + config-audit endpoints.
api/ai_feedback.py — thumbs-up/down + comments per execution, fed back into prompt tuning and quality reports.
Temporal activities#
| Activity | Retry | Timeout | Trigger |
|---|---|---|---|
execute_agent | LLM | 10m | Direct dispatch from services |
process_agent_chat_mention | LLM | 10m | Chat @agent_handle mention |
process_comment_agent_mention | LLM | 10m | @agent_handle in an entity comment |
process_agent_inbox_message | LLM | 5m | Inbound mail to an agent's address |
send_agent_inbox_reply | Standard | 2m | A held automatic reply coming due |
All registered in ACTIVITY_CONFIG (temporal/dispatch.py).
Frontend#
/frontend/src/app/(app)/crm/agents/ — agent list, configuration UI, execution history, policy administration. Agents also surface in chat (/chat/) via @mention and in automation builders (/crm/automations/).
Agent principals#
The identity an agent runs as when nobody is at a keyboard.
Every MCP call used to run as a person — an OAuth grant somebody consented to,
or a personal API token. A principal (models/agent_principal.py) is a
workspace-owned identity with a capability scope that can only ever be a subset
of what the workspace grants. It acts through one synthetic Developer row
(account_type = "agent") and one WorkspaceMember row whose app overrides
mirror its capabilities, so every created_by_id and requested_by_id column in
the application can name it without change.
| Property | Behaviour |
|---|---|
| Scope | capabilities (e.g. ["mcp.service_desk", "mcp.tickets"]), intersected with workspace grants on every call and mirrored into the member row's app access |
| Actor | Every request on a principal token carries actor=agent, so governance, the review gate and the ledger apply over MCP and REST alike |
| Tokens | One live token; issuing another revokes the previous. Deactivating the principal revokes its token in the same transaction |
| Audit | Ledger rows and held actions carry principal_id and the synthetic developer id |
API: api/agent_principals.py — prefix /workspaces/{ws}/agent-principals,
admin-only, excluded from the MCP catalogue (agent_identity). UI: Settings →
Agent Principals.
One tool substrate#
Three tool registries used to exist: the MCP catalogue, a hand-written LangGraph registry (18 tools), and Ask's five reads. There is now one: the catalogue, generated from the API and run through one executor.
- Agents. Every name in
CRMAgent.toolsis a catalogue name (agents/tools/mcp_tools.py): a routine such asaexy_sd_open_tickets, an action such asupdate_ticket, a per-capability tool such asaexy_service_desk, oraexy_discover/aexy_call. It runs throughMcpToolExecutoras the agent's principal, or as the person who triggered the run — so the endpoint's permissions, workspace policies and the ledger apply. The picker lists routines and the catalogue; any action name typed in also works. Prebuilt agents declarecatalog_tool_namesthe same way. - Outreach. Email, Slack and SMS had no API behind them, so they became
endpoints (
POST crm/outreach/email|slack|sms,GET crm/outreach/email-history) in thecrmcapability. The default policy pack holds every send for a person to approve — an agent's "draft" is a send waiting in/review. The old enrichment and web-search tools returned canned placeholder text and were removed rather than ported. - Migration.
scripts/migrate_agent_tools_to_catalogue.sqlrewrites stored tool lists (search_contacts→aexy_crm_records,get_record→get_record_by_id,create_draft→send_email, …) so existing agents run unchanged; the placeholders are dropped from them. - Ask. Offers
current_timeplus the caller's MCP surface, restricted to read operations unlessASK_ALLOW_WRITES=true. - Automations. The
service_deskmodule now exposes triggers (ticket_created,ticket_updated,pending_with_changed) and actions (set_pending_with,set_request_type,assign_owner), so an automation can run a principal-backed agent against a desk event.
Agent schedules#
A routine an agent runs on a clock (models/agent_schedule.py). Each run is a
CRMAgentExecution with triggered_by = "schedule" and trigger_id naming
the schedule; the routine text arrives as the routine key of the run context
and is shown to the model as its task.
Only agents with a principal can be scheduled — a schedule has nobody at the
keyboard. run_due_agent_schedules (Temporal, every 5 minutes) claims each due
row by advancing next_run_at before dispatching, anchored on the slot so a
daily routine does not drift. A schedule whose agent loses its principal is
switched off rather than failing every tick.
API: api/agent_schedules.py — /workspaces/{ws}/agent-schedules (members
read, admins write, POST /{id}/run fires once now). UI: Settings → Agent
Schedules.
Agent inbox#
An agent with email enabled owns an address — {handle}@{workspace-slug}.aexy.email by default, or {handle}@ any sending domain the workspace has verified (POST /workspaces/{ws}/crm/agents/{id}/email/enable, which refuses any other domain). Disabling releases the address.
Inbound flow#
- The mail provider posts to
POST /api/v1/webhooks/email/inbound(SendGrid, Mailgun, Postmark or SES shapes; the request must carry the provider credential, seeapi/email_webhooks.py). - A registered Service Desk mailbox takes precedence. Otherwise
AgentEmailService.route_incoming_emailmatches an agent by exact address (every recipient, To first), then by the routing rules of the workspace the address belongs to — rules never cross workspaces. - Once the
agent_inbox_messagesrow is committed, the webhook dispatches the Temporal activityprocess_agent_inbox_message(analysis queue,LLM_RETRY, workflow idagent-inbox-triage-{message id}). - The activity runs
AgentEmailService.process_inbox_message, which callsAgentInboxTriage(services/agent_inbox_triage.py, AI featureagents.inbox_triage) and acts on the decision.
"Process" in the inbox UI runs the same step by hand.
Triage#
One model call reads the message (intent, sentiment, urgency, topics, whether a reply is needed, plus the profile's classification); a second drafts the reply when one is wanted. The profile follows agent_type: sales_outreach agents triage as sales (intents meeting_request, pricing_question, feature_question, objection, not_interested, positive_interest, question, unclear; replies under 150 words ending on a question), everything else as support (categories billing … feature_request; acknowledge first, never promise a timeline).
| Decision | When | What happens |
|---|---|---|
escalate | negative + high/critical urgency; mentions billing, a refund, anything legal, salary or negotiation; critical urgency | No draft. Status escalated; escalation_email gets an email and escalation_slack_channel a Slack message |
reply, needs review | confidence below require_approval_below; negative sentiment; sales pricing (always), objections below 0.85, answers below 0.8, meeting requests, unclear intent | Draft saved, status stays pending |
reply, no review | everything else | Sent automatically if auto_reply_enabled and confidence ≥ confidence_threshold |
no_action | nothing to answer | Left pending for a person to archive |
An automatic reply is held (left pending, reason in classification.auto_reply) when the sender looks automatic or is a no-reply address, or when max_daily_responses replies already left the agent's address today. It is scheduled — a delayed send_agent_inbox_reply workflow — when response_delay_minutes or working_hours put the send in the future; the scheduled send re-checks everything and stands down if a person answered first.
Replies go out through the workspace's default sending provider, as the agent (From, Reply-To, In-Reply-To/References). Without one, the platform mailer sends with Reply-To set to the agent's address. /inbox/{id}/reply fails (502/503) when nothing was sent.
@mentions in comments#
@handle in an entity comment (POST /workspaces/{ws}/activities/{type}/{id}/comment) resolves against the workspace's active agents and queues process_comment_agent_mention, which runs AgentService.execute_agent and posts the answer back on the same timeline. The author gets an agent_invoked notification. Writes the agent attempts go through the policy engine like any other run — ones that need review wait in Agent actions (/workspaces/{ws}/agent-actions), not in the comment thread.
Frontend#
| Route | Purpose |
|---|---|
/crm/agents | CRM agent list + configuration |
/crm/agents/{id} | Detail + execution history |
/crm/agent-policies | Policy admin |
/crm/automations/{id}/agent-triggers | Wire an agent into an automation |
| Chat panels | @agent_handle invokes the agent inline |
Common pitfalls#
- Looking for "SUPPORT"/"SCHEDULING" agent types: there are none. CRM
AgentTypevalues areSALES_OUTREACH,LEAD_SCORING,EMAIL_DRAFTER,DATA_ENRICHMENT,CUSTOM; "support" and "sales" are inbox triage profiles, chosen from the agent type. - Treating triage decisions as CRM tools:
reply,escalate,no_actiondescribe what inbox triage decided about a message. A CRM agent doesn't have areplytool — it hassend_email, which workspace policy holds for approval. - Two thresholds, two roles:
confidence_thresholdis the confidence an inbox draft needs to be sent automatically;require_approval_belowmarks a draft as needing review. A draft is sent unreviewed only when it clears both and no other review rule applies. - Policy is restrictive-wins: if two
AgentPolicyrows match a tool call, the more restrictive decision applies (BLOCK>REQUIRE_APPROVAL>RATE_LIMITED>ALLOW). Multiple policies are additive, not last-write. is_system=Trueagents can't be deleted by workspace admins — they're seeded by platform code. Trying to delete one returns a 4xx.- Email-owning agents need an
email_address: ifemail_enabled=truebutemail_addressis empty, incoming mail can't be routed to the agent. The unique constraint onemail_addressmeans each address binds to exactly one agent. - No
/check-handleendpoint: handle uniqueness is enforced at create-time bymention_handlecollision. To check ahead of time, list agents and filter. response_delay_minutesandworking_hoursdelay automatic inbox replies: the send is a delayed Temporal workflow (send_agent_inbox_reply), and the message stayspendinguntil it fires —classification.auto_replysays when. Confusing if you're trying to debug "why didn't my message send."
Related#
- Workflows & automations — agent triggers inside automations, agent policy details
- CRM — the substrate agents act on
- Email marketing — the email infrastructure agents send through
- MCP — how external LLMs talk to Aexy as tools