Skip to content

2. Customer support inbox — mini-CRM in 25 minutes

Build a small services-business support inbox: customers, tickets, a relation between them, and a lifecycle hook that auto-tags urgent tickets. By the end you’ll be asking Slack “what’s open and urgent for the Acme account?” and getting back a rendered table.

You’ll learn: relations, lifecycle hooks, the agent’s render_table tool, and aggregations.

Time budget: ~25 minutes.

Terminal window
npx create-davepi-app support-inbox --template blank
cd support-inbox
docker compose up -d
npm start

Delete schema/versions/v1/note.js — we won’t use it.

schema/versions/v1/customer.js:

module.exports = {
path: 'customer',
collection: 'customer',
fields: [
{ name: 'userId', type: String, required: true },
{ name: 'name', type: String, required: true, searchable: true, searchWeight: 5 },
{ name: 'email', type: String, required: true },
{ name: 'company', type: String, searchable: true },
],
};

schema/versions/v1/ticket.js:

module.exports = {
path: 'ticket',
collection: 'ticket',
fields: [
{ name: 'userId', type: String, required: true },
{ name: 'customerId', type: String, required: true },
{ name: 'subject', type: String, required: true, searchable: true, searchWeight: 5 },
{ name: 'body', type: String, searchable: true },
{
name: 'status',
type: String,
enum: ['open', 'pending', 'closed'],
default: 'open',
},
{ name: 'opened_at', type: Date, default: Date.now },
],
relations: {
customer: { belongsTo: 'customer', localKey: 'customerId' },
},
};

Save both. Open the davepi-ui admin at http://localhost:5173 (start it with cd admin && npm install && npm run dev if it’s not running). Notice that on ticket forms the customer field is now a searchable combobox — the framework read the relations.customer declaration, surfaced it through /_describe, and the admin rendered a <RelationPicker> auto-populated from the customer collection. Zero UI code. Customer detail pages also auto-discover a Tickets tab via the backend-synthesised inverse hasMany. See Relations.

Register and log in (see Tutorial 1 if you need the curl commands). Then through the admin UI, or by curl:

