AI Agents

LangGraph-based agents with CRM/email tools

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

ValuePurpose
SALES_OUTREACHOutreach drafting, lead follow-ups
LEAD_SCORINGScore and qualify leads
EMAIL_DRAFTERDraft contextual replies
DATA_ENRICHMENTFill in CRM record fields from web sources
CUSTOMFully 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:

FieldNote
name, description, agent_typeIdentity
mention_handle@handle triggers the agent from chat
is_systemSystem-defined agents that the workspace can't delete
goal, system_prompt, custom_instructionsPrompting
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_tokensLLM config
max_iterations (default 10), timeout_seconds (default 300)Execution bounds
confidence_thresholdMinimum confidence to act without approval
require_approval_belowForce human approval below this score
max_daily_responses (nullable)Daily cap; null = unlimited
response_delay_minutesPause before send (anti-robotic)
working_hours (JSONB){enabled, timezone, start, end, days}
escalation_email, escalation_slack_channelWhere to route human escalations
email_address (unique nullable), email_enabled, auto_reply_enabled, email_signatureEmail persona — when set, the agent owns an inbox
is_active, created_by_idLifecycle
total_executions, successful_executions, failed_executions, avg_duration_msCached stats

Backend code#

backend/src/aexy/agents/:

FilePurpose
base.pyBaseAgent 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.pyCustomAgent + AgentBuilder; get_available_tools() lists the catalogue
prebuilt/sales_outreach.pySales outreach agent (catalog_tool_names)
prebuilt/lead_scoring.pyLead scoring agent
prebuilt/email_drafter.pyEmail draft agent
prebuilt/data_enrichment.pyRecord enrichment agent
tools/mcp_tools.pyThe only tool module: catalogue operations, routines and per-capability tools as LangChain tools; attach_to_agent
api/outreach.pyEmail, 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:

ShapeExampleWhat it binds
A named routineaexy_crm_records, aexy_sd_open_ticketsOne operation with flat arguments
One capability's whole surfaceaexy_crm, aexy_service_deskaction is an enum of that capability's operations
A bare action nameget_record_by_id, update_ticketThat one operation, with its own description
The two generic toolsaexy_discover, aexy_callSearch 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:

PurposeNameCapability
Search CRM recordsaexy_crm_records (routine)mcp.crm
Read one recordget_record_by_idmcp.crm
Write back to a recordupdate_record_by_idmcp.crm
Create a recordcreate_recordmcp.crm
A record's activity timelinelist_activitiesmcp.crm
Send an emailsend_email (POST crm/outreach/email)mcp.crm
Prior thread with an addressget_email_historymcp.crm
Post to Slack / send an SMSsend_slack_message, send_smsmcp.crm
Match the persona's toneget_writing_stylemcp.agents
Read / search / write documentsget_document, list_documents, create_document, aexy_docs_proposemcp.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, escalate and no_action are not tools — they are the decisions inbox triage makes about a message (see Agent inbox below). A CRM agent that needs to escalate calls send_slack_message to its escalation channel, not an "escalate" tool.

Execution model#

When an agent is dispatched:

  1. dispatch("execute_agent", ExecuteAgentInput(...), task_queue=TaskQueue.WORKFLOWS, workflow_id=...) from a service
  2. The wrapping SingleActivityWorkflow runs execute_agent with LLM_RETRY (6 attempts, non-retryable on ValueError/KeyError) and a 10-minute timeout (dispatch.py:97)
  3. The activity loads the CRMAgent, builds the LangGraph, and runs it
  4. Every tool call goes through AgentPolicy evaluation before execution (line 180-224 in base.py)
  5. The full run is persisted to CRMAgentExecution

CRMAgentExecution (models/agent.py:154-236):

FieldNote
agent_id, conversation_id, record_idContext
triggered_byautomation / workflow / manual
trigger_idThe 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_tokensCost tracking

Agent policies#

A separate governance layer. See workflows-and-automations.md for the full doc.

AgentPolicy (models/agent_policy.py:39-85):

