Skip to main content

Step 6 — AI assistant and scheduled jobs

Two final layers complete the CRM:

  • AI assistant — natural-language access to the CRM data, scoped to what the calling user can see.
  • A scheduled job — runs every night, finds deals with no activity in 14 days, sends a summary to Slack.

By the end of this step the CRM is feature-complete. Estimated time: 15 minutes.


Set up the AI assistant

The framework's AI assistant is an Anthropic Claude model with tool use enabled. Every connector query the calling user can run becomes a tool the assistant can pick. You ask a question; the model runs the right queries; it answers.

Configure the provider

Set ANTHROPIC_API_KEY in your environment (get one from https://console.anthropic.com → API keys), then restart the framework. Open Settings → Framework → AI to confirm the field reads "Provider: Anthropic / API key: ✓ configured".

Default model is claude-sonnet-4-6. Leave it.

Confirm the connectors are exposed

We set Expose to AI assistant on the customers and deals connectors when we created them in Steps 2 and 3 (it defaulted to on). To confirm, open Settings → Connectors → customers — the toggle should be on.

Open the same view for deals and activities. Make sure they're all on.

Then open GET /ai/tools in a new tab (or liberty-admin ai-tools on the CLI). You should see a list:

customers__list — Returns every customer with their status, industry and primary contact.
deals__list — Sales deals — pipeline stage, amount, expected close.
deals__pipeline_total — …
deals__by_stage — …
activities__list — …

Only read queries appear by default. Write queries are excluded unless the connector entry explicitly opts in (and the caller carries the ai:write permission).

Grant ai:chat to the role

Open Settings → Roles → crm-sales and + Add permission: ai:chat. Save.

Same for crm-admin. Leave crm-viewer without it for now.

Try it

Sign out, sign in as sales-alice. The 💬 Chat icon appears in the header. Click it.

Try a few questions:

"How many deals are in the pipeline right now?"

The assistant picks the right tool (deals__pipeline_total), runs it, reads the result and answers:

"There are €173,500 in open pipeline value across 3 deals (excluding won/lost)."

The tool call is visible inline — expand it to see the parameters and the rows returned.

"Which customers have no deals in the last 30 days?"

The assistant chains customers__list and deals__list, joins the results in its reasoning, and answers with the right customers — possibly suggesting a follow-up.

"Delete the Globex deal."

Refused. The assistant doesn't have access to write queries — deals__delete isn't in its tool list because the connector didn't opt in.

What the assistant can and can't do

ActionAllowed?
Read any query the caller can run.
Combine multiple queries to answer a complex question.
Render the answer in the user's language (uses the session.lang).
Run a write query.Only when the connector + role both opt in (expose_to_ai + ai:write permission).
Access data the caller doesn't have permission for.✗ — the tool list is per-caller, the permission gates apply.

The framework's row-level access patterns (WHERE owner = :session_user) propagate to the assistant automatically: if the human user only sees their own customers, so does the AI on their behalf.


Add the stale-deal job

A common pipeline-hygiene practice: flag deals that haven't moved in two weeks. The framework's Jobs / Nomaflow engine runs scheduled work in-process; we'll add a job that fires nightly.

Add the detection query

Open Settings → Connectors → deals → + Add query:

FieldValue
Namestale-deals
OperationRead
SQLThe query below
SELECT d.id, d.name, c.name AS customer_name, d.stage, d.amount,
MAX(a.happened_at) AS last_activity
FROM deals d
JOIN customers c ON c.id = d.customer_id
LEFT JOIN activities a ON a.deal_id = d.id
WHERE d.stage NOT IN ('won', 'lost')
GROUP BY d.id, d.name, c.name, d.stage, d.amount
HAVING MAX(a.happened_at) IS NULL
OR MAX(a.happened_at) < (CURRENT_DATE - INTERVAL '14 days');

Test — you should see the deals with no recent activity (likely all of them given the seed data).

Build the job

Settings → Jobs → + New job:

FieldValue
Namecrm-flag-stale-deals
Appcrm
DescriptionFind deals with no activity in 14+ days; summary to Slack.
Schedule0 8 * * 1-5 (08:00 on weekdays)
TimezoneEurope/Paris
Enabled
Single instance
Timeout60 seconds

Add three steps

Step 1 — fetch stale deals

FieldValue
Namefetch-stale
TypeSQL Query
Connector / Querydeals / stale-deals
Result aliasstale

The first step's result is referenced by the next step as ${steps.fetch-stale.rows}.

Step 2 — guard against empty result

FieldValue
Namecheck-empty
TypePython
Callablecrm.alerts:format_stale_message
Kwargsrows = ${steps.fetch-stale.rows}
Condition${steps.fetch-stale.row_count} > 0

When there are no stale deals, the Condition is false and the step is skipped — the job ends succeeded without sending a Slack message.

We'll need a tiny Python module. Create liberty-apps/plugins/crm/__init__.py and liberty-apps/plugins/crm/alerts.py:

# liberty-apps/plugins/crm/alerts.py
def format_stale_message(*, rows, **ctx):
"""Format the list of stale deals as a Slack-friendly message."""
lines = ["🟡 *Deals with no activity for 14+ days*", ""]
for r in rows:
last = r.get("last_activity") or "never"
lines.append(f"• *{r['name']}* — {r['customer_name']} · €{r['amount']:,.0f} · last activity: {last}")
return {"text": "\n".join(lines), "rows_affected": len(rows)}

Step 3 — post to Slack

FieldValue
Nameslack-notify
TypeHTTP
VariantRaw URL
MethodPOST
URLhttps://hooks.slack.com/services/T0/B0/XXXXX (your webhook)
Body{ "text": "${steps.check-empty.text}" }
Condition${steps.fetch-stale.row_count} > 0

Notifications on failure

In the Notifications section of the job, set:

FieldValue
On failureslack:#ops-alerts

So if the job itself errors (database down, Slack 500), the ops channel is notified — not lost to the run log.

Save and trigger manually

Save & reload. Click the ▶ Run now button at the top of the job builder to test without waiting for 08:00. The run history opens; the three steps appear in order; you watch the log tail stream.


Verify

CheckHow
AI assistant works for sales-aliceSign in as Alice, open /chat, ask "how many deals are in the pipeline?". Get a numeric answer with the tool call visible.
crm-viewer doesn't see the assistantSign in as Bob, the 💬 icon is hidden.
The stale-deal job runs successfullySettings → Jobs → crm-flag-stale-deals → run history shows the manual fire with status succeeded.
The Slack channel got the message(Or the run log shows the formatted message in the check-empty step's output.)
The job will fire tomorrow at 08:00The job's Next 5 fires preview shows the upcoming runs.

What you have now

A complete CRM application built end-to-end on the Liberty Framework:

  • Three screens — Customers, Deals, Activities (sub-grid).
  • One dashboard — Pipeline overview with four KPIs, a chart and a recent-activity feed.
  • Three roles — viewer, sales, admin — with OIDC sign-in mapped from the IdP's groups claim.
  • An AI assistant that answers natural-language questions over the data, scoped per role.
  • A scheduled job that flags stale deals and posts to Slack every weekday morning.

Total time across the six steps: about 90 minutes for a first-timer, ~30 for someone who's done it before. Lines of code written: the SQL queries, one short Python file (alerts.py), and a slim 🔒 client secret in the environment. The rest is configuration.


Where to go from here

You want to…Read
Apply the same pattern to SAP / NetSuite / another ERPSame recipe, point the pool at the ERP database. For JD Edwards specifically, the packaged Nomajde app already ships every screen.
Deploy this CRM to productionDeployment → Running in production.
Look up a specific recipeCookbook.
Understand a concept more deeplyThe Concepts pages have a "What / Why / When" intro now.
See other use casesWhat you can build.