An AI-powered shared inbox for dealership service teams.
A service advisor can be juggling 40 conversations at once: sales questions, repair updates, parts arrivals, and customers who stopped responding days ago. Attend turns that noise into a prioritized workflow.
An ambient AI layer summarizes conversations as they evolve, identifies the next action, and ranks the inbox by what actually needs attention, not simply by which message arrived last.
- Shared customer texting across the dealership
- AI-generated conversation summaries and next steps
- Prioritized inbox for service advisors
- Search over customer names, phone numbers in any format, and message text
- Follow-up tasks and team assignments
- Coverage, so an advisor who is away hands her open conversations to a colleague who reads them
- Built-in SMS compliance and opt-out handling
Screens below show the local demo UI, signed in as the service advisor.
| Login | Ranked inbox | Tasks |
|---|---|---|
![]() |
![]() |
![]() |
Next.jsApp RouterTypeScriptTailwind CSSPrisma 7Neon PostgresAuth.jscredentials loginTwilioSMS/MMS route structureVercelfor local/preview/production deployment targets
Route-by-route auth and API contracts are in ARCHITECTURE.md.
DATABASE_URL is always the Neon pooled runtime URL used by the app at runtime.
DIRECT_URL is always the Neon direct connection used by Prisma CLI commands and one-time admin scripts.
Required app env:
DATABASE_URLNEXTAUTH_SECRETNEXTAUTH_URLNEXT_PUBLIC_APP_URL
Required CLI/bootstrap env:
DIRECT_URLBOOTSTRAP_ADMIN_NAMEBOOTSTRAP_ADMIN_EMAILBOOTSTRAP_ADMIN_PASSWORD
Twilio env:
TWILIO_ACCOUNT_SIDTWILIO_AUTH_TOKENTWILIO_PHONE_NUMBERorTWILIO_MESSAGING_SERVICE_SID
AI env:
OPENAI_API_KEYOPENAI_MODELdefaults togpt-4.1-miniwhen unsetAI_PASS_MAX_BRIEFScaps how many conversations one ambient pass will brief, defaults to12SEED_AI_BRIEFSset tofalseto stop the seed regenerating its briefs through the real model
Dealership env:
DEALERSHIP_TIME_ZONEis the IANA zone the dealership's day runs on, defaults toAmerica/Chicagowhen unset or blank. It decides which follow-ups count as due today and which raise aFOLLOW_UP_DUEalert - the server's own clock is UTC on Vercel and is never the right answer. A value that is set but is not an IANA zone is a typo rather than a choice, so it throws at module load and failsnext buildinstead of silently counting the day in the wrong zone. Seesrc/lib/dealership-day.ts
Demo and cron env:
DEMO_USER_EMAILnames the demo account, seeDemo ModeDEMO_AI_DAILY_LIMITcaps how many briefs the demo account can generate per rolling 24h, defaults to20. An explicit0is honoured and turns live demo AI off; blank, unset, or unparseable falls back to the default rather than to zeroCRON_SECRETauthorizes the scheduledGET /api/demo/reseedandGET /api/ai/sweeproutesSEED_PASSWORDis required before demo data can be seeded in productionNEXT_PUBLIC_TURNSTILE_SITE_KEYandTURNSTILE_SECRET_KEYgate theView demobutton; without the secret, verification is skipped outside production and refused in production
- Copy
.env.exampleto.env. - Point
DATABASE_URLat the Neon non-productiondevbranch pooled URL. - Point
DIRECT_URLat the matching Neon non-productiondevbranch direct URL. - Generate the Prisma client:
pnpm prisma:generate- Apply local development migrations:
pnpm prisma:migrate- Seed local demo data:
pnpm prisma:seed- Start the app:
pnpm devprisma/seed.ts is for local development only. All seeded users use password ctxdemo123.
admin@ctxchat.localgm@ctxchat.localsales@ctxchat.localservice@ctxchat.localparts@ctxchat.local
Those addresses keep the ctxchat.local domain on purpose. They are login
identifiers, not the product name: they exist as rows in every already-seeded
database and one of them is the value of the deployed DEMO_USER_EMAIL, so
renaming them is a data migration rather than part of the rename to Attend. See
content/decisions/2026-08-03-product-renamed-to-attend.md.
The local seed creates a portfolio-ready demo state with staff users, demo customers, conversations, tasks, notifications, tags, templates, AI Ops Brief insights, and product analytics events. Do not use it for production initialization.
The seed uses fictional customer data. It always writes a hand-written fallback brief for each demo conversation so the app is never empty and never depends on a provider being up.
When OPENAI_API_KEY is set, the seed then regenerates those same briefs through
the real inference path, so a viewer sees genuine model output rather than text
someone typed. A conversation whose call fails keeps its written fallback, which is
why the demo cannot break on a provider outage. Each regenerated brief is a paid
call; set SEED_AI_BRIEFS=false to skip that step and keep the written text.
One exception, stated because it is a real one: a short list of fields that
docs/demo-script.md quotes word for word is held across
regeneration, so a reseed cannot leave the script describing a screen that says
something else. Those specific values are written rather than inferred even when a
key is present. They are listed in demoScriptPinnedFields in src/lib/demo-seed.ts
and cover two conversations; every other field on every brief is model output.
The AI ops brief does not wait to be asked. A background pass briefs every conversation that needs one, so the inbox is ranked before an advisor opens it.
A conversation is eligible when it is not closed, a customer has sent at least one message in it, and no brief exists that is newer than its last activity. That last clause is what keeps the pass affordable: an unchanged thread is never re-briefed, so re-running the pass costs nothing until something actually happens.
Entry points:
GET /api/ai/sweep, authorized withCRON_SECRET. Scheduled daily invercel.json, after the demo reseed. It skips the one thread the seed deliberately leaves stale, matched on the seeded customer insrc/lib/demo-fixtures.ts, so the reseed's curated state lasts the whole day instead of until the sweep runs.Run passis a person asking, so it still briefs it.Run passin the inbox header, which runs the same pass scoped to the conversations the signed-in user can see, and reports what it did.
Each brief is a paid model call. AI_PASS_MAX_BRIEFS bounds a single run, and the
shared demo account is additionally bounded by DEMO_AI_DAILY_LIMIT.
Run pass is a Server Action, so it runs inside the serverless invocation that
rendered the page it was clicked on, and its time budget has to cover the same
sequential model calls the cron route makes. AI_PASS_MAX_BRIEFS alone does not fit
inside that budget: twelve calls against a thirty second provider timeout is 360
seconds against a 300 second invocation, so a degraded provider used to have the
invocation killed mid-pass and the briefs already written reported as nothing having
happened. Every loop that calls the model one conversation at a time therefore asks
startBriefBudget in src/lib/ai/ops-brief.ts before each call and reports what it
managed once the answer is no. The clock starts where the invocation starts and is
passed down, so whatever ran before the first brief draws down the same budget.
There are two loops: the ambient pass, behind /api/ai/sweep, /inbox and
/inbox/[conversationId], which counts the rest as left for the next pass; and the
seed's regeneration of its own written briefs, behind /api/demo/reseed, where the
destructive recreate spends the budget first and the rest stay on their hand-written
fallback. pnpm prisma:seed passes no budget and regenerates all of them,
because a terminal has no invocation to fit inside. The deadline is derived from the
invocation budget and the provider timeout rather than typed, so raising
AI_PASS_MAX_BRIEFS or adding seeded conversations cannot reopen the overrun.
Still hand-maintained: INVOCATION_BUDGET_MS in that file has to match maxDuration
in those four routes.
Known limit: DEMO_AI_DAILY_LIMIT is read and then spent, with no reservation in
between, so two people who click Run pass on the shared demo account at the same
moment both see the full remaining quota and both run a full pass. Before the ambient
pass existed the same race could overspend by one brief; a pass can now overspend by
up to AI_PASS_MAX_BRIEFS briefs, which at the default of 12 is roughly a quarter of
a dollar. It is accepted rather than fixed: the alternative is capping the pass below
what it needs to read every conversation. Not mitigated, and it needs simultaneous
clicks to happen at all.
Known limit: the staleness check runs in application code, so picking candidates means
loading every non-closed conversation that has an inbound message and filtering them
in memory. That candidate scan is unbounded, and it does not run only on a pass: the
inbox header's N of M briefed line is counted over exactly the same candidate set,
so the scan also runs on every /inbox load, every filter change, and every thread
open. It is a second query of a shape the inbox already runs, since the conversation
list itself is loaded unbounded on every render, rather than a new kind of cost. It is
fine at dealership volume and would need bounding if conversation volume grew
materially.
Without OPENAI_API_KEY the pass writes nothing, the inbox says AI is not
configured, and no brief is ever fabricated.
DEMO_USER_EMAIL names the account the login page's View demo button signs into.
It is service@ctxchat.local (Alyssa Torres, service advisor) because the service
advisor is the product's primary user - the demo has to land inside her work, not on
a manager's dashboard. The demo session lands on /inbox.
Leaving DEMO_USER_EMAIL unset hides the button and disables the demo provider.
Required on deploy: .env.example is a template, not configuration. An environment
deployed before this value changed keeps whatever DEMO_USER_EMAIL it was given,
which for earlier deploys is the manager account gm@ctxchat.local. Set
DEMO_USER_EMAIL=service@ctxchat.local in the deployed environment and redeploy, or
the demo still opens on a manager.
On a brand-new production database:
- Set production
DATABASE_URLto the Neon pooled production URL. - Set production
DIRECT_URLto the Neon direct production URL. - Set
BOOTSTRAP_ADMIN_NAME,BOOTSTRAP_ADMIN_EMAIL, andBOOTSTRAP_ADMIN_PASSWORD. - Run migrations:
pnpm prisma:migrate:deploy- Run the one-time bootstrap:
pnpm bootstrap:prodbootstrap:prod creates only:
- the first
ADMINuser - default dealership settings
- required tags
- starter templates
It intentionally does not create demo customers, conversations, or tasks. It also refuses to run if users already exist.
Use one Vercel project for the full app and one Next.js codebase.
- Local envs point at the Neon non-production
devbranch. - Preview envs point at the shared Neon non-production
previewbranch. - Production envs point at the dedicated Neon production database or branch.
Before validating a preview or production deploy, run Prisma migrations against that environment's DIRECT_URL.
Preview / production command:
pnpm prisma:migrate:deployVercel deployment and Twilio webhook setup details live in docs/vercel-twilio-deploy.md.
Routes:
POST /api/messages/sendPOST /api/twilio/inboundPOST /api/twilio/status
Production SMS in the US requires approved A2P 10DLC registration before sending dealership traffic.
Webhook verification is strict in local, preview, and production:
POST /api/twilio/inboundandPOST /api/twilio/statusonly accept Twilio-signedapplication/x-www-form-urlencodedrequests with a validX-Twilio-Signature.- Signature verification uses the existing
TWILIO_AUTH_TOKENand the exact incomingrequest.url. There is no separate signing URL env. - If
TWILIO_AUTH_TOKENis missing, webhook routes return503and do not mutate app data. - If the Twilio signature is missing or invalid, webhook routes return
403and do not mutate app data. - Signed but unusable payloads, including incomplete inbound messages or unknown outbound status SIDs, return
200 ignored.
Local and preview webhook setup:
- Use a real public tunnel or public callback URL. Twilio cannot sign requests against
localhost. - Point Twilio’s inbound and status callback URLs at that public URL.
- Ensure the URL seen by the app matches the signed request URL exactly, including protocol and host, or verification will fail.
- For manual replay testing, reuse the original signed payload and
MessageSidto confirm duplicate inbound/status requests return200without creating duplicate rows or notifications. - A step-by-step local validation runbook and replay utility live in docs/twilio-local-verification.md. Use
pnpm twilio:replayto send valid, missing-signature, or invalid-signature Twilio form posts at the public webhook URL. - For deployed webhook testing on Vercel instead of a local tunnel, use docs/vercel-twilio-deploy.md.
/settingsis available toADMINandMANAGER.ADMINcan create staff users, deactivate/reactivate them, reset passwords, and update dealership defaults.- Deactivating records a cutoff on the account (
User.accessEndedAt). Existing sign-in cookies are not destroyed - they stay in the browser and stop resolving, so every session minted at or before that moment is refused from its next request on, on every device. A session that carries no sign-in timestamp is refused too, whether or not its account has a cutoff, because it cannot show when it began - so the deploy that ships this signs everyone out once, on purpose. The cutoff is never cleared, so a reactivated staff member signs in again. A deactivated account's row on/settingsshows when access ended and when the account was last granted a request; refused requests are not recorded. - Resetting a password stamps the same cutoff without deactivating the account, so every session that existed before the reset is refused on every device while the account stays usable - the person signs in once with the new password. Every reset of an active account moves the cutoff, including a repeat one. Resetting the password of an already-deactivated account writes the new hash but leaves the cutoff where deactivation put it, so the "Access ended" time on that row stays the moment access actually ended. An admin may reset their own password and is signed out by it; that request lands on the login page with a notice saying so, while their other devices, and everyone else, get the plain login page.
- Integration health on
/settingsreports database, auth, app URL, and Twilio readiness plus recent outbound delivery failures. /coveragehands an advisor's open conversations to somebody who will read them. Every signed-in staff member reaches it and arranges their own coverage; aMANAGERalso reads the floor, and anADMINarranges anyone's - the case where somebody has already gone and cannot act at all. The picker offers only active staff who are not away themselves, and only open conversations move; closed history stays attributed to whoever handled it. The customer is never told, and every move is written to the existing audit log.- Coverage ends two ways.
I'm backreturns everything the cover never answered; anything the cover has replied to since the coverage began stays with the cover until it closes, because handing a live exchange back is a second change of voice for the customer.Leave them with <cover>is the admin's, for a trip that became a departure, as is a hand-off that is permanent from the start. Deactivation never arranges coverage by itself: a staff member's row on/settingsnames the open conversations still on the account, says nobody is reading them once it is switched off, and links to/coverage. The rules in full are in content/prds/2026-09-07-somebody-is-reading-while-she-is-away.md.
Stripepayment flows- Stripe signature verification hardening
- CI-driven migration automation against preview or production
- Branch-per-preview Neon automation