policy_typeBehavior
TOOL_BLOCKDisallow a tool entirely
TOOL_REQUIRE_APPROVALTool call pauses for human approval
FIELD_RESTRICTIONAgent can read/write only listed attributes
RATE_LIMITCap tool calls per period
TOKEN_BUDGETCap 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#

ActivityRetryTimeoutTrigger
execute_agentLLM10mDirect dispatch from services
process_agent_chat_mentionLLM10mChat @agent_handle mention
process_comment_agent_mentionLLM10m@agent_handle in an entity comment
process_agent_inbox_messageLLM5mInbound mail to an agent's address
send_agent_inbox_replyStandard2mA 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.

PropertyBehaviour
Scopecapabilities (e.g. ["mcp.service_desk", "mcp.tickets"]), intersected with workspace grants on every call and mirrored into the member row's app access
ActorEvery request on a principal token carries actor=agent, so governance, the review gate and the ledger apply over MCP and REST alike
TokensOne live token; issuing another revokes the previous. Deactivating the principal revokes its token in the same transaction
AuditLedger 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.tools is a catalogue name (agents/tools/mcp_tools.py): a routine such as aexy_sd_open_tickets, an action such as update_ticket, a per-capability tool such as aexy_service_desk, or aexy_discover / aexy_call. It runs through McpToolExecutor as 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 declare catalog_tool_names the 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 the crm capability. 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.sql rewrites 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_time plus the caller's MCP surface, restricted to read operations unless ASK_ALLOW_WRITES=true.
  • Automations. The service_desk module 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#

  1. The mail provider posts to POST /api/v1/webhooks/email/inbound (SendGrid, Mailgun, Postmark or SES shapes; the request must carry the provider credential, see api/email_webhooks.py).
  2. A registered Service Desk mailbox takes precedence. Otherwise AgentEmailService.route_incoming_email matches 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.
  3. Once the agent_inbox_messages row is committed, the webhook dispatches the Temporal activity process_agent_inbox_message (analysis queue, LLM_RETRY, workflow id agent-inbox-triage-{message id}).
  4. The activity runs AgentEmailService.process_inbox_message, which calls AgentInboxTriage (services/agent_inbox_triage.py, AI feature agents.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).

DecisionWhenWhat happens
escalatenegative + high/critical urgency; mentions billing, a refund, anything legal, salary or negotiation; critical urgencyNo draft. Status escalated; escalation_email gets an email and escalation_slack_channel a Slack message
reply, needs reviewconfidence below require_approval_below; negative sentiment; sales pricing (always), objections below 0.85, answers below 0.8, meeting requests, unclear intentDraft saved, status stays pending
reply, no revieweverything elseSent automatically if auto_reply_enabled and confidence ≥ confidence_threshold
no_actionnothing to answerLeft 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#

RoutePurpose
/crm/agentsCRM agent list + configuration
/crm/agents/{id}Detail + execution history
/crm/agent-policiesPolicy admin
/crm/automations/{id}/agent-triggersWire 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 AgentType values are SALES_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_action describe what inbox triage decided about a message. A CRM agent doesn't have a reply tool — it has send_email, which workspace policy holds for approval.
  • Two thresholds, two roles: confidence_threshold is the confidence an inbox draft needs to be sent automatically; require_approval_below marks 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 AgentPolicy rows match a tool call, the more restrictive decision applies (BLOCK > REQUIRE_APPROVAL > RATE_LIMITED > ALLOW). Multiple policies are additive, not last-write.
  • is_system=True agents 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: if email_enabled=true but email_address is empty, incoming mail can't be routed to the agent. The unique constraint on email_address means each address binds to exactly one agent.
  • No /check-handle endpoint: handle uniqueness is enforced at create-time by mention_handle collision. To check ahead of time, list agents and filter.
  • response_delay_minutes and working_hours delay automatic inbox replies: the send is a delayed Temporal workflow (send_agent_inbox_reply), and the message stays pending until it fires — classification.auto_reply says when. Confusing if you're trying to debug "why didn't my message send."
  • 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