# Plan: one agency number for every owner conversation

**Status:** Direction settled, not built. Written 2026-09-03, rewritten 2026-09-04.
**Goal:** Give every school owner one number for everything Jess says to them, instead of
one number per location plus each sub-account's own booking notifications.

**Decided:**
- One agency line, owner traffic only. Leads keep texting their own school's number.
- Owner inbound is its own kind of work, not a lead item with a flag on it.
- Booking notifications get internalized into the dashboard rather than moved inside GHL.
- No mirror of who owns what. The owner is a GHL contact, and the schools they own are
  read back from the `ownerContactId` we already store per slug.
- One wake per owner.
- The only thing enforced in code is that a wake may touch only that owner's schools.
  Everything else is Jess's judgment, including which schools a request affects.
- Escalations stop carrying contact ids and start using names.
- The instructions load only on the shared line.

---

## The problem

`ownerContactId` is stored per slug in the `text-agent` field store
(`engine/scripts/lib/text-agent/client-config.js:75`), and owner-directed sends go out
from that client's own sub-account. An owner with two locations gets texts from two
numbers, both signed Jess, and a third from each sub-account's booking-notification
workflow.

Worse than ugly. Every queue item is stamped with one `locationId` at ingest
(`engine/scripts/lib/text-agent/ingest.js:84`), and that id resolves to exactly one slug
(`client-config.js:61`), so a wake reads only its own sub-account's conversation. Jess at
location A cannot see a single message Jess sent from location B. If the owner answers in
the wrong thread — and she will, they are two unknown numbers on her phone — the question
the reply belongs to is not merely hard to find, it is invisible. If A has its own open
escalation the reply looks like it answers that one, a lead gets booked wrong, and no
error is raised anywhere. The other location waits forever, because telling the owner
completes the item.

Live exposure: `third-eye-martial-arts-studio` and
`third-eye-martial-arts-studio-dunbar` are both text-agent enabled with separate
locationIds. Neither has an owner contact set yet, so this has not fired.

## The idea

One agency sub-account with one number, A2P registered under Veuze. Every owner
escalation, every owner reply, and every booking notification leaves from it. The owner
learns one number.

The reason this is worth the work is not tidiness. On one line the whole conversation is
one thread, so an ambiguous reply becomes an ordinary reading problem instead of an
unrecoverable one. That is the entire trade.

**Leads are not on this line.** A lead texts the school's own number, because that is the
number they know, and their wake is born pointed at that location exactly as it is today.
Only owner-facing traffic moves. This is what keeps the change small, and it is also why
the agency number only ever texts people we have a business relationship with — a
materially easier A2P story than an agency number texting consumers.

## Owner inbound is a different kind of work

A lead message and an owner message are not the same shape and should stop pretending to
be. A lead arrives on a school's number, is about themselves, and the wake is born
knowing everything it needs. An owner arrives on the shared number, is usually about
somebody else, and may concern any of their schools.

`KINDS` in the queue is `inbound` and `followup` today
(`engine/scripts/lib/db/text-agent-dal.js:8`). Owner traffic becomes its own kind, so the
difference is in the data rather than in a branch inside the lead path. That is what lets
the shared-line rules, the skill module, and the one-wake-per-owner rule all key off the
same fact without any of them inspecting a lead item.

## Who the owner is, without a second database

There is no ownership table. The owner is a contact in the agency sub-account, and their
name and numbers are that contact's — GHL already holds them, so we read them rather than
copy them.

Which schools they own is already recorded: `ownerContactId` per slug. Point those at the
agency contact and the mapping falls out of a reverse lookup — every slug whose
`ownerContactId` is this contact. One owner, one contact, one place their identity lives,
and the thing that says what they own is the field that has always said it.

An inbound message on the shared line therefore resolves without a lookup table: the
webhook names the contact, and the contact names the schools. A sender who owns nothing
is not an owner, and does not enqueue. Anyone can text that number.

## Where the line falls between code and judgment

Exactly one thing is enforced:

