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.

Running aba151a https://speed-to-lead--forge-trevato-apps.open-platform.sh 1 preview first preview 19m after New app · in production 1h 23m
History

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.

  1. 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.

  1. 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.

  1. 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.

  1. Change the words first. Open Lena Novak, another red lead. Press

"Edit", change a sentence in the text box, then approve and send it.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

variablewhat it is
AGENT_NAMEyour name
AGENT_BROKERAGEyour brokerage
AGENT_LICENSEyour license number, as shown
AGENT_PHONEthe phone number in your emails
AGENT_EMAILthe email address in your emails
AGENT_TIMEZONEyour 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.
  • n logs a lead by hand: type each answer and press enter (name, email,

phone, source as a number, what they asked, consent y or n).

  • f cycles the views (needs you, in follow-up, closed, all); the desk

shows the current one with its count.

  • w shows this week's report (the last 7 days beside the 7 before, in the

same words as the page; esc goes back).

  • x shows the leads as CSV for the current view and search.
  • R restarts the sample leads from now (admins only; asks first).
  • esc leaves.

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:

  • s approves and sends it as shown.
  • e lets 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.

  • q marks the lead qualified, x lost.
  • b books 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.

  • D removes the lead (admins only; asks first).
  • esc goes 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 is no-store.
  • MCP: the export_leads tool (show, q) returns { name, csv, count }. Terminal: x on the desk shows the CSV for the current view and search on screen (esc goes 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.

filewhat it is
src/actions.tsevery operation, asking contract.json who may
src/leads.tsleads and messages: arrive, send, follow up, close
src/drafts.tsthe drafted words, the signature, the consent rule
src/clock.ts, static/clock.jsthe response clock: its colours, and its tick
src/sample.tsthe fifteen sample leads
src/pages/the desk and the lead page, server-rendered
src/routes.tsevery path, each API route described beside it
src/security.ts, src/http.tsthe door and the floor: rationing, same origin, headers
src/audit.tsevery change and every refused one, as a row (admins read /api/audit)
src/who.ts, src/contract.tswho is asking, and what the table lets them do
src/db.tsSQLite under FORGE_APP_DATA, forward-only migrations
src/live.ts, static/live.jsthe page stays current over /events, terminal writes too
src/mcp.tsthe same actions as MCP tools
src/terminal.ts, src/tty.tsthe desk in a terminal: its screens and keys, and the pty
design/tokens.jsonthe palette and type, light and dark
↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs