trevato/speed-to-lead
Public: anyone can read this; the crew can change it.
Answer every new lead in under a minute: a desk of incoming leads with a response clock, a drafted personal reply to approve and send, and a 30-day follow-up cadence that never lets anyone sit untouched.
main
- contract
- design
- docs
- scripts
- src
- static
- .gitignore 37 B · ~11 tok
- AGENTS.md 12.0 KB · ~3.1k tok
- bunfig.toml 41 B · ~14 tok
- contract.json 928 B · ~310 tok
- forge.json 137 B · ~46 tok
- package.json 506 B · ~169 tok
- README.md 43.6 KB · ~11k tok
README.md
Speed to Lead
Answer every new lead in under a minute, and never let anyone sit untouched for thirty days.
An agent who replies within five minutes is far likelier to win the lead than one who replies in thirty — and most agents take hours. Speed to Lead puts every new inquiry on one screen with a clock running, has a personal reply already written for you, and keeps following up for a month until the person is qualified or gone. You approve every message before it goes.
A five-minute tour
For the sales meeting. The names below are the sample leads on the desk.
Before you start: Press "Sign in" on the desk. Anyone signed in can read and answer leads (steps 2 to 7). Restarting the samples (step 1) and saving your profile (step 8) need an admin; if you are not one, ask the person who runs the app to do step 1 just before the meeting, and read step 8 aloud.
The clocks keep running while the app sits idle, so the colours below are only what you see right after a restart. Do step 1 first.
- Restart the samples. At the bottom of the desk, press "Restart the
sample leads from now" (admin). The fifteen sample leads are laid out again with fresh clocks.
- Read the desk. The newest leads are at the top. Maria Gonzalez and
David Chen have just asked, so their clocks are green, under five minutes; they turn amber at five. Above the list, your response record says how fast you have been lately, and under it one short line for each lead source, e.g. "Zillow · 4 leads · median 0:48 · 3 answered under 5 min", so you can see whether Zillow leads are slipping.
- Answer a red lead. Tom Becker has waited hours, so his clock is
red. Tap his name: his question is on one side, the reply already
written for him on the other. Press "Approve and send". His clock stops
and shows how long he waited, and under "Sent." a "Next: <name>, waiting
<clock>" link takes you to the lead who has waited longest among those
that need you (the desk's "Needs you" view); when none is left it says
so and links back to the desk. Any page that needs nothing from you shows
the same link. In the terminal, ] opens the next lead; POST
/leads/:id/reply and the send_reply tool return it as next.
- Change the words first. Open Lena Novak, another red lead. Press
"Edit", change a sentence in the text box, then approve and send it.
- See who may be texted. David Chen carries an "Email only" badge: he
never agreed to be texted. Open him and see that his drafted reply is an email, not a text. Maria Gonzalez says "Text OK", so hers is a text.
- See the follow-up plan. Open Marcus Lee, who has been answered.
Below the conversation sit his follow-ups for day 1, 3, 7, 14 and 30:
the ones already gone, and the ones still to come. When a day arrives,
the follow-up shows on the desk, ready for you to approve. Then open
Ethan Brooks and press "Mark qualified": he leaves the follow-up plan.
Back on Marcus Lee, open "They wrote back", paste "Yes, Saturday
works" and record it: he is in "Needs you" with a fresh green clock and an
answer drafted, and his upcoming follow-ups wait until you send it.
On a busy morning, press "read them together" beside "follow-ups due
today" on the desk: every due follow-up is on one page with its words, each
box unticked. Tick the ones you approve and press "Send the ticked ones";
nothing ticked sends nothing, and one that stopped being due is skipped
and named. It is the same send as "Approve and send" (quiet hours, the
one-minute Undo, the audit). In the terminal, press d on the desk: the
same list with each follow-up's words, space ticks, s sends the ticked.
- Book a showing. Open Marcus Lee again, press "Book a showing", pick
a day and time later this week (in your time zone from the profile), type an address and book it. The
history says "Showing booked", his row on the desk carries a Showing
badge, and his follow-up plan says which follow-ups wait until the day
after. Press "Cancel showing" and (once it is the last) they return to their usual place. A
booked showing also drafts a note for the morning after it, at 8:00 in
your time zone: it asks how the home was, names the address, and offers
another showing or talking numbers, by the lead's channel and signed with
your profile. It is only a draft until you approve it, and it shows on the
lead's plan as "After the showing". The sample Rosa Alvarez had a
showing yesterday, so her note is already waiting on a fresh desk. A
cancelled showing has none; a second showing gets a second note.
The day before, at 8:00 in your time zone, a "see you tomorrow"
confirmation waits on the desk too: it names the showing's time and the
address, and asks them to reply if the time no longer works (a showing
booked less than a day ahead gets it at once). It is a draft you approve
or edit, shows on the plan as "Before the showing", and moves or goes
with the showing. The sample Ethan Brooks has a showing tomorrow, so
his confirmation is on a fresh desk.
A fresh desk also lists upcoming showings above the leads (soonest first, at most five, each linked to its lead): Aisha Bello has one in two days.
A lead who says "not until spring" can be put away: on an open lead press
"Come back on…" and pick a day. Their follow-ups stop, they leave Needs
you and In follow-up, and their row (under All) says Back on that
day; at 8:00 that morning in your time zone a check-in draft waits for you
to approve. Hannah Wright is already put away for sixty days. "Bring
back now" clears the day, and so does a lead writing back.
Keep your best leads close with the star: tap ☆ on a desk row or under a
lead's name (one tap, no JavaScript needed). Starred open leads sort first
under every filter, and Starred is a filter of its own. A first reply
that has waited past five minutes still comes before the stars.
When a listing drops its price, press "Price change" on the desk, enter
418 Juniper Ln and a new price, and every open buyer whose inquiry or
booked showing names that address (Maria Gonzalez and Rosa Alvarez
in the samples) gets a drafted note on the desk; it says how many, and
entering it twice drafts nothing new. Nothing is sent until you approve.
In the terminal, press $ on the desk, type the address, press enter, type
the price and press enter: the same notes, the same count.
- Set your profile (admin to save). Press "Your profile" at the top, fill
in your name, brokerage, license number, phone and email, and save. Every draft not yet sent takes it at once.
- Watch a lead arrive live. Press "Bring in a new inquiry", beside "Log a
lead by hand". A new person is at the top of the desk with a green clock at 0:00 and a reply already drafted. Open the lead, press "Approve and send", and see the clock stop well under a minute. Press the button again for someone else; the seven made-up inquiries come in turn (some "Text OK", some "Email only"), and restarting the samples clears them.
- Paste a lead email. Real leads reach you as notification emails. Copy
this made-up one, press "Paste a lead email" beside "Log a lead by hand", paste it and press "Read the email":
``` From: Zillow Premier Agent <[email protected]> Subject: New Zillow lead: 12 Example St
You have a new contact on Zillow.
Name: Priya Raman Email: [email protected] Phone: (206) 555-0143 Message: Is 12 Example St still available? Can we see it this weekend?
Reply to this lead within five minutes for the best chance of a showing. ```
Nothing is added yet. The desk comes back with the hand form filled in (Priya Raman, her email and phone, Zillow, her question) and a line naming anything the email did not hold. "Text OK" stays unticked: consent is never read from an email. Check it and press "Add to the desk": her reply is drafted.
- After an open house, press "Open house sign-ins", type the property
address and paste the sheet, one visitor per line (a name, then an email and/or phone, separated by commas or tabs, as a spreadsheet copy gives them). "Read the sheet" lists each line as read and marks the ones it cannot; "Add" makes each visitor a lead with a thank-you for that address drafted. Someone already on the desk lands on their lead. Nothing is sent until you approve it.
- When a lead came by referral, say who sent them: pick the source
"Referral" and fill "Referred by" with their name and an email or phone (James O'Neill in the samples came from Sarah Patel). A short thank-you to that person, naming only the lead's first name, waits on the lead's page (and as a "Thank Sarah (referrer)" tag on the desk), labelled as going to the referrer, never to the lead. Press "Approve and send thank-you", edit it first, or "Don't thank". Nothing goes until you approve it; it goes by email when they gave one; with only a phone it goes as a text (to someone who has not agreed to be texted, so the card says so and the choice is yours), held until 8:00 a.m. if you approve it after hours. The referrer shows on the lead's page, in the spreadsheet and in Activity, and is never put in a message to the lead.
- For the sales meeting, press "This week's report" under the list: one
printable page (no nav, no JavaScript) with the last 7 days in your time zone beside the 7 before: leads in by source, median time to first reply and how many were answered inside 5 minutes, follow-ups sent, showings booked, leads marked qualified and lost with their reasons.
Nothing is really sent in this prototype: "send" records the message in the lead's history and goes no further. To run the tour again, restart the samples (step 1).
To take a lead to a showing on paper, press "Print brief" on their page: one
sheet with their details, what they asked, your private note, the next showing
and the last three messages (?print=1 shows the same sheet on screen).
How it works
When follow-ups run out. A lead that is still new or contacted, whose
every follow-up (on its own schedule, whatever cadence the agent chose) has
gone out or been set aside, and who has never written back, been called or had
a showing booked, shows "Follow-ups finished — no answer" on its desk row and
its page (followUpsFinished, src/finished.ts). The row offers one button,
"Mark lost — never answered": it posts to /leads/:id/state with the
never_answered reason, so it is the same action, permit check and Activity
entry as marking lost by hand, and nothing is sent to the lead. The
terminal says the same on the desk and lead screen, and m on the lead screen
does the same one thing (hidden for anyone permit would refuse). No new
permission cell. The sample desk includes one such lead (Ruth Okafor).
Sample inquiries arrive on demand from the desk button, POST
/sample/inquiry (JSON, 201), the MCP new_sample_inquiry tool, or i in the
terminal desk. They go through the same path as POST /leads, for anyone who
may add a lead. The turn is read off the desk, so it needs no extra state.
Open house sign-ins. readSignIns (src/signins.ts) reads one visitor
per line (tab- or comma-separated, any order) and marks a line it cannot
read instead of guessing; openHouseDraft (src/drafts.ts) words the
thank-you. POST /leads/signins/preview (previewSignIns, MCP
preview_sign_ins) writes nothing; POST /leads/signins (addSignIns, MCP
add_sign_ins, o in the terminal) adds, re-reading the sheet itself. At
most 100 lines and 20,000 characters (422 otherwise); the cell is lead
create, and a match by email or phone joins the existing lead.
Pasting a lead email. readLeadEmail (src/intake.ts) reads the
labelled lines of a Zillow, Realtor.com or website-form notification (Name,
Email, Phone, Message and the usual variants) into a new lead's fields and
sets the source from the sender (Realtor.com is filed under "Website form":
the desk has no source of its own for it). It only prefills: nothing is
added until the person presses the add button, a field it cannot find is left
out, and consent is never taken from an email. The same read is POST
/leads/parse with {"text": …} (at most 10,000 characters; JSON back, nothing
written) and the MCP parse_lead_email tool, for whoever may add a lead. The
terminal has it too: v on the desk takes the pasted
text, ctrl-d reads it, and the add screen opens with what was found (and a
line naming what was not) for you to check; consent starts as n.
Fixing a lead's details. A website form brings in a misspelled name or a
mistyped phone, so an open lead's page has Edit details (name, email,
phone; checked as when a lead is added, and one of email or phone must
remain). The same edit is PATCH /leads/{id} or POST /leads/{id}/edit
with JSON, the MCP edit_lead tool, and d on the terminal's lead screen.
Drafts not yet sent follow the new details: they are written again for the
new name, and with no phone (or after STOP) unsent texts become emails; with no
email, unsent emails become texts only for a lead who agreed to be texted,
otherwise they wait and the page says so (nothing can be sent to no address).
Sent messages stay as they were. The Activity page tells which fields changed,
with old and new values.
Your note on a lead. What a message thread does not hold (budget, timing,
what they want) goes in Your note, a card at the top of the lead's page
(2,000 characters; empty clears it), for a lead in any state. It is private
to you: never put into a drafted message or follow-up, never sent to the lead.
Its first line shows under the lead's name on the desk and the desk search finds
its text; PATCH /leads/{id} with {"note": …} (or POST /leads/{id}/note),
the MCP set_note tool and n on the terminal's lead screen change it, and the
Activity page tells the old and new note.
The desk (the first screen) lists your leads, newest first. Links above
the list narrow it: Needs you (a first reply or follow-up waiting),
In follow-up (open, nothing waiting), Closed and All, each with
its count (/?show=needs|open|closed|all; GET /leads?show=… and the MCP
list_leads tool take the same filter). The search box above the
links finds a lead by name, email, private note or phone (/?q=maria; digits only for a
phone, so 555 0101 finds (206) 555-0101), case-insensitive, up to 80
characters, and combines with the view; "Clear" drops it. GET /leads?q=…
and the MCP list_leads tool take q too, and the terminal desk searches
with /. Each row
shows the person's name, where they came from (Zillow, your website form,
an open house, a referral), what they asked, and a response clock
counting since they asked:
- green — under 5 minutes. You're winning.
- amber — under an hour.
- red — over an hour. Answer this one now.
Above the list sits your response record for the last 30 days, e.g.
"Median first reply: 3 min · 71% answered within 5 minutes": the middle
time from a lead asking to your first reply, and the share of leads
answered inside 5 minutes. A lead still waiting counts with the time it has
waited so far, so a red clock pulls both numbers down until you answer it.
With no replies yet it says so. The same figures are in GET /leads
(record, with the per-source lines as record.by_source) and on the
terminal desk. Under the record, one line per lead source that had leads in
those 30 days says how many leads it sent, the median first reply and how
many were answered under 5 minutes, measured the same way; sources with no
leads are left out.
Under the record, one plain line answers "am I getting faster?" by setting
the last 30 days beside the 30 days before them, counted by the same rules:
"2 min faster than the month before · 71% within 5 minutes, up from 54%".
It reads "slower" when you slowed down and "Same pace as the month before"
when the middle time is the same; if no lead arrived in the month before,
there is no line at all. A lead still waiting counts with its time so far,
so a red clock pulls the recent month down. The sample leads include a
slower earlier month, so a fresh desk shows an improving line. The figures
are record.earlier in GET /leads and the list_leads tool, and the line
is on the terminal desk too.
Admins can read the Activity page (/activity): every change and refused attempt in plain sentences, newest first, 50 to a page, with a "Messages sent only" filter. In the terminal an admin presses a on the desk (o older, n newer, m messages sent only).
Once you've replied, the clock stops and shows how long it took.
Tap a lead to see what they wrote beside the reply drafted for them. The draft uses their first name, mentions where they found you, and answers what they asked about — a showing, selling their home, financing, relocating, a first purchase, price. Then:
- Approve and send sends it as written.
- Edit opens the text right there; change anything, then approve and send.
Text or email. Each lead carries a badge: Text OK if they gave written consent to be texted, Email only if they didn't. The app never drafts a text to someone without that consent — it writes an email instead.
When someone replies STOP, open their lead and use They asked to stop
texts (tick the confirm box first), press t on their screen in the
terminal, POST /leads/{id}/opt-out, or the opt_out_texts MCP tool. Their
badge reads Email only (opted out) with the date, every draft not yet
sent — the first reply and the remaining follow-ups, edited or not — is
written again as an email, and sent messages stay in the history as they
were. It is one-way: the app has no button to turn texting back on (new
written consent comes with a new inquiry), and asking twice changes nothing.
When someone writes in twice, they stay one lead. A new arrival (desk
form, pasted email, inbound webhook, MCP, terminal) whose email matches an
existing lead in any letter case, or whose phone has the same digits, adds no
row: their message joins that lead's history, a qualified or lost lead is
reopened, a fresh reply is drafted, the lead moves back to the top of the desk,
and the page says "Dana P. wrote in again". An open lead is preferred to a
closed one. The audit log records one merged event naming the arrival's
source and the lead. A name alone never merges. Two people who share a phone
(a family) do merge unless one gives another number, so give each their own.
The "Add a sample inquiry" button always adds a new person, with an address
and number of its own, once the set of examples is used up.
Your signature goes on every message, and can't be edited off: your name, brokerage and license number, as state advertising rules require. Texts also say "Reply STOP to opt out."
The follow-up plan. The moment your first reply goes out, the app
drafts five follow-ups for day 1, 3, 7, 14 and 30. Each shows on the
lead's page as upcoming; on its day it appears on the desk as ready, and
you approve and send it like the first. Mark qualified or Mark lost
takes a lead out of the plan. A mis-tap is undoable: a closed lead's page
offers Put back in follow-ups (o in the terminal, {"state":"open"} to
the API and MCP). The lead returns to contacted (or new if no first reply
was ever sent), and the follow-ups not yet sent are drafted again and
scheduled from that moment at day 1, 3, 7, 14 and 30, skipping those already
sent.
Why a lead was lost. Marking a lead lost asks for one reason: bought with
another agent (bought_elsewhere), not ready yet (not_ready), never
answered (never_answered), not a real lead (not_a_lead) or other. On
the lead's page it is a menu beside Mark lost; in the terminal x lists
the five and 1–5 picks; the API and MCP take {"state":"lost","reason":"not_ready"}
and answer 422 for a missing or unknown reason. The page then reads "Lost — not
ready yet" with the date, and putting the lead back clears both. Leads lost
before reasons were asked for read "no reason given". Admins see a count of
lost leads per reason over the last 30 days at the top of Activity, and the
CSV export ends with a lost_reason column.
When a lead answers. The app sends nothing, so it never sees their
reply: you paste it in. On the lead's page open They wrote back, paste
what they wrote (up to 2000 characters) and choose email or text (text only
for a lead who gave written consent). It appears in the history on the other
side from your messages, with the time; the lead is back in Needs you with
a fresh response clock from that moment, and a short answer is drafted (same
signature and consent rule as every draft) to edit and approve like any
reply. While that answer is owed the follow-ups not yet sent are paused;
when you send it they are scheduled again from that day at day 1, 3, 7, 14
and 30, the same rule as putting a closed lead back. Sent touches are never
changed, and the response record still measures first replies only. A lead
that is closed, or has had no first reply yet, must first be put back or
answered. The same is POST /leads/:id/inbound (body, channel; form or
JSON), the MCP log_lead_reply tool, and w on a lead's terminal screen
(ctrl-t switches text and email, ctrl-d records).
When you phone a lead. Many realtors answer by phone, and the app sends
nothing, so you say how the call went. On a lead that has a phone number,
press Log a call beside the number, choose Talked, Left a voicemail or
No answer, and add a note if you like (up to 500 characters). The history
shows it with its time ("You called · Left a voicemail"). Talked or Left a
voicemail on a lead not yet answered is its first answer: the response clock
stops at the call, the day 1, 3, 7, 14 and 30 follow-ups start from it exactly
as after a sent first reply, the drafted first reply is set aside (never
sent), and the response record counts the lead as answered, once, at the
earlier of the sent reply and the call. No answer is only written down. The
same is POST /leads/:id/call (outcome: talked, voicemail or
no_answer; note; form or JSON), the MCP log_call tool, and c on a
lead's terminal screen (ctrl-o changes the outcome, ctrl-d logs it). It needs
the same permission as any change to a lead, and shows on the Activity page.
When a lead agrees to a showing. "Saturday at 10 works": on an open lead,
press Book a showing, choose the date and time (in the future, at most 90
days ahead, entered in your profile's time zone) and, if you like, the address (up to 200
characters). The history says it ("Showing booked · Sat 10:00 · 12 Example
St") and the lead's row on the desk carries a Showing badge. A buyer touring
several homes can hold several showings (at most 20, never two at the same
time): each is listed on the lead's page in time order with its own Cancel
showing, and each gets its own "see you tomorrow" draft and its own
morning-after note; cancelling one withdraws only its drafts. While a
showing is ahead, follow-ups due before the last one do not come to Needs you until
the day after it, so nobody gets "just checking in" before they have seen the
house; the follow-up plan on the lead's page says which ones wait and until
when. Cancelling puts them back where they were, ready at once if their day
has come. Nothing is sent. The same is POST /leads/:id/showings (at, an
ISO 8601 time such as 2026-11-07T10:00; address; form or JSON; 422 for a
past, malformed or too distant time), DELETE /leads/:id/showings/:showingId (leads carry a showings list), and the MCP
tools book_showing and cancel_showing (showing_id, optional with just one). It needs the same permission as any
change to a lead, and shows on the Activity page.
When a lead says "not until spring". On an open lead whose first reply
has gone, Come back on… takes a day (a calendar day in your profile's time
zone, from tomorrow to 365 days out). The lead gets a back_on date, not a new
state, so qualified and lost are untouched: follow-ups not yet sent are set
aside, the lead is in neither Needs you nor In follow-up (it shows under
All with a Back on badge), and at 8:00 that morning in your zone one
check-in draft ("you said to get back in touch around now…", by the lead's
channel, signed with your profile, never your private note) comes due for you
to approve. Nothing is sent without approval. Bring back now clears the
date, removes an unsent check-in and schedules the set-aside follow-ups again
from today; logging that the lead wrote back, marking them qualified or lost,
and sending the check-in do the same. The same is PATCH /leads/:id with
{"back_on": "2027-03-01"} (null brings them back; 422 naming back_on
for a bad date), the MCP tool put_lead_away, and a (put away) and A
(bring back) on a lead's terminal screen. It needs the same permission as any
change to a lead, and the Activity page says "put … away until …" and "brought
… back".
Starring a lead keeps it close: PATCH /leads/:id with {"starred": true} (or
false; 422 for anything else), the page's POST /leads/:id/star, the MCP
tool star_lead, and * on the terminal's desk and lead screens. A lead's JSON
and the CSV export carry it (starred, the CSV's last column, yes or no),
GET /leads?show=starred lists the starred open leads, and the Activity page
says "starred …" and "took the star off …". It needs the same permission as any
change to a lead; the policy table is unchanged.
Quiet hours. Texts go out only between 8:00 a.m. and 9:00 p.m. in your
time zone, the window realtors are told to keep. Approve a text at 11 p.m.
and the app does not send it: it is approved, held, and the lead's page and
the desk say "Goes at 8:00 a.m. tomorrow". It goes by itself soon after 8:00
(the app checks every five minutes), and until then Cancel and keep as a
draft takes it back. If the lead replies STOP before it goes, it never goes
as a text: it becomes an email draft. Emails are never held. A held text
counts as your reply from the moment you approve it, not from when it goes,
and the line under your response record says so. Set your time zone on
Your profile (an IANA name such as America/Chicago; until you do it is
AGENT_TIMEZONE, then TZ, then America/New_York). A script sees it as
scheduledFor in the answer to POST /leads/:id/reply and takes the text
back with POST /leads/:id/unhold; MCP has send_reply and
cancel_held_text; in the terminal, h on the lead. QUIET_HOURS=off lets
texts go at any hour (the test suite sets it). The 8:00 delivery is done by
the clock, not by a person, so it is not audited: Activity shows the text as
held (and the cancel), but never the send itself.
Undo send. Pressed Send on the wrong lead? For 60 seconds after you
approve a message, its lead's page shows Undo send beside it, and so does
the Due follow-ups page after "Send the ticked ones", naming each lead sent to.
One tap returns the message to a draft with the words it went with, and the lead is as
it was before: a first reply undone leaves the lead new with the clock running
again (and out of your response record), and the follow-ups that send
scheduled are taken back, so sending again makes them once. It works on a text
held for quiet hours too. The minute is the server's clock; after it the
button is gone and POST /leads/:id/undo answers 409 with a sentence, as it
does if anything else changed on the lead in the meantime. It is a same-origin
POST needing the same permission as any change to a lead (the policy table is
unchanged), and Activity records both the send and "took back the message …"
with who and when. It is on the page, the JSON door, MCP (undo_send) and the
terminal (z on the lead, offered while the minute lasts).
Skip one follow-up. Each Upcoming (or ready) follow-up in a lead's plan
has Skip this one: it leaves the desk and never sends, and the rest of the
plan is untouched. It shows as Skipped with Bring it back, which returns
it to Upcoming at its original day, or to ready if that day has passed (a
showing's hold still applies). Only the lead's own day-1-or-later follow-ups
that are scheduled or ready can be skipped; the first reply, answers, and sent
or held messages are refused with 409. Closing and reopening a lead leaves a
follow-up you skipped skipped. Scripts use
POST /leads/:id/messages/:mid/skip and .../restore; MCP has
skip_follow_up and restore_follow_up. In the terminal, on the lead, k
skips the next follow-up in the plan (it says which day) and K brings back the
first one you skipped. Activity shows both.
Sample leads. The app starts with fifteen sample leads so you can see it work: two just in, two waiting a while, three gone red, and five already answered and partway through their follow-ups, plus three lost, each with a different reason. An admin can restart them from the bottom of the desk, so the clocks read fresh for a demonstration.
What is real and what is not, in this prototype
- Nothing is actually sent by the app. "Send" marks the message sent, shows it in
the lead's history with the time and who approved it. This is how a
message really leaves: each sent message in the history has an "Open in
Mail" (mailto:) or "Open in Messages" (sms:) link that opens it,
signature included, ready to send in your own app. No link for a draft
(approve first), for a lead with no address for that channel, or for a text
to a lead who asked to stop. Texts use sms:<number>?body=…, which
Android and current iOS read; older iOS wanted ?&body=.
- No CRM import. Leads arrive by the form on the desk ("Log a lead by
hand") or by POST /leads (below), which a website form can call.
- No login of its own. You sign in with the forge that runs the app.
Anyone signed in can work the desk; only an admin can delete a lead or restart the samples. Someone not signed in sees only your name and a sign-in button.
- Drafts are templates, filled from what the lead wrote and your
profile. There is no AI writing them and no outside service called.
Sending due follow-ups together
/due lists every follow-up due today. A script does the same with
POST /leads/send-due and {"ids": [...]} (message ids, at most 100, each a
due follow-up; one that stopped being due is skipped and named in skipped),
and the MCP tool is send_due_followups. Each id goes through the same send
as POST /leads/:id/reply. In the terminal, d opens the same list and
s sends the ticked ones.
Your profile
Open Your profile in the header (/profile) and fill in your name,
brokerage, license number, phone and email. An admin saves it; everyone
signed in can read it. The license number is required, because every
message shows it. Drafts you have not sent take the new profile at once;
messages already sent keep the signature they went out with.
Under How your first reply opens, an admin can write the opening line for each source (Zillow, Realtor.com, website, open house, referral), up to 160 characters on one line; leave one empty to keep the built-in wording. Preview shows the first reply to a sample lead in your words without saving; new first replies use them, drafts already on the desk keep theirs, and an opener too long to leave a text under its limit falls back to the built-in line for that text.
Under Follow-up days an admin chooses when follow-ups go out after the first reply: 1 to 8 whole days, each 1 to 90 and each later than the one before, typed as 1, 3, 7, 14, 30 (the default). A lead you answer after saving gets its follow-ups on the new days; leads already in their follow-ups keep the touches already scheduled, and messages already sent are never touched. A list that breaks those rules is refused with a plain-English message and nothing changes (422 from the API, where cadence is a list of numbers, e.g. {"cadence": [2, 5, 10]}; MCP's set_profile takes the same). Activity shows the change. A day with no wording of its own borrows the nearest standard wording. In the terminal, p shows the days and an admin's e ends with a "Follow-up days" line (after the openers) that takes the same list and shows the same refusal.
Until you save one, the app uses these environment variables (forge Settings → App → Environment) or, failing those, a sample agent, Jordan Avery of Harbor & Pine Realty. Once a profile is saved it wins.
| variable | what it is |
|---|---|
AGENT_NAME | your name |
AGENT_BROKERAGE | your brokerage |
AGENT_LICENSE | your license number, as shown |
AGENT_PHONE | the phone number in your emails |
AGENT_EMAIL | the email address in your emails |
AGENT_TIMEZONE | your time zone, for quiet hours (falls back to TZ) |
In the terminal, press p on the desk to read it, openers included; an admin
presses e and types each field in turn, the five above and then one opening
line per source (enter moves on, empty means the built-in wording, the last
one saves). A save from the terminal reaches web viewers live.
A browser form that posts openers as a non-admin gets a redirect (303) with a notice on the page, where JSON and MCP get a 403; either way nothing is saved.
A script reads it with GET /profile and an admin changes it with
POST /profile (JSON, the five fields and optionally timeZone); MCP has get_profile and
set_profile.
Saved replies
The profile keeps up to 12 saved replies (a name of at most 40 characters, text of at most 1,000), edited with the profile by whoever may update it; two samples (a lender intro and an open-house invite) ship until you save your own. While editing a draft on a lead's page, Insert saved reply adds the chosen text at the end of the draft box (a small script does it in place; without JavaScript the same button posts to /leads/{id}/insert and returns the page with the text appended). Nothing is sent until Approve and send, and a text's usual length limit still applies. {first_name} becomes the lead's first name; nothing else is interpolated. JSON: replies in GET/POST /profile, POST /leads/{id}/insert; MCP: insert_saved_reply and set_profile's replies.
For whoever connects it to a website
Every page has a JSON twin. Send content-type: application/json (or
accept: application/json) as someone the forge has signed in:
# a new lead (consent: written consent to be texted)
curl -X POST https://<app>/leads -H 'content-type: application/json' \
-d '{"name":"Maria Gonzalez","email":"[email protected]","phone":"(206) 555-0101",
"source":"zillow","message":"Can we see 418 Juniper Ln?","consent":true}'
curl https://<app>/leads # the desk
curl "https://<app>/leads?show=needs" # only who is waiting on you
curl https://<app>/leads/1 -H 'accept: application/json' # one lead, every message
curl -X POST https://<app>/leads/1/reply -H 'content-type: application/json' -d '{}'
curl -X POST https://<app>/leads/1/reply -H 'content-type: application/json' -d '{"body":"edited words"}'
curl -X POST https://<app>/leads/1/state -H 'content-type: application/json' -d '{"state":"qualified"}'
curl -X POST https://<app>/tick # the day turns: follow-ups come due
source is zillow, realtor_com, website, open_house or referral. Names,
emails, phones and messages are plain text: anything carrying control
characters is turned away. POST /tick is safe to call as often as you
like, but only as someone the forge has signed in — an unsigned scheduled
job gets "sign in first". Nothing depends on it: the app does the same
itself every five minutes. The whole API is described at /openapi.json, and the same
actions are MCP tools at POST /mcp (list_leads, get_lead, add_lead,
send_reply, mark_lead, export_leads).
A referral may name who sent it the same way over the wire: POST /leads takes
referred_by with referrer_email or referrer_phone (only with source
referral), add_lead in MCP takes the same, and the thank-you is approved
with POST /leads/{id}/thanks (send_thanks in MCP; r in the terminal).
From a terminal
When the forge gives the app a terminal, the command to reach it is shown
in the forge as FORGE_APP_SSH. It is the same desk, with the same clocks
ticking, and anything done there shows on open web pages within a second.
On the desk:
j/k(or the arrow keys) move between leads; enter opens one.nlogs a lead by hand: type each answer and press enter (name, email,
phone, source as a number, what they asked, consent y or n).
fcycles the views (needs you, in follow-up, closed, all); the desk
shows the current one with its count.
wshows this week's report (the last 7 days beside the 7 before, in the
same words as the page; esc goes back).
xshows the leads as CSV for the current view and search.Rrestarts the sample leads from now (admins only; asks first).escleaves.
On a lead, you see what they asked, the reply exactly as it will go, your signature under it, and whether it goes by text or email. Nothing is sent until you press a key on this screen:
sapproves and sends it as shown.elets you edit it first: type, enter for a new line, backspace to
delete, then ctrl-d to approve and send — or esc to send nothing.
qmarks the lead qualified,xlost.bbooks a showing (or moves the one it has) on an open lead: type the
time in your profile's time zone like 2026-11-07T10:00 and press enter, then the address (or
nothing) and press enter. A refused time asks again. u cancels the showing. The lead's
screen and its desk row show the showing.
Dremoves the lead (admins only; asks first).escgoes back to the desk.
The week on one page
GET /report (the desk's "This week's report" link) shows the last 7
calendar days in the agent's time zone, today included, beside the 7 before.
Leads, answer times and losses come from the same code as the desk's response
record (responseRecord, lostCounts), so the two never disagree; follow-ups
sent (everything sent after the first reply except answers to a lead's own
message), showings booked and leads qualified are counted in src/report.ts.
A lead is "qualified" in the week it was marked qualified (lead.qualified_at, set when it is marked and cleared when reopened), so a later edit or note never moves it; one qualified and then lost counts in both. Follow-ups sent are days after the first reply (day 0) and before the answers to a lead's own messages. The terminal's w shows the same report. A script
asks the same path for application/json (in /openapi.json); the MCP tool is
weekly_report. It needs the right to list leads, like the desk; anon gets 401.
Taking your leads with you
Every lead and its status, as a spreadsheet, for whoever may list leads, with no cap on the count:
- Desk: the "Download leads (CSV)" link under the list; it keeps the current view and search. HTTP:
GET /leads.csv[?show=needs|open|closed|all][&q=…](anon gets 401). The response isno-store. - MCP: the
export_leadstool (show,q) returns{ name, csv, count }. Terminal:xon the desk shows the CSV for the current view and search on screen (escgoes back); download the file from the web desk.
Columns: name, email, phone, source, status, arrived at, first reply at, minutes to first reply, last message at (the later of a message sent and a reply from the lead), texts allowed, lost reason, starred, and who referred them (name, email, phone). Times are ISO 8601. A field starting with =, @, -, +, tab or carriage return is prefixed with ' so a spreadsheet never runs it as a formula; the one exception is a phone number written like +1 206-555-0101 (a plus, then digits and the usual marks), which is kept as typed.
Bringing your leads in
From the desk, "Import leads (CSV)" (/leads/import): paste a CSV or choose a .csv file (up to 1 MB and 2,000 rows) with a header row, then press "Check the spreadsheet". Columns are found by header, in any case: name, source (zillow, realtor_com, website, open_house, referral) and email and/or phone are needed; message, state (or status: new/open, qualified, lost) and arrived at are optional, and other columns (the download's own, for instance, so your export imports as it is) are ignored. The preview shows each row as it will land and lists the rows it cannot use (no name, neither email nor phone, bad email, unknown source) with the reason; a row whose email or phone matches a lead already on the desk, or an earlier row, is named and not added again. Nothing is written until you press Import, and imported leads are history, not inquiries: no first reply is drafted, no follow-ups are scheduled, no response clock runs, they are not counted in the response record or the week's leads in, and consent to text is never carried over (log a call or reply on one to start working it). HTTP: POST /leads/import/preview and POST /leads/import with { "csv": "…" } (the same cell as adding a lead; anon gets 401, a cross-site post 403), each import written to the audit as imported, one row per lead. Terminal: I on the desk, paste the CSV, ctrl-d to check it, y to import. MCP: the preview_import and import_leads tools (csv).
Running it
PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
bun test # what forge.json's app.test runs
FORGE_APP_DATA=./data bun run src/tty.ts # the terminal
FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity
bun run lint:design # no colour but the tokens'
bun run contract:emit # rewrite contract/openapi.json
No dependencies: Bun, its built-in SQLite, and one database file under
FORGE_APP_DATA. AGENTS.md says how the code is shaped; contract.json
says who may do what.
| file | what it is |
|---|---|
src/actions.ts | every operation, asking contract.json who may |
src/leads.ts | leads and messages: arrive, send, follow up, close |
src/drafts.ts | the drafted words, the signature, the consent rule |
src/clock.ts, static/clock.js | the response clock: its colours, and its tick |
src/sample.ts | the fifteen sample leads |
src/pages/ | the desk and the lead page, server-rendered |
src/routes.ts | every path, each API route described beside it |
src/security.ts, src/http.ts | the door and the floor: rationing, same origin, headers |
src/audit.ts | every change and every refused one, as a row (admins read /api/audit) |
src/who.ts, src/contract.ts | who is asking, and what the table lets them do |
src/db.ts | SQLite under FORGE_APP_DATA, forward-only migrations |
src/live.ts, static/live.js | the page stays current over /events, terminal writes too |
src/mcp.ts | the same actions as MCP tools |
src/terminal.ts, src/tty.ts | the desk in a terminal: its screens and keys, and the pty |
design/tokens.json | the palette and type, light and dark |