Terminal window
TOKEN=$(curl -s -X POST http://localhost:5050/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"sup3rsecret!"}' | jq -r .accessToken)
for c in "Acme Corp" "Globex" "Initech"; do
curl -s -X POST http://localhost:5050/api/v1/customer \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"$c\",\"email\":\"hello@$(echo $c | tr A-Z a-z | tr -d ' ').com\",\"company\":\"$c\"}" > /dev/null
done

7:00 — Ask the agent to add a priority field

Section titled “7:00 — Ask the agent to add a priority field”

Open the project in Claude Code. Ask:

Add a priority field to ticket (low / normal / high / urgent, default normal) and a tags array of strings.

Claude edits schema/versions/v1/ticket.js. Hot reload picks it up. Verify in _describe:

Terminal window
curl -s http://localhost:5050/_describe \
-H "Authorization: Bearer $TOKEN" \
| jq '.schemas[] | select(.path=="ticket").fields[] | select(.name | inside("priority,tags"))'

9:00 — Ask the agent to add an auto-tag hook

Section titled “9:00 — Ask the agent to add an auto-tag hook”

Same Claude session:

Add a beforeCreate hook to ticket that auto-tags urgent if the subject contains ‘down’ or ‘broken’ (case-insensitive), and bumps the priority to urgent in that case.

Claude updates the schema with a hooks.beforeCreate function. The result should look something like:

hooks: {
beforeCreate: async ({ input }) => {
const subject = String(input.subject || '');
if (/\b(down|broken)\b/i.test(subject)) {
const tags = new Set([...(input.tags || []), 'urgent']);
return { ...input, tags: [...tags], priority: 'urgent' };
}
},
},

The before* hook returning a value replaces the input that gets persisted; returning undefined keeps it. See Hooks.

Drop these in through the admin UI or by curl:

Terminal window
ACME=$(curl -s "http://localhost:5050/api/v1/customer?__q=acme" \
-H "Authorization: Bearer $TOKEN" | jq -r '.results[0]._id')
GLOBEX=$(curl -s "http://localhost:5050/api/v1/customer?__q=globex" \
-H "Authorization: Bearer $TOKEN" | jq -r '.results[0]._id')
curl -s -X POST http://localhost:5050/api/v1/ticket \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"customerId\":\"$ACME\",\"subject\":\"Cannot reset 2FA\",\"body\":\"Got locked out, need help.\"}"
curl -s -X POST http://localhost:5050/api/v1/ticket \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"customerId\":\"$ACME\",\"subject\":\"Production is DOWN!\",\"body\":\"Status page is red.\"}"
curl -s -X POST http://localhost:5050/api/v1/ticket \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"customerId\":\"$GLOBEX\",\"subject\":\"How do I export to CSV?\",\"body\":\"Looking for the option.\"}"
curl -s -X POST http://localhost:5050/api/v1/ticket \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"customerId\":\"$GLOBEX\",\"subject\":\"Search is broken on mobile\",\"body\":\"Results never load.\"}"

Check the admin UI: tickets with “DOWN” and “broken” in the subject should have priority: urgent and tags: ["urgent"] auto-applied. Tickets without those words shouldn’t.

Add an aggregation to ticket called openByCustomer that groups open tickets by customer and returns count per customer, sorted descending by count.

Claude appends:

aggregations: [
{
name: 'openByCustomer',
description: 'Open ticket count grouped by customer.',
pipeline: [
{ $match: { status: 'open' } },
{ $group: { _id: '$customerId', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
],
cache: { ttlSeconds: 30 },
},
],

You now have three surfaces for the same query:

  • REST: GET /api/v1/ticket/aggregations/openByCustomer
  • GraphQL: ticketOpenByCustomer
  • MCP: aggregate_ticket_openByCustomer

See Aggregations.

Terminal window
npm install @davepi/agent

.env.agent:

Terminal window
DAVEPI_URL=http://localhost:5050
LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
DAVEPI_BEARER=eyJ... # your /login access token

Access tokens default to 15 minutes and service mode does NOT refresh. For a 25-minute tutorial, set ACCESS_TOKEN_TTL=2h in the davepi server’s .env (not the agent’s), restart davepi, and log in again to get a 2-hour token. For production, switch to per-user auth mode (shown in Tutorial 5).

Follow the Slack-bot setup checklist from Tutorial 1. The only difference: name the app support-inbox-bot. Add to .env.agent:

Terminal window
SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=...
SLACK_SOCKET_MODE=true # if using socket mode
SLACK_APP_TOKEN=xapp-... # if using socket mode

Start it:

Terminal window
set -a; source .env; source .env.agent; set +a
npx davepi-agent

DM the bot:

What’s open and urgent for the Acme account? Show as a table.

The agent:

  1. Calls search_customer (or list_customer with __q=acme) to resolve “Acme” to a customerId.
  2. Calls list_ticket with filter: { customerId, status: 'open', priority: 'urgent' }.
  3. Calls render_table with the resulting rows.

In Slack you see a Block Kit table with columns subject / priority / opened_at — rendered natively, not as ASCII.

Now try:

Which customer has the most open tickets right now?

The agent reaches for aggregate_ticket_openByCustomer, gets back the grouped result, joins the top _id against get_customer, and replies: “Acme Corp has 2 open tickets, the most of any customer.”

One more:

Show me a chart of ticket volume by status, all customers.

Agent groups in its head from list_ticket (or via an aggregation if you have one), calls render_chart with a pie or bar spec. Chart appears inline.

What you have:

  • Two collections with a relation, hot-reloaded into REST + GraphQL
    • MCP + Swagger + admin SPA.
  • A lifecycle hook the agent wrote that auto-tags + bumps priority on creation.
  • An aggregation the agent wrote, available on all three surfaces.
  • A Slack bot that resolves entities by name, runs aggregations, and renders tables and charts natively per channel.

Total schema code you wrote by hand: ~40 lines. Total non-schema code: zero.