**A wake may only touch the schools that owner actually owns.** The item carries that
set, every acting tool refuses a location outside it, and no tool enumerates anything
wider. This is the security boundary, so it lives in the data layer with a test.

Everything else is hers, and the plan is deliberate about not building machinery around
it. She is a colleague, not a process. If the owner says they are away Friday and to
close their schedule, she closes it everywhere it is affected — that is what the sentence
means, and asking permission to act on both of someone's own schools is not safety, it is
noise. If a request genuinely reads as ambiguous she asks, the way a person does, and
that is the whole mechanism.

What the queue does is hand her facts, not decisions:

- the owner's schools, and
- what is outstanding for that owner — who we escalated about, which school, what we asked.

That second list is the important one and it costs nothing. Today the contact id in her
own earlier message is the only return path she has: she signs an escalation with
`(Sarah Miller, contact ABC123)` and next wake she finds that message and `focus`es the
id (`engine/skills/text-agent-core.md:243-247`). We already know what we asked and where,
so the system remembers it instead of making her re-read her own outbox to recover it.
That is pre-digestion, not a constraint.

With that list in front of her the common case answers itself. When it does not, she does
what a person would: the owner said a name, so she looks it up across their schools. Two
people with the same name and she reads both threads and picks by what fits. Still unsure
and she asks.

## Escalations stop looking like software

Names replace contact ids. A name search resolves both who and which school at once, and
it reads like a colleague texting rather than a database row. First name when it is
unambiguous, full name when it is not, in the sentence rather than in brackets.

The id was exact and a name is not, so the rule that replaces its exactness is the one
above: ambiguity ends in a question to the owner.

## The instructions live in their own module

None of this should cost anything on an ordinary lead message, which is nearly every
wake. `engine/scripts/lib/text-agent/skill-modules.js:21` already assembles the skill per
wake and already hands her `text-agent-ops.md` only when the thread is the owner's. A new
module for the shared line is selected the same way and read only when it wakes her.

The selector changes with the new kind. `ownerThread` today compares
`client.ownerContactId` to the item's contactId (`skill-modules.js:36`), which needs a
client, which needs a locationId. An owner item has no single client, and does not need
the comparison — being owner-kind is the condition.

## Booking notifications come in-house

The reason this is worth stating as a decision: leaving them in GHL means each
sub-account's workflow keeps texting from its own number, and the owner still has several
numbers no matter what we do with escalations.

We already receive every booking. `AppointmentCreate` reaches the same webhook the text
agent ingests from (`dashboard/server/webhooks/ghl.js:45`, handled at
`engine/scripts/lib/text-agent/ingest.js:116-122`). So the notification becomes ours: the
dashboard sends it, from the agency line, worded once instead of per sub-account, and the
per-sub-account workflows get turned off. This also means the owner's booking alerts land
in the same thread as everything else Jess says, which is the point of the whole plan.

## Sequence

1. **Owner kind.** Add it to the queue, and give owner items the owner's school set.
2. **The boundary, with its test.** Acting tools refuse a location outside the item's
   set. Land this before anything can reach two sub-accounts.
3. **The agency sub-account and number**, A2P registered.
4. **Shared-line ingest.** Resolve the sender to a contact, reverse-look-up their slugs,
   enqueue owner-kind. Unrecognised senders are dropped. One wake per owner at a time.
5. **Open items.** Record what an escalation asked and about whom, and put that list in
   the wake prompt. Names replace ids in the escalation text.
6. **Cross-school reads.** Name lookup across the owner's schools, and a way to read a
   contact's thread without claiming them. Both are ordinary GHL reads keyed by
   sub-account plus contact, bounded by the item's school set.
7. **The skill module**, selected on owner-kind.
8. **Booking notifications in-house**, per the section above, and the GHL workflows off.

## Open

- **A2P** for an agency number texting on behalf of client businesses. Smaller than it
  looks, because the line is owner-only, but not answered.
- **What the owner sees** the first time an unknown number texts them.
- **Changeover**: whether per-location owner threads keep working during the transition
  or get cut over at once. Third Eye is the natural pilot, being the only live
  multi-location owner.
