trevato/listing-launch
Public: anyone can read this; the crew can change it.
One address in, a listing launched: the MLS description, three social posts, a flyer and an email blast, each checked for Fair Housing language and dressed in the agent's own brand.
main
- .claude
- contract
- design
- docs
- scripts
- src
- static
- .bash_profile 0 B · ~0 tok
- .bashrc 0 B · ~0 tok
- .gitconfig 0 B · ~0 tok
- .gitignore 37 B · ~11 tok
- .gitmodules 0 B · ~0 tok
- .idea 0 B · ~0 tok
- .mcp.json 0 B · ~0 tok
- .profile 0 B · ~0 tok
- .ripgreprc 0 B · ~0 tok
- .vscode 0 B · ~0 tok
- .zprofile 0 B · ~0 tok
- .zshrc 0 B · ~0 tok
- AGENTS.md 8.3 KB · ~2.1k tok
- contract.json 962 B · ~321 tok
- forge.json 137 B · ~46 tok
- package.json 485 B · ~162 tok
- README.md 43.8 KB · ~11k tok
README.md
listing-launch
One address in, a listing launched: the MLS description, three social posts, a flyer and an email blast, each checked for Fair Housing language and dressed in the agent's own brand.
Try it in five minutes
Sign in first; you can read everything without, but you need to be signed in to make a listing of your own.
- Open the sample. On the first screen, under "Launched", press the
card for 412 Alder Lane, Portland. Its page shows the price, the photos and the words, in tabs: MLS, Social, Flyer and Email. It opens finished: all seven assets approved (the pill reads "Ready to send"), each signed off by "Morgan Reyes (sample)", so you see what a launch looks like at the end. (Birchwood Court stays at 0, and your own copy starts at 0.)
- Read an asset and its check. The MLS tab has the description in a box,
and beside it the Fair Housing check: green when it is clear. Open the
card for 27 Birchwood Court, Boise to see it catch something: the
risky words are underlined, each with a one-line reason. Above the tabs,
the page lists each flagged phrase once, with how many assets it is in;
press Use the suggestion everywhere beside one to put the suggested
words in every asset at once. Each asset it changes needs approval again
(three taps clear the sample's 22 findings; you still approve every asset).
In the terminal, U on a listing does the same for the first suggestible
phrase on the asset under the cursor.
- Start a new one from the sample. Back on Alder Lane, press **Start a
new one from this. The form fills in with the sample's facts. Change the address to your own listing, then press Launch**. The sample is only to read; the new listing is yours to change.
- Approve one. On your listing, read the MLS description, then press
Approve under it (Approve with 1 flag when the check still has something to say). The top of the page now says "1 of 9 approved".
- Change the tone. Pick another tone beside the address (Classic, Warm
or Bold) and press Rewrite all. Every asset is written again in that tone, your edits are replaced, and the approval is cleared: it says "0 of 9 approved" again, because you have not read the new words yet.
- Put back your MLS words. Under the MLS box, press **Earlier versions
(1): it lists the words the rewrite replaced, when, by whom and why
("rewrite in Bold"), and the first line of each. Press Restore** by
your own words. They are back in the box, checked again, and the
approval is clear until you read them again. Every asset keeps its last
five versions; restoring keeps the words it replaces, so it can be undone.
Only you (and an admin) see the list: it holds drafts and words you took
out on purpose, which a reader of your listing never sees. In the terminal,
open the listing, move to the asset and press v to list its earlier
versions, then 1 to 5 to put one back. An assistant connected to the
app does the same with the list_versions and restore_asset tools.
- Drop the price. Press Edit facts at the top of your listing. The
launch form opens filled in with this listing's address, beds, baths, square feet, lot, year, price, features and brand. Lower the price and press Save and write all again: the new price is on the flyer, every asset is written again from the facts (your edits are replaced), and the approval is cleared, so it says "0 of 9 approved". The photos stay.
- Add an open house. Press Edit facts again. Under "Open house"
pick a date, a start and an end time (Alder Lane's sample already has one),
then press Save and write all again. The flyer gets a band under the
price, the email a line above its body, and the MLS description and every
post a closing line, such as "Open Sunday, March 7, 1–3 pm", all in the
listing's tone. Clearing the three boxes takes it off again. The times are
kept as you type them: no time zone is applied.
A listing with an open house also shows a Sign-in sheet link (and
/listings/<id>/sign-in): one Letter page with the photo, price, open house
times, your name and phone in your colour, and fifteen blank rows for name,
email, phone and whether the visitor works with an agent. Print it; nothing
typed on paper is stored. A listing with no open house answers 404 there.
Beside it, Print the open house sign (/listings/<id>/sign, on 412
Alder Lane too): one Letter landscape page for the corner post, with OPEN
HOUSE in your colour, the next open house's day and time, the address, a
large arrow, your name and phone and the property page's QR code. Choose
the arrow with the links above the page (?arrow=left, right or up).
A listing with no upcoming open house says to add one first; a sold or
off-market one says so.
Beside it, Sign-in screen for a tablet (/listings/<id>/open-house, on
412 Alder Lane too) is for handing the device to visitors at the door,
on your own signed-in session: your name and colour, the address and
photo, a name, an email and one big Sign in button. Each visitor goes
into the follow-up list (same rules as typing them in), sees "Thanks for
stopping by, <first name>" for a few seconds, and never sees anyone who
signed in before; a bad email shows in place with the name kept. Only you
or an admin can open it, it shows no other link than a small Done back
to the listing, and it works for a private showing with no open house.
A listing with an open house also gets an Open house follow-up email
(Email tab, "Preview follow-up" on the email page): a thanks in the
listing's tone with the facts, the property page link and your signature.
It is a draft until a person approves it (an assistant connected to the app
may draft and edit it, never approve it), has the Fair Housing check and
version history like every asset, is rewritten when the facts change, and
goes when the open house does. Below it, Would go to lists the people
from the sign-in sheet, which you type in from the paper (name and email,
escaped, at most 200). The list is personal data: only the listing's agent
or an admin can read or change it, through the listing's existing update
cell, so nobody gains a permission. The app sends nothing; copy the
email into your mail tool, or Download visitors (CSV)
(/listings/<id>/visitors.csv, author only) to import them into your CRM:
Name,Email,Open house,Listing, every field quoted, and a field starting
with =, +, -, @, tab or return gets a leading ' so a spreadsheet
never runs it.
It also gets an Open tomorrow reminder: a post (Social tab, with its
length line) and an email ("Open tomorrow — 412 Alder Lane, 1–3 pm", Email
tab, "Preview reminder"; O in the terminal), written in the listing's tone from the open house
times exactly as you typed them, with your contact, and the property page
link in the text of both. With two open houses the subject names both and
claims no "tomorrow". Like every asset they are unapproved drafts that count
in "0 of 11 approved", keep versions, are Fair Housing checked, are rewritten
(and unapproved) when the facts change, and go when the open house does or
when the listing is sold or leaves the market.
Copy a post. Once you approve a social post, a Copy post button
appears under it: one tap copies the approved words followed by the
listing's property page link (/homes/:id, absolute), ready to paste into
Instagram or Facebook. A draft has none. Without JavaScript the same text
sits in a selectable read-only box under "Copy post".
Text or email the agent. Under the price on /homes/:id, Text Morgan
and Email Morgan are plain sms: and mailto: links whose message
already names the address, the page link and any open house; a missing phone
or email hides its button, and the off-market page shows neither.
Copy for MLS works the same way on the MLS tab: once the MLS description is approved (the sample 412 Alder Lane MLS description already is), a Copy for MLS button copies the approved words as plain text, with no link, markup or emoji (© ® ™ stay). Without JavaScript the text sits in a read-only box under "Copy for MLS".
- Mark it sold. On your listing, pick Sold in the status box beside
the tone, add the price it sold at if you want it said, and press Set
status. Two more assets appear, a status post under Social and a status
email under Email ("Just sold — 9 Walkthrough Way"), written in the
listing's tone and checked like the rest; each needs your approval, so it
says "0 of 11 approved". The other assets and your approvals stay. The
flyer wears a "Sold" band, and the list on the first screen shows the
status beside the approval count. 88 Meridian Street is already sold, so
you can see it. Under contract does the same with its own words, and
Active takes the two away again, except from under contract, where it
reads Back on market: the deal fell through, the listing is active, and
a "Back on market" post and email (address, price, open house if set, your
contact) wait for approval; from sold or coming soon it just takes them
away, and saying Active on an active listing changes nothing.
Withdrawn and Expired take a listing off the market without a sale:
its property page answers with the address and "This home is no longer on
the market" and shows no price, words, photos or open house; its launch
week and calendar file list nothing; open house dates drop off the flyer,
email and posts. Pick Back on market to relist it, the same way as from
under contract.
Coming soon teases a home before it is listed: a post and an email
("Coming soon — 412 Alder Lane", the price, the facts, "Message me to be first to see it"), never a date you did not give.
Lower the price. In Edit facts, a lower price adds a price
improvement post (Social) and email ("New price — 412 Alder Lane, now
$849,000, was $899,000") in the listing's tone, each Fair Housing checked
and needing its own approval. A raise or no change adds nothing; raising
the price back to the original, or above it, takes them away. The same
goes in the terminal (press r on the listing) and for other tools.
Find one. Above the list, All · Active · Under contract · Sold ·
Coming soon · Off market · Needs approval are links with their counts, and the
search box matches the address, any case, together with the filter.
- Print the flyer. Press Print flyer: a one-page flyer opens with
the photo, price, facts, description and your name. Print it or save it as a PDF.
- Make a square picture. Under the Social tab press Square picture, then save it as a PDF or take a screenshot; it wears "Draft — not approved" until the first social post is approved.
- Download the launch kit. Press Download launch kit at the top of
the listing: one text file named from the address, with the price and facts, then every asset under its name, each marked "Approved by … on …" or "DRAFT — not approved", with its Fair Housing findings beneath it. Hand it to an assistant or a coordinator.
- Show buyers the home. Under the facts on the listing, the property
page link (/homes/:id) has a Copy button; the flyer prints it and the
email's "See the home" button points to it. Open it on a phone: photos,
price, status, open house, your contact details, and the MLS description
only once you approved it. Until then everyone else gets a 404 and you see
"Not public yet: approve the MLS description". The link's origin is
FORGE_APP_URL, never whatever address the visitor typed; the terminal and the
launch kit print the same URL. Two samples' MLS texts (Alder Lane and 1630 Hawthorne Terrace) are approved so
the preview shows a page, with each linking the other under "More homes from".
- Print the seller report. Press Seller report beside the flyer and
the kit: one page with the address, price, status, photo, the property page
link and a row per asset, each with "Approved by … on …" and its words, or
"Draft — not approved yet" with no words. Print it at the end of the
meeting. For a sold listing with a sold price, a line under the address
gives the result in agents' numbers: "Sold for $752,000 · 101% of the
original $745,000 list price · 9 days on market". The percent is the
sale-to-list ratio against the first list price; if the price came down it
also gives the ratio to the last list price. Days on market are the same
count the listing page shows. Sold without a price shows no line.
In the terminal, R on an open listing shows the same report, approved
words only, with the same line.
- See the launch week. Press Launch week on a listing: a day-by-day
plan of which asset goes out when — the Coming soon teaser, launch day (MLS,
social posts, email blast), price and status posts, and for each open house
a reminder post the day before and the open house itself. Each row links to
its asset and says who approved it, or Draft; a draft is never shown as
going out. Add to calendar downloads /listings/:id/schedule.ics, built
by hand: all-day events, open houses at the times as typed (floating local
time, no time zones), launch day being the day the listing was created.
Nothing is sent; the app only drafts. In the terminal, w on an open
listing shows the same plan as text.
- Draft and approve the seller update. Open the Seller update tab on 412 Alder Lane to read one, then use
the listing you made in step 3 to approve one: the sample listings belong
to the sample agent, and only a listing's own agent can approve its words.
It is a
weekly email to the seller, written in the agent's name from what the app
knows — the address and status, every asset approved in the last seven
days (who and when), what the launch week has in the next seven days, the
open house dates and the property page link. Edit it, read the Fair Housing
check, then press Approve like any asset; until then it wears "Draft — not
approved". It follows approvals while it is an unedited draft, and editing
the facts or the status clears its approval. An assistant cannot approve it.
In the terminal it is the last asset of the listing: j to it, a to approve
or withdraw. The seller report leaves it out; it is a note, not something
that goes out.
- See the coming week. Press This week above the list on the first
screen: the next seven days, a section for each day, with every listing's posts, emails and open houses that go out that day, and who approved each (or Draft). At the top, Still needs approval names each listing with some of its words not yet approved, such as "3 of 7 approved". 412 Alder Lane is on this page now: its launch day rows, each marked "Approved by Morgan Reyes (sample)". 27 Birchwood Court and 88 Meridian Street are under Still needs approval, with their drafts. (Alder Lane's open house is in March, so it joins the week when that date comes within seven days.) Add to calendar puts the same week in your calendar. Nothing is sent from this page.
- Write down a showing. Use the listing you made in step 3: a showing log is only
visible to the listing's own agent, so the sample listings, which belong to the sample agent, show you none. Scroll to Showings. Leave the day as today, type the buyer's agent if you know them, pick how interested they were (Not interested, Maybe, Interested or Offer likely), write one line of what they said and press Log showing. It lands at the top of the list, newest first, and the first screen now shows "1 showing" beside your listing. Remove takes it back out. Only you see this log: it is not on the property page, the flyer or the email.
- Paste the property page link. Open 412 Alder Lane and copy its property page
link (the Copy button), or just open it on a phone, and paste it into a text or a post: the phone shows a preview with the address, the price, the beds and baths and the first photo. 412 Alder Lane is approved, so its link works for anyone; the listing you made shows a page only once you approve its MLS description.
src/walkthrough.test.ts walks these steps on every bun test, so this
list cannot drift from the app.
For the agent: what it does, in one read
- Type the property in. On the first screen: the address, beds, baths,
square feet, lot, year built, price, five to ten feature lines (one per
line, in your words), and up to eight photos (24 MB at a time) — the first
is the hero. Photos can be changed later, on Edit facts: add, remove,
or make another the hero (24 MB of new files per save; the flyer and the email need approving again).
Over the JSON API, PATCH { photos: { order, remove, alt } } reorders,
removes and describes; uploads are multipart on the page only.
Describe each photo once. On Edit facts, each photo has an optional
one-line description (up to 120 characters, plain text, no control
characters), saved with the photo order. It becomes the alt text on the
listing's photo strip, the property page, the email (and the HTML "Copy as
email" copies), the flyer and the square picture, so a screen reader or a
mail client with images off says "Kitchen with quartz island" instead of
"Photo 3 of 412 Alder Lane"; a photo without one keeps today's alt text.
Descriptions are Fair Housing checked like any typed words, and a flagged one
shows its finding beside its box (they are public on the property page).
Removing a photo removes its description. A description is not an asset's
words, so changing one clears no approval; only a new order or a removal
clears the flyer's and the email's. The sample listings come described.
GET /api/listings?status=sold&q=oak and the MCP list_listings tool take
the same filter and search as the home list; an unknown status is All and
q is capped at 100 characters.
Your brand (name, brokerage, license number, phone, email, headshot,
colour) is asked once: the new-listing form starts filled in from your most
recent listing (only ever your own), every field editable, and your headshot
is reused unless you choose a new file (the form says which listing it is from).
Launching an address you already launched (same address, any case or
spacing; only your own listings are looked at) asks first: the form comes
back with everything you typed and a link to the listing you have, and
launch another anyway goes ahead (choose the photos again). The JSON API
and the MCP launch_listing tool answer 409 duplicate_address with the
existing id unless the request says again: true. The terminal has no
launch command, so it has nothing to ask.
- Pick a tone — classic (measured), warm (inviting) or bold
(short and punchy) — and press Launch.
- Read every word. The listing opens at its own address
(/listings/<number>, bookmark it) with the assets in tabs: the MLS
description, three social posts (short, medium, carousel
captions), the flyer, the email blast and a 30-second video tour
script (6–8 numbered shots, each naming the photo to show, the words to
say and the on-screen caption, at most 80 spoken words, counted beside it
like the MLS length) and a text to past clients (a "Just listed"
message of at most 160 characters, the property page link included, with
the first open house if there is one). Each has an edit box, a
Copy button, and the Fair Housing check beside it: green when
clear, amber with the risky words underlined and a one-line reason —
"'family-friendly' implies familial status; say 'near parks and
schools'". The check runs again as you type and on every save.
Under the text is N / 160 characters; one over reads "Too long for one
text by N" and its Approve button asks you to approve it anyway (audited as
approved_too_long_for_text). A status change (coming soon, under contract,
sold, back on market) writes a matching status text. Once the text is
approved, Copy text copies it with its link.
Under each social post's box is its length and the limits it fits (X 280,
Instagram 2,200, Facebook 63,206). A short post over 280 reads "Too long
for X by 34" in the same aside, and its Approve button says "Approve —
too long for X". The medium post and carousel are for Instagram and
Facebook, so only the short post is held to X. Counting follows the
stated rules only: on X a link is 23 and an emoji is 2; elsewhere a
character is a character. X's other weights (wide CJK characters) are not
modelled, so a post close to the line deserves a look in the network.
Approving a short post over X is refused unless the request says it knows
(tooLong: true; the page's button sends it, since it says so on its
face), and the audit trail records such an approval as
approved_too_long_for_x. The terminal shows the same line and note under
each social post; a on a short post over X asks once, and a second a
approves it.
The MLS description shows N / 1,000 characters beside its box, counted in
code points and live as you type; over 1,000 (the common MLS public-remarks
cap, MLS_LIMIT in src/postlength.ts) it needs the same tooLong
acknowledgement, on the page, the API, MCP and the terminal, and the audit
trail records it as approved_too_long_for_mls. (The audit trail is where
it is recorded; no seller report reads audit outcomes, so none shows it.)
Where a finding's rewrite is a phrase that can stand in its place, the
aside has a Use "near parks and schools" button beside it (only for
whoever may edit the listing; advice such as "describe the home, not the
faith of who might buy it" gets none). It replaces exactly those words —
checked against the stored text by start and end, so a stale page answers
409 and changes nothing — and saves as any edit does: checked again,
audited, approval cleared. It works as a plain form POST
(/listings/:id/assets/:kind/suggestion) and updates the box in place
with JavaScript; the use_suggestion MCP tool takes { id, kind, start,
end, text }, and u in the terminal uses the first suggestion on the
asset under the cursor.
To take a phrase in every asset at once, the page has Use the suggestion
everywhere (a plain form POST to /listings/:id/fair-housing/suggestion
with phrase), the terminal has U, and the use_suggestion_everywhere
MCP tool takes { id, phrase }: one transaction, one audit entry per
changed asset, approval cleared on each, nothing approved.
Each asset also has an Approve button ("Approve with 2 flags" when
the check still has findings): it records who signed off and when, the
header counts "4 of 7 approved" and turns to "Ready to send" at 7 of 7,
and editing an asset or Rewrite all clears the approval. The same
action is a in the terminal; PATCH /api/listings/:id with
{ kind, approved: false } and the approve_asset MCP tool can withdraw,
and refuse to approve (person_required), as does the JSON API. Approving is bound to the words you
read (seen, the asset's updated_at; the form also sends the box's text):
if they changed, it answers 409 and approves nothing. Approval is a
record, not a gate: Copy and Print flyer do not look at it.
Each listing on the home list (and in the terminal's list) reads "Ready to send"
or "3 of 7 approved" beside its Fair Housing pill, live through /events;
GET /api/listings and the MCP list carry approvals (kind, approved) per
listing. The flyer and the email preview wear a "Draft — not approved" band
(on paper too; the terminal's preview says it in text) until their asset is approved.
- Preview the email. The Email tab's "Preview email" opens
/listings/:id/email: the subject, hero photo, price and facts, the body,
your photo, name, brokerage, license and contact in your colour, and the
Equal Housing Opportunity line. "Copy as email" puts the rendered HTML
(with a plain-text twin) on the clipboard for your mail tool; without
JavaScript the page still renders. "Copy subject" copies the subject line
apart, for the mail tool's subject box; the message copied is the card only.
In the terminal, p on an open listing previews the same message as text. Photos are this app's own, never remote.
Send it from your own mail app: approve the email, tap Download as email
draft (.eml), open the file, and it opens as an unsent draft in your mail
app (X-Unsent: 1), with your name and address in From, the subject, a
plain-text twin and the HTML with the photos as links to this app. Add the
people to send it to and press send yourself; the app sends nothing. The
file is GET /listings/<id>/email.eml (?which=followup or reminder for
the other two; built in src/eml.ts), same read as the email page. An email
not yet approved answers 409; the follow-up's To is left empty and the
visitor list is never in the file. Header values are cleaned of line breaks.
The follow-up and reminder download as …-followup.eml and …-reminder.eml.
In the terminal, E in an email preview shows the file's headers once approved
(a draft says it needs approving first).
Download launch kit (GET /listings/:id/kit.txt, built in src/kit.ts) sends
the whole listing as one attachment named like 412-alder-lane-portland-or-97214-launch-kit.txt.
Whoever may read the listing may download it; no permission changed. The launch_kit
MCP tool returns the same text, and k on an open listing in the terminal shows it
(the arrow keys move between assets there).
The launch week. GET /listings/:id/schedule lays the listing out as a
day-by-day plan (built in src/schedule.ts): the Coming soon teaser, launch
day (MLS, social posts, email blast), status and price posts, and for each
open house a reminder post the day before. Each row names its asset, links to
it and says who approved it; an unapproved asset reads Draft and is never
shown as going out. GET /listings/:id/schedule.ics is the same plan as an
iCalendar file, written by hand with no dependency, days as typed and no time
zones. Whoever may read the listing may read both; nothing is written or sent.
This week, across every listing. GET /week (the home list's This
week link) shows the next seven days from today, a section per day with
every row of every listing you may see: the address linking to the listing,
what goes out, the open house times, and who approved it or Draft. Above the
days, each listing with assets still awaiting approval says how many are
approved ("3 of 7 approved"). To add it to a calendar, open Add to
calendar (or subscribe/import /week.ics): the same seven days as one
iCalendar file, open houses as floating local times, everything else
all-day. Today comes from src/clock.ts; nothing is sent from the page.
In the terminal, w on the listings view shows the same week as plain text
(still-needs-approval first, then the seven days), for the listings the
viewer may list; esc goes back.
- Print the flyer. "Print flyer" opens a letter-size page — hero photo,
price, a strip of the facts, the description, your photo, name,
brokerage and license number, and the Equal Housing Opportunity line. It
also carries a QR code, labelled "Scan to see the home", beside the
property page link in words: a buyer scans it with a phone and lands on
/homes/:id. The open house sign-in sheet carries the same code. Until the
MLS description is approved the page is not public, so both say so in place
of the code. Print it, or save it as a PDF. "Rewrite all" writes every
asset again in another tone (your edits are replaced, so it asks with a
tooltip first).
Print the neighbor postcard. "Postcards" opens a Letter sheet of four
identical postcards (/listings/:id/postcard), dashed cut lines between
them: hero photo, a band ("Just listed", "Open house" with its date, or
"Just sold"), address, price, facts, a sentence or two of the approved MLS
description, the property page link and QR code, and your photo, name,
brokerage, license and phone. Until the MLS description is approved the
sheet is marked "Draft — not approved" and prints none of its words. Print
at 100% with margins set to none, then cut.
The estimated payment. Under the price, the property page of an active or
coming-soon home says "Est. $2,528/mo" with one line of assumptions:
principal and interest only, 20% down, a 30-year fixed rate of 6.5% (a sample
rate held as ANNUAL_RATE in src/payment.ts; there is no rate feed). It is
an estimate, not a loan offer: no taxes, insurance or fees. Under contract,
sold, withdrawn and expired pages show none, and neither does a home with no
price.
Showings. After each showing, write it down on the listing page: the day
(today unless you change it; never a future one), the buyer's agent if you
know them, how interested they were (Not interested, Maybe, Interested, Offer
likely) and one line of what they said. It is a plain form, newest first, and
"Remove" takes one back out. When the seller asks "how is it showing?", the
log answers, and the home list shows "5 showings" beside the listing. The
feedback gets the same Fair Housing check as anything you type, with the
finding beside the line. Only you (or an admin) see the log: buyer's agents'
names stay off the property page, the email and the flyer, and go when the
listing does. Assistants can add one too (POST /api/listings/:id/showings,
or log_showing over MCP); a refused field is named in the 422.
Three sample listings are there from the start so the screen is never empty; "Start a new one from this" copies any listing into the form.
What is real. Your listings, edits and photos are saved (SQLite and
files on disk under FORGE_APP_DATA). The words come from templates written
from your facts and nothing else — no other broker's MLS data, nothing from
the internet, no AI model yet: generate(listing) in src/generate.ts is
the one place a model would plug in later. Assistants may draft and edit;
only you approve. The approve_asset MCP tool can withdraw an approval but
refuses to give one (person_required); approval comes from the listing page or the terminal, and the page shows
which ("Approved by you 2m ago · via the page").
What it does not do. It does not post to social networks, submit to an
MLS, or edit images. You copy, print and send. The Fair Housing check is a
second pair of eyes, not legal advice: it knows phrases in context
(familial status, religion, national origin, disability, race, sex) —
"family room" passes, "family-friendly" does not; "integrated appliances"
passes, "integrated neighborhood" does not; "not suitable for children" and
"couples welcome" do not; "master bedroom" is a
flag-with-note — and a person approves every word. /fair-housing says what
it looks for, class by class, in words you can read to a client; every finding
links to it. It is built from the rules themselves, so it is never out of date.
- Days on market and price history. Every listing page and the seller
report show "N days on market" and a short table of its prices (the list price, each price change and the sold price, each with its date and the change from the one before); the home list shows "N days" beside each active listing, and the weekly seller update includes both. The terminal shows the same: the day count in the listing list, and the day line and price history at the top of an opened listing. Days run from the day the listing first went active (Coming soon does not count) to today, and stop at Under contract, Sold, Withdrawn or Expired; Back on market resumes the count from the original list date, as MLS does. Dates, not timestamps, so a listing launched today reads "0 days". The history is recorded when the price is changed on Edit facts or the sold price is set.
For whoever runs it
This is an app: the forge runs it. Production follows the default branch;
every open pull gets a preview at its own address with its own copy of the
data. Everything the app keeps lives under FORGE_APP_DATA, which is what
makes "production data" and "preview data" two different things.
There are no packages to install: package.json declares none, every
import is relative, bun: or node:, and no page loads anything from
another host. src/deps.test.ts fails the moment that stops being true.
PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
bun test # what `forge.json`'s app.test runs on every push
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 from the routes
app.test in forge.json runs beside app.run in every deploy, not before
it: the preview comes up the moment the app answers, and says live, or
serving · tests failed the moment the suite disagrees.
A property page's link preview (Open Graph and Twitter tags) carries the address and price, the facts line, the first sentence of the approved words and the hero photo's absolute URL. The sample photos are SVG, which some networks ignore when drawing a card; images are not converted, so those previews show text only there. Uploaded JPEG and PNG photos are unaffected.
Below the agent card, a property page lists up to three of the same agent's other homes for sale ("More homes from Pat Agent"): active or coming soon, newest first, each with its hero photo, address, price and beds/baths, and only those whose own page is public. No words are shown, and with none to show the section is absent.
The shape
One implementation, many doors. src/actions.ts is every operation as a
plain function — (db, who, input) in, data or a named failure out — and
the page, the JSON API, the MCP server and the terminal all call it and
decide nothing themselves. AGENTS.md says how to add to it.
| file | what it is |
|---|---|
src/actions.ts | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail |
src/listing.ts | what a listing is, and the check at the boundary that one arrived |
src/generate.ts | generate(listing): the eight assets from the facts, the video tour script and the 160-character text among them (more once it is under contract, sold or lowered in price), in three tones |
src/postlength.ts | a social post's length by what X, Instagram and Facebook count; the MLS and text counts against their limits |
src/fairhousing.ts | the Fair Housing rules: phrase, context, class, reason, rewrite |
src/photos.ts | photos as files beside the database, known by their bytes |
src/sample.ts | the sample listing, planted once |
src/showings.ts | the showings the agent logged: the boundary for a day, a buyer's agent, an interest and a line |
src/visitors.ts | the open house sign-in sheet's visitors: the boundary for a name and an email |
contract.json | who may do what: roles, and the least role each verb needs |
src/contract.ts, src/can.ts | the table, read at boot; the forge's evaluator, verbatim |
src/security.ts | the door: rationing, same origin, the table before the body |
src/audit.ts | every change and every refused one, as a row |
src/who.ts | who is asking, from the forge's signed headers |
src/db.ts | SQLite under FORGE_APP_DATA, WAL, forward-only migrations |
src/live.ts | the event table every process writes, tailed by the server for GET /events |
src/mcp.ts | the actions as MCP tools at POST /mcp |
src/routes.ts | every path, in one table, each API route described beside it |
src/openapi.ts | the routes as OpenAPI; committed at contract/openapi.json |
src/qr.ts | a string to a QR module matrix (byte mode, level M, versions 1-10) and its SVG; no dependency |
src/pages/ | the HTML, server-rendered: layout, home, listing and flyer, notes |
design/tokens.json, src/tokens.ts | the look as data, light and dark; compiled to /tokens.css |
src/http.ts | the floor: headers, body cap, files |
src/server.ts | boot: open the database, serve |
src/tty.ts | the terminal, over the same actions |
src/env.ts | what the forge told this process |
static/app.css, static/live.js | the look in the tokens' words (and @media print), the list that stays current |
static/listing.js | tabs, copy buttons, the check as you type; the page works without it |
static/sample-*.svg | the sample listing's illustrations |
What every app stands on
- The door. Before a route sees a request,
src/security.tsrations
writes (and a stranger's API calls), refuses a write from another site, and turns away — before the body is read — anyone the table lets through a resource's door for no row at all: 401 for a stranger, 403 for anyone else. The action still decides about the real row.
- The audit trail. Every change, and every change refused, is a row:
who, which resource and verb, which row, when, and whether they were
let. Admins read it at GET /api/audit. A row that cannot be written
is a log line, never a failed request.
- The API, described.
GET /openapi.jsoniscontract/openapi.json,
written from the routes by bun run contract:emit; each operation names
its cell of the table (x-contract). bun test fails while it is stale.
- Up, or not.
GET /healthzanswers{ ok, version, uptime }; the
forge waits on it before it sends anyone to a deploy.
- The look.
design/tokens.jsonholds every colour, space and size,
light and dark; nothing else spells out a colour (bun run lint:design),
and every pair a person reads meets 4.5:1 in both themes.
- The open web. A shared link unfurls (description, Open Graph, a
card); /robots.txt keeps crawlers to the page, and out of previews
entirely; /.well-known/security.txt sends a reporter to the forge
that runs the app.
Who you are
The forge says who is asking on every request it proxies, and the app
verifies the signature (src/who.ts); nothing else is trusted. Three tiers:
- anon — anyone. Reads listings and their flyers (a flyer link can be
shared), and notes.
- user — signed in to the forge and able to see this app. Launches
listings and adds notes, as themselves; edits and removes their own.
- admin — able to write this repository. Edits or removes any listing
or note (the sample listing included).
contract.json holds this as a table; listing has the same cells as
note.
"Sign in" is /__forge/signin?to=<path> and "Sign out" is
/__forge/signout; the forge answers both on the app's own host, and
/__forge/report too: it sends anyone, with no account, to report a
problem on this app's repository, if that repository is public.
Any interface
The same actions, the same rules, at every door. A script is the person whose forge grant it carries; a bare script is anonymous.
curl https://<app>/api/listings
curl https://<app>/api/listings/1
curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"kind":"mls","text":"…"}'
curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"tone":"bold"}'
# change facts (any launch field); every asset is written again, approvals cleared
curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}'
# an open house (up to two; local date and 24-hour times as typed; [] clears)
curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"open_houses":[{"date":"2027-03-07","start":"13:00","end":"15:00"}]}}'
# where it stands: active, coming_soon, under_contract, sold, withdrawn or expired (sold_price optional, whole dollars); adds or removes the status post and email
curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"status":"sold","sold_price":752000}'
curl -X DELETE https://<app>/api/listings/1
As an MCP server, for Claude Code or Claude Desktop — list_listings,
get_listing, launch_listing, edit_asset, retone_listing, set_status, update_facts,
update_photos (order, removals and descriptions; uploads are on the page only), delete_listing,
check_fair_housing, and the notes' list_notes, add_note,
delete_note:
claude mcp add --transport http listing-launch https://<app>/mcp
{
"mcpServers": { "listing-launch": { "type": "http", "url": "https://<app>/mcp" } }
}
The pages stay current over /events: a listing launched, changed or
removed elsewhere — on another page, over the API, or in the terminal — or a
note, appears without a reload. Without JavaScript it is the same page, one
reload behind.
The terminal opens with the exact command in FORGE_APP_SSH when the app
runs (ssh -p 2222 <name>@<forge> — the app is the login name, the forge
the host; <name>--<pull>@<forge> for a preview's). It is src/tty.ts on
a real pty over the same data, as the account at the keyboard. Open a
listing with enter to read every asset with its Fair Housing findings;
j/k move between assets, e edits the one under the cursor (type \n
for a line break, enter saves and checks it again), t rewrites them all
in the next tone, s sets the status (sold 752000, under contract, coming soon,
withdrawn, expired, active), p previews the email as the client reads it, P the open house follow-up with the sign-in visitors it would go to, V lists those visitors (a adds one as Name, email, d removes the one under the cursor; only the listing's agent or an admin, a guest sees none), S lists the showings you logged (a logs one as date; buyer's agent; interest; what they said, with the date empty for today and the interest as not interested, maybe, interested or offer likely; d removes the one under the cursor; only the listing's agent or an admin, a guest sees none), x removes a listing of your own. tab switches to the notes. Launching a listing, with its photos, is the page's alone.
No runtime dependencies: Bun's own bun:sqlite, Bun.serve and node:
modules. The typefaces are the system's (a Palatino/Iowan serif for
headings and the flyer, the system sans for the rest), so nothing is
fetched from another site.
The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV (production or
preview), FORGE_APP_URL, FORGE_APP_COMMIT, FORGE_APP_SSH and
FORGE_APP_IDENTITY_KEY when it starts the app. forge.json says how.