How DJ runs inside CrowdVolt. It covers what reads, what decides, what moves money, how fast it has to happen, and where each piece plugs into code that already exists. The rule under all of it: the model reads, the engine acts, and money only moves under terms the user approved.
DJ runs end to end on an isolated test system, with test cards and test sellers. Nothing is merged yet: it is a stack of draft pull requests (the work). It ships dark, to staff, over the air: no native change and no Face ID. The design further down is the original architecture; where it differs from this section, this section is what was built.
| Permission | What the person means | What runs |
|---|---|---|
| Once | “get those now” | DJ quotes the exact show, ticket type and quantity at checkout's own price. Buy now re-quotes, then opens CrowdVolt checkout. List and Change price open CrowdVolt's own listing editor at DJ's price. |
| Notify | “lmk under 80” | An alert at their price for their quantity, shown as a live row. A group alert counts seats as listings line up. |
| Buy for me | “get me 2 under 80” | A plan, approved on a sheet with one required consent box. DJ buys the whole quantity from one listing through checkout's own path. When no one listing has the quantity under the max, it buys in parts: several sellers, each an ordinary CrowdVolt order, one after another. It never splits tickets they said must sit together. CrowdVolt's per-show ticket limit holds: over it there is no sheet, only CrowdVolt's line and Change plan to what's left. A platform they haven't linked never holds a plan: at approval DJ fixes the one delivery platform that fills the whole quantity under the max for the least, every part comes from it, and the sheet offers one optional row, Link <Platform> for instant delivery, that never blocks Approve. Up to $1,000 a plan, with no daily cap, and Buy for me is only offered within those caps. |
| DJ prices it for me | “sell my pair by Friday, at least 80 back” | The autopricer keeps their listing first in line and never goes under the minimum they approved. One sell sweep per pass: offers first, then pricing. A buyer's offer within $1 of DJ's price that still earns the minimum sells the whole listing as one CrowdVolt sale. Selling to offers has its own switch. |
One card per plan. A plan bought in one purchase posts one receipt in the thread. A plan bought in parts posts nothing per part: its own card fills in place (“2 of 6 bought”), and each part is a row in the plan's activity, with its seller and a link to the order.
Sheets open complete. The approval sheet arrives with the draft: the show's art with ticket type, date and venue, the terms as rows, and one consent line. Approve works as soon as the box is ticked.
No backward compatibility. Nothing has shipped, so the DJ pull requests carry no compatibility code and no compatibility migrations. The server and the app deploy together, and the first merge is the first contract.
The capability layer. DJ reads the person's whole account (wallets on every platform, each listing's delivery state, each sale's next step) and the catalog (artists, venues, events, search), and answers with composed cards: artist and venue cards with one hero, details behind folds, and names that open CrowdVolt's pages.
Delivery in CrowdVolt's words. An order or a sale is one card: CrowdVolt's own status badge, one line on what is happening, the steps folded under it, and one button only when a step is the person's to do. An order or sale waiting on them takes the dock with that step. Each plan has its own page: the terms, what DJ bought or sold, and the activity, with the orders one tap down.
calc_buy, cart_buy_order and buy_approved, the chain checkout uses, with an off-session charge on the card pinned at approval.Each layer can do one kind of thing. Nothing below a line can widen what the line above allowed.
Backtests on past events, agent evals and a market simulation changed these decisions. Where they disagree with the first version of this design, the evals win, and the rest of this page already reflects them.
| Area | First version | Now |
|---|---|---|
| Last call | Transfer close, and plans end at doors | When carting closes, doors_close_time. Buy and sell plans run past doors, because a big share of trading happens after doors open |
| Buying | The first listing under the max | Smart waiting. DJ buys when a listing beats the expected cost of waiting, and that bar rises to the max as the deadline nears |
| Recommending a max | Three odds-based chips | Calibrated options phrased as facts, plus buy now. The suggested discount shrinks as DJ's share of waiting demand grows, because a discount everyone follows stops trades |
| Selling | An expected-payout curve | The simple rule: match early, lead by $1 in the last 6 h. The curve did no better and is harder to explain. A demand anchor gives a book of mostly DJ sellers something to price against |
| Price moves | Up to 6 an hour | A move budget that loosens toward doors. Raises are capped per move and wait until cheaper competition has stayed gone, which stops prices bouncing up and down |
| Fair share | DJ takes any listing at once | A short hold-off on brand-new listings once DJ holds a large share of an event's waiting demand, so people buying by hand still get a turn |
| Offers | Sell when an offer beats waiting | Sell to an offer within $1 of DJ's price, or one that crosses. Kept for speed and certainty, since an offer is a committed buyer. Built after the core pricer |
| The agent | Another model round after every tool call | The server looks up the event before the first call, replies ride inside the final tool call, and the model sees short aliases instead of real ids |
| Cards | Composed by the agent | A designed card for every common question. Composing from the kit is the fallback |
| Scale | One worker per event | Signals coalesce per listing, executors run in parallel within an event, and a pool's price is stored once |
| Market info | Recent sale ranges | Facts with a frame. No forecasts, no verdicts, no sale-by-sale history, no one's private limits |
flowchart LR
app["DJ thread, bar, page"]
subgraph API["py-mkt, /api/dj"]
direction LR
routes["DjRouteManager
auth, rate limits"]
look["Event lookup
before the model"]
llm["Agent loop
GPT-6 Luna, up to 16 rounds"]
val["Grounding check
every number from a tool"]
plans["Plan service
states, versions, edits"]
consent["Consent
challenge, hashes, ticked box"]
end
stripe[("Stripe
SetupIntent")]
idx[("dj_buy_scope
match index")]
pol[("pricing_policy
sell goals")]
log[("dj_event
dj_thread_message")]
app -->|"POST messages"| routes
routes -->|"text"| look
look -->|"text, market"| llm
llm -->|"reply, drafts, card"| val
llm <-->|"read tools"| reads[("Market and my data
book, plans, quotes, events")]
val -->|"draft or needs_info"| plans
routes -->|"edit, pause, stop"| plans
routes -->|"approval, approve"| consent
consent -->|"card for off-session"| stripe
consent -->|"active"| plans
plans -->|"buy plans"| idx
plans -->|"sell plans"| pol
plans -->|"every change"| log
classDef nw fill:__NEWSOFT__,stroke:__NEW__,color:__INK__
classDef ext fill:__INFOSOFT__,stroke:__INFO__,color:__INK__
classDef ex fill:__SURF2__,stroke:__LINE__,color:__INK__
classDef money fill:__WARNSOFT__,stroke:__WARN__,color:__INK__
classDef bad fill:__BADSOFT__,stroke:__BAD__,color:__INK__
class app,routes,look,val,plans,consent,idx,log nw
class reads ex
class llm bad
class pol ext
class stripe money
flowchart LR
subgraph MKT["market commits, same rules as today"]
direction TB
l1["new listing
QR approval"]
l2["price edit
cart released"]
l3["new bid"]
end
stream[("Redis DJ stream
latest per listing")]
pq[("Pricing signal queue
5 s coalesce")]
matcher["Buy matcher
beats waiting, oldest first"]
exec["Executors
parallel, locked per listing"]
recon["Reconciler
every 30 s"]
pricer["Pool pricer
match early, lead late"]
sugg["Suggestions
1 a day, 3 a week"]
chain["Checkout chain
calc_buy, cart_buy_order,
buy_approved, sell_approved"]
apply["ListingPriceChangeService"]
stripe[("Stripe
off-session charge")]
log[("dj_event
dj_thread_message")]
sock["cvsocket
ids only"]
push["notify
Expo push"]
app["App
refetches over REST"]
l1 -->|"after commit"| stream
l2 -->|"after commit"| stream
l1 --> pq
l2 --> pq
l3 -->|"after commit"| pq
stream -->|"blocking pop"| matcher
matcher -->|"match"| exec
matcher -.->|"alert lost, offer would fill"| sugg
exec -->|"cart, buy_approved"| chain
exec -->|"charge"| stripe
recon -->|"same key"| stripe
pq -->|"pool"| pricer
pricer -->|"move, within budget"| apply
pricer -->|"sell to offer"| chain
exec -->|"writes"| log
pricer -->|"writes"| log
sugg -->|"writes"| log
log -->|"on commit"| sock
log -->|"on commit"| push
sock --> app
push --> app
classDef nw fill:__NEWSOFT__,stroke:__NEW__,color:__INK__
classDef ext fill:__INFOSOFT__,stroke:__INFO__,color:__INK__
classDef ex fill:__SURF2__,stroke:__LINE__,color:__INK__
classDef money fill:__WARNSOFT__,stroke:__WARN__,color:__INK__
classDef bad fill:__BADSOFT__,stroke:__BAD__,color:__INK__
class stream,matcher,exec,recon,sugg,log,app nw
class pq,pricer,sock,push,apply ext
class l1,l2,l3,chain ex
class stripe money
| Piece | Lives in | Built like |
|---|---|---|
| new Plan service | shared/dj/_async, repos in shared/db/_async, tables tktx.dj_* | Async first, sync by unasync |
| new Routes | DjRouteManager, @requires_auth_uow on every route | vd_chat_resource.py, agent_action_resource.py |
| extended Agent | The ops_copilot loop made async and streaming, on the Responses API with gpt-6-luna. An event lookup runs before the first call | ops_copilot_agent_service.py, llm_tool_client.py |
| extended DJ signal | uow.add_dj_match_after_commit(event, ask) at the 23 pricing sites, keeping only each listing's latest state | unit_of_work.py:955, flushed :813 |
| new Buy matcher | Async worker lifespan loop, blocking pop instead of a 1 s sleep. One consumer per event, split by ticket type when an event gets busy | pricing_worker_service.py:58 |
| new Executor | Sync py_mkt code on a dj-worker fleet, in parallel within an event, locked per listing and per user and event. The unique dj_execution row is the idempotency guard | Ops CV-fill chain |
| new Reconciler, last call | Hatchet cron every 30 s. Last call at doors_close_time, scheduled like cart expiry at T−30 min and T, plus a 1 min sweep | ms:2157 |
| extended Seller pricer | shared/pricing: goals, evaluator, apply. Writes through ListingPriceChangeService. A pool's price is stored once and its DJ listings reference it | Manual edits still pause it |
| extended Discovery | A discover tool on the recs stack py-mkt already has: taste vectors, event embeddings, the "More for you" blend | recs/ |
| new Live Activities | An ActivityKit widget target via expo-widgets. py-mkt sends to APNs directly | The target the bank-check activity already needs |
| extended Push, socket | dj-* notif types written in the executor's transaction. dj_plan_updated, dj_message frames | notif_policy.py, cvsocket/models.py |
| new Kill switch | dj_control rows read FOR SHARE inside every execution | pricing_control_repo.py:12 |
Every plan is one row with a state, a version and a revision. Pausing everything and the kill switch are not states. The API returns a computed effective_state, and the executor checks all three.
flowchart LR msg(["message"]) --> draft draft -->|"missing a field"| needs_info needs_info -->|"pick"| draft draft -->|"POST approval"| pending_approval pending_approval -->|"POST approve"| active active -->|"match"| executing executing -->|"lost race, partial"| active executing -->|"filled or sold"| done(["done"]) WAIT["waits on the user
needs_auth
needs_payment
blocked
paused"] executing -->|"bank check, decline"| WAIT active -->|"limit, minimum, pause"| WAIT WAIT -->|"fixed or approved"| active active -->|"stop"| stopped(["stopped"]) WAIT -->|"stop"| stopped active -->|"deadline"| expired(["expired"]) classDef live fill:__GOODSOFT__,stroke:__GOOD__,color:__INK__ classDef attn fill:__WARNSOFT__,stroke:__WARN__,color:__INK__ classDef pre fill:__INFOSOFT__,stroke:__INFO__,color:__INK__ classDef fin fill:__SURF2__,stroke:__LINE__,color:__INK__ class active,executing live class WAIT attn class draft,needs_info,pending_approval pre class done,stopped,expired,msg fin
| State | In when | Out when |
|---|---|---|
draft | A draft passes validation | Approval requested. Expires when carting closes |
needs_info | Event, type or a number is missing | The user picks |
pending_approval | POST /approval issues a challenge | Approved, or the challenge lapses after 10 min |
active | A verified consent | A match, a wait, a stop, the deadline. Plans can run until carting closes, past doors |
executing | Match found, preflight passes | Bought or sold, lost race, bank check, decline |
needs_auth | Stripe authentication_required, cart kept | User confirms, or the hold expires |
needs_payment | Hard decline, or the card expires before the deadline | A new card, approved |
blocked | The pricer is at NET_FLOOR, or a buy is at CrowdVolt's per-show ticket limit | A lower minimum approved, last call, or Change plan to what's left |
paused | User, a manual price edit, a dispute, ops | Only the same kind resumes it |
done, stopped, expired | Filled or sold, stopped by the user or ops, deadline passed | Final |
| More power: approve again | Less power: applies now |
|---|---|
| Raise or remove the max | Lower the max |
| Lower or remove the minimum | Raise the minimum |
| More tickets | Fewer tickets |
| Later deadline, new ticket type | Earlier deadline |
| A different card | Offer off |
| Show an offer, best offer at last call | Pause, stop |
The server classifies every edit, never the client. A more-power edit makes a pending version, and the plan keeps running on the approved terms until the new one is approved. A less-power edit bumps the version at once and posts a receipt. Edits carry base_revision, so two devices can't overwrite each other.
sequenceDiagram autonumber participant A as App participant API as /api/dj participant ST as Stripe participant DB as Postgres A->>API: POST approval API->>ST: SetupIntent, off_session API-->>A: challenge, terms, hashes Note over A: box starts unticked
button says Approve $160 A->>A: box ticked A->>ST: confirm, bank check over $250 A->>API: POST approve with challenge rect __MONEYRECT__ API->>DB: lock plan, check owner, challenge, hashes API->>ST: SetupIntent succeeded? API->>DB: insert dj_consent, plan active end API-->>A: Approved up to $160
client_request_id returns the first result. Approve is never optimistic in the app.Several buys share one sheet and several sells share another. A buy and a sell asked together get two sheets back to back. Each side has its own headline number, the most you'll pay or the least you'll earn, and one total can't show both. The agent opens a sheet only once every plan in it is drafted.
A sheet lists each plan with its own amount, above one box. The server writes one dj_consent per plan, and the plans on a sheet share one challenge. Removing a plan from the sheet drops it from the batch. One sheet per side is decided.
The hero case: Four Tet, Sat Oct 10. Max $80 each, all in. A $73 listing ($76 all in) appears Thu 11:48 PM. It beats the expected cost of waiting, so DJ buys it 3 seconds later.
sequenceDiagram autonumber participant S as Seller participant MS as market_service participant R as DJ stream participant M as Matcher participant X as Executor participant ST as Stripe participant B as Buyer app S->>MS: list 2 GA at $73 MS-->>R: after commit, event and ask R-->>M: pop M->>M: scan index, oldest first
beats waiting, preflight from cache M->>X: execute, 2 tickets X->>X: insert dj_execution X->>MS: calc_buy, cart_buy_order MS-->>X: cart held, $152 all in X->>X: $152 within 2 × $80 rect __MONEYRECT__ X->>ST: off-session charge, idempotency key ST-->>X: requires_capture X->>MS: buy_approved end X-->>B: dj-bought push, socket id alt bank check needed X-->>B: keep the cart, needs_auth else hard decline X->>MS: release the cart, needs_payment else lost the lock X->>M: next listing end
doors_close_time, past doors, because people keep buying after doors open.calc_buy filtered to one ask, cartbuy_approved, minus redundant retrievesMax all in to a list-price cap. Fees round up per ticket
def list_cap(M, cfg, buyer, linked):
def fee(p):
return 100 * calc_fee_with_qty(
cfg, price=p / 100, qty=1, is_buy=True,
user=buyer, linked_users=linked)
p = floor_dollar(M / (1 + pct(cfg, buyer)))
while p + fee(p) > M:
p -= 100
while p + 100 + fee(p + 100) <= M:
p += 100
return p
# stored per plan: cap_normal_cents, cap_surge_cents
# the column switches when resolve_fee_config does
# binding check: charge_now_amount <= q * M
Matcher, one consumer per event partition. A listing has to beat waiting
def on_signal(event, ask, now):
avail = ask.qty_open_to_cart
cap = cap_col(event)
for plan in eligible(event, ask.tt, ask.price, cap):
if not beats_waiting(plan, ask, now):
continue
q = fill_qty(plan, ask, avail)
if q == 0 or not preflight(plan, q):
continue
r = execute(plan, ask, q)
if r == LOST_RACE:
return
if r == BOUGHT:
avail -= q
if avail == 0:
return
def fill_qty(plan, ask, avail):
if ask.allow_partial:
q = min(plan.remaining, avail)
else:
q = avail if avail <= plan.remaining else 0
if ask.no_single and avail - q == 1:
q -= 1
if plan.together_only and q != plan.remaining:
return 0
return q
def beats_waiting(plan, ask, now):
rate, prices = last_24h(plan.event, plan.tt)
wait = expected_best(rate, prices, now, plan.deadline)
return ask.all_in <= min(plan.max_all_in, wait)
# wait rises toward the max as the deadline nears
| Trigger | Hook |
|---|---|
| New listing live, QR approval | ms:5974, ms:2331 |
| Price edit by a user or the pricer | ms:7885, ms:8031, price_change_service.py:36 |
| Cart released or expired | ms:7039, ms:1431 (new hook) |
| Plan approved, resumed, max raised | Match against the whole book once |
| Sweep | Lowest per ticket type vs the top cap, so a listing DJ waited on is bought once the rising bar reaches it. Every 60 s, and every 15 s from 6 h before doors |
Price alerts run inside the seller's transaction and swallow errors (price_alert_service.py:20). A Stripe call there would hold the seller's locks for seconds, so DJ always waits for the commit.
queue_at only moves when a version raises the max or the quantity. Public bids fill inside the seller's own calc_sell, so they win ties by construction.buy_approved is already idempotent on the intent.auth_complete. Doing it in that order avoids buy_approved cancelling intents that still need action (ms:3117).sus_buyer_service.py:32).The hero case: Jamie xx, Fri Oct 9. Listed at $110, 11th of 11. DJ moves it to $98, 1st of 11, which earns $93. The minimum is $80 earned, which means listing at $85.
sequenceDiagram autonumber participant O as Market participant Q as Pricing queue participant P as Pool pricer participant MS as market_service participant SL as Seller app O-->>Q: listing, edit or bid committed Q-->>P: the event's pool P->>P: lowest non-DJ price, best bid
match early, lead by $1 in the last 6 h
under 3 others, anchor on recent sales loop each DJ listing, oldest approval first alt offer within $1 or crossing, earns the minimum rect __MONEYRECT__ P->>MS: lock bid, re-check, calc_sell P->>MS: sell_approved end P-->>SL: Sold 2 at $98, you earn $186 else a move the budget allows P->>MS: new pool price through
ListingPriceChangeService P-->>SL: 1st of 11 at $98 else at the minimum P-->>SL: blocked end end Note over P,SL: last call, when carting closes: an offer at the minimum or above sells
below it asks the seller, no offer ends the plan
The seller wants to earn at least $80, which means listing at $85. The best offer is $78 and the lowest other listing is $90. Nothing can trade yet, because $78 earns $74.
Match early, lead by $1 late, anchor on sales when the book is thin
def sell_target(a, pool, now):
others = pool.non_dj_asks()
lo = others.lowest
if len(others) < 3:
tgt = median(pool.last_sales(20))
if lo:
tgt = min(tgt, lo)
elif hours_to_doors(now) <= 6:
tgt = lo - 100
else:
tgt = lo
return max(a.floor, tgt)
# hours_to_doors goes negative after doors,
# so DJ keeps leading until carting closes
The pool pass, oldest approval first. Offers within $1 or crossing sell
def pool_prices(pool, now):
b = best_bid(pool)
late = hours_after_doors(now) >= 3
for a in pool.dj_asks_by_queue_at():
if a.goal == DJ_HOLD:
tgt = hold_target(a, pool)
else:
tgt = sell_target(a, pool, now)
near = b and b.price >= min(a.price, tgt) - 100
if b and b.fits(a) and net(a, b) >= a.min_net \
and (near or late):
yield a, SellToBid(b)
b = next_bid(pool, b)
elif tgt != a.price and move_ok(a, tgt, now):
yield a, tgt
def hold_target(a, pool):
lo = pool.lowest_other or a.price
return max(a.floor, min(lo, a.price_24h_ago * 1.2))
Matching the lowest raises the price when cheaper listings sell and cuts it when a new one appears, so a listing can bounce up and down. Moves get a budget that loosens toward doors, and raises have to wait.
| Time to doors | Moves | Wait before a raise |
|---|---|---|
| More than 48 h | 2 per 2 h | 30 min |
| 12 to 48 h | 1 per 30 min, bursts up to 3 | 30 min |
| 3 to 12 h | 1 per 15 min, bursts up to 5 | 15 min |
| Last 3 h | 1 per 10 min, bursts up to 6 | 5 min |
A raise is at most 10% a move. The wait runs from when the cheaper competition disappears. If a cheaper listing appears during it, there's no raise, which is what stops the bouncing. A buyer who arrives during the wait pays the current price.
Cuts spend the budget. Raises also need the wait
def move_ok(a, new, now):
rate, burst, wait = budget(hours_to_doors(now))
if not a.moves.allows(rate, burst):
return False
if new <= a.price:
return True
return (new <= a.price * 1.10
and a.pool.cheaper_gone_for(now) >= wait)
"You earn at least" to a list price. Fee is 5% here, the seller rate in the last 7 h, rounded up per ticket
def list_floor(N, ctx, ask):
if ask.allow_partial:
qs = range(1, ask.qty_left + 1)
else:
qs = [ask.qty_left]
def net(p):
return min((p * q - 100 * seller_fee(ctx, p, q)) // q
for q in qs)
p = ceil_dollar((N + ctx.shipping) / (1 - seller_pct))
while net(p - 100) >= N:
p -= 100
while net(p) < N:
p += 100
return p
# earn $80 -> list $85, fee ceil(4.25) = $5
# list $98 -> earn $93, fee ceil(4.90) = $5
# 2 sold at $98 -> $186
| Where | Today | With DJ goals |
|---|---|---|
evaluator.py:30 | Holds with fewer than 2 other sellers | 0 others hold, 1 or more use the position rule |
evaluator.py:53 | Clamps between cheapest and dearest | Bypassed, so it can lead |
evaluator.py:80 | Skips moves under 2% | $1 step |
evaluator.py:103 | No raise without sales | Raises to the next competitor once the cheaper one has stayed gone for the wait |
evaluator.py:206 | Compares same-quantity listings only | Per-ticket price, any listing with 1 or more open |
evaluator.py:265 | 10% a move, 20% a day | Sell-by: the move budget above, raises at most 10% a move, cuts bounded by the floor. Hold: 20% a day |
service.py:261 | Re-evaluates every 5 to 60 min | Backstop. Primary is the signal queue |
price_apply_service.py:101 | Rejects no minimum, rejects crossing a bid | Allows no minimum, returns CROSSES_BID and sells to it |
types.py:6, migration | 3 goals, min_net_cents NOT NULL | Adds DJ_SELL_BY, DJ_HOLD, nullable minimum, dj_plan_uqid |
calc_sell(use_bid_uqid=B) by name. Without the name it fills the cheapest qualifying bid (ms:4259). This ships after the core pricer.doors_close_time, unless the plan's own deadline is earlier. The heads-up goes out 30 min before.calc_buy breaks ties by created_at (ms:1570). Add ask.queue_at and sort by price then queue_at, there and on the event page.recommend_limits is engine code. The agent calls it and puts the answer into words. It never picks a number itself.
What should my max be?
It's $94 now. Here's how often each price showed up before doors.
Four Tet · $94 lowest now
| Buyer max | Seller minimum | |
|---|---|---|
| Model | How low the price went, relative to the lowest at the time, on past events, by hours to doors, plan length and book depth. Calibrated by plan length so each option holds up, refit monthly | Where each price would sit in line, and what it earns there |
| Options | Three prices with two-word hints (most nights, half the nights, 1 in 3 nights), plus buy now. Sometimes nothing cheaper ever appears, so buy now is always one of them | The price that puts them first in line, and what they'd earn at it |
| DJ demand | The suggested discount shrinks as DJ's share of waiting demand grows. The odds count DJ plans already waiting at or above a price, since those get listings first | Any lift above the seller's own price is still open (below) |
| Guard | Never above the lowest all in. Past that, the answer is "Buy now at $94" | Never under what they paid on CrowdVolt without saying so |
| Anchors | Their alert, their offer, what they last paid for this artist | Their list price, what they paid. Wallet purchase prices are unknown |
The odds stay inside. Each option is a fact with a frame: how often shows like this got that low before doors. DJ never says a price will drop and never calls one a good deal. Value works the same way. "Bought at $76, $18 under the lowest when you asked" is true. "Saved $18" isn't, because the price might have dropped anyway.
Revenue is per trade. On the $73 Four Tet ticket that's a $3 buyer fee and a $3 to $4 seller fee, about $7. A few dollars on price moves fees by cents, and rounding often makes it zero. A trade that doesn't happen loses all $7. So the recommender aims for trades that happen, which is what the user wants too.
Nudging bidders into the spread. DJ buyers meeting DJ sellers. Limits that clear instead of wishful ones. A recommender that counts DJ demand, so buyers following it together don't stall the market. Selling inventory before carting closes, since an unsold ticket earns nothing. No upward bias and no fake urgency.
People ask "is this a good price", "where should I list", "will it drop". Answers should help a trade happen without teaching everyone to wait out the market, handing competitors CrowdVolt's data, or exposing anyone's limits. The tools enforce it. None returns per-sale rows or anyone's limits, so no card can show them.
| Question | DJ answers with | Never with |
|---|---|---|
| "Is $95 a good price?" | The lowest now, where the lowest has been each recent day, and a plan's odds as facts tied to a Watch chip | A Low, Typical or High verdict, a sale range, "it'll probably drop" |
| "Where should I list?" | The price for first in line, and what you'd earn at it | Other sellers' minimums or DJ's pool strategy |
| "Will it drop?" | Their plan's odds at 2 or 3 maxes, as facts | Event-wide forecasts |
| "How many want this?" | "Lots of buyers are watching", with a rounded count | Their maxes, or how many are DJ plans |
GPT-6 Luna on the Responses API, built on the ops_copilot loop. It can look things up, answer questions, find events and handle several asks in one turn. The safety line isn't "no tools". It's that no tool can approve, charge, list or raise a limit.
flowchart LR tap["Chip or picker tap"] -->|"no model"| h["Handler
instant"] text["Typed text"] --> look["Event lookup
market attached"] look --> m["GPT-6 Luna
cached prefix + thread"] m <-->|"parallel"| read[("Read tools
market, plans, quotes,
limits, discover")] m -->|"draft, edit"| draft["Draft tools
cards, no money"] m -->|"pause, lower"| safe["Safe actions
receipt in the thread"] m -->|"needs approval"| ask["Ask the user
sheet, picker, confirm"] m --> g{"Every number
from a tool?"} g -->|"no, retry once"| m g -->|"yes"| out["Stream the reply
designed card first"] h --> out ask -.->|"tick"| eng["Engine
moves money"] classDef nw fill:__NEWSOFT__,stroke:__NEW__,color:__INK__ classDef bad fill:__BADSOFT__,stroke:__BAD__,color:__INK__ classDef ex fill:__SURF2__,stroke:__LINE__,color:__INK__ classDef good fill:__GOODSOFT__,stroke:__GOOD__,color:__INK__ classDef money fill:__WARNSOFT__,stroke:__WARN__,color:__INK__ class tap,text ex class h,look,draft,safe,g,out nw class m bad class read ex class ask good class eng money
| Kind | Tools | Rule |
|---|---|---|
| read | search_events, discover, get_market, get_my_plans, get_my_listings, get_my_orders, get_my_alerts, get_wallet_tickets, quote, recommend_limits, help | Scoped to the user on the server. Run in parallel. Never return text other users wrote, per-sale rows or anyone's limits. Short aliases instead of real ids. Wallet only after opt-in |
| draft | draft_plan, edit_draft, create_price_alert | No money. Several a turn. Each renders a card. A sell draft must name the caller's own live listing |
| show | show_template, show_card | A designed card first. Composing from the kit only when none fits. The server validates every spec |
| safe action | pause_plan, lower_limit | Only ever reduces power. Leaves a receipt |
| ask the user | request_approval, show_picker, confirm | Renders a component. The user's tap does the work. Stop and resume go through confirm |
| doesn't exist | approve, raise a limit, buy, list, sell to an offer, change the card | Only the approval sheet does these |
"Get me 2 for Four Tet under 80 and sell my Jamie xx by Friday" makes two drafts. Questions around them ("what if it doesn't drop?", "what's the fee?") get answers from tools while the drafts wait. DJ drafts whatever is ready and asks about the rest in the same turn. The buy and the sell get a sheet each, back to back (above).
A simple ask is one model call. Prompt caching: about 5k tokens of system prompt, tool schemas and glossary go first, the same for everyone. User state comes from tools, never the prefix. prompt_cache_key per user, store: false. First token under 1 s at p95, a typical turn under 3 s. It streams over the authed response, never the socket.
The model never writes digits. It writes references like {m1.lowest_all_in} that the server fills from tool results, and the server does the arithmetic. Draft limits must trace to the user's words, a chip or recommend_limits. Deploy gates over 500+ cases: the right event first try at least 95% of the time, the right tool and arguments at least 97%, median rounds 2 or fewer, no ungrounded numbers, every injection case handled. If it would need a money tool, it asks for approval instead, every time. When the model is down, chips, pickers and sheets still work.
Answer small, reveal on tap. The message gives the answer with enough detail to make the decision. The card holds only what you act on: one row with the price and a short note, and one or two buttons. History and options open inside the card on tap. Every common question has a designed card; the agent composes from the same kit only when none fits.
| Tier | What | Who decides the layout |
|---|---|---|
| fixed | The plan card, approval sheet, edit sheets, receipts, the bar, Live Activities, home screen widgets | Nobody. They render from the plan row because they're the money record. The agent can only cause them, by drafting |
| designed | Price check, watch options, compare, fee, place in line, demand, why it moved, events, help | A designer. The agent picks one with show_template and feeds it references |
| composed | The long tail of questions | The agent arranges kit blocks in a small spec with show_card |
| never | Model-written markup or free layout | Not allowed |
recommend_limits option, open the edit sheet, open an event. None of them moves money.A suggestion is DJ starting the conversation: a plan the user didn't ask for, built from something they did on purpose. "Four Tet dropped to $78. It's in your Likes. Want me to get 2 under $80?". Reasons come only from Likes, alerts, orders and offers, never views or searches, and never with a date.
| Message | Examples | Push | Capped |
|---|---|---|---|
| asked for | Bought, sold, needs you, price alert hit, last call | Always | No |
| fills now | An expired offer would fill right now. An alert lost the race and a new listing is at the alert price | Yes | Yes |
| other ideas | A liked event moved. An artist is rebooked | No, thread plus a dot on the DJ bar | Yes |
| Trigger | Signal | Runs | Weight |
|---|---|---|---|
| Alert lost the race | The alert's listing sold to someone else within 60 min and the user didn't buy. Needs price_alert.trigger_ask_uqid | Stream, on buy_approved | 1.0 |
| Expired offer would fill | An expired bid, then a listing at or under it within 7 days | Stream, through the matcher | 0.9 |
| Liked event moved | In Likes, lowest moved 10% or more in 24 h, between 48 h out and doors | Every 30 min | 0.6 |
| Artist rebooked | A performer from a past order announces a date in the user's city | On publish | 0.5 |
MarketingFrequencyService.weight × fillable × 2^(−age_h / 12). The suggested price is always the user's own number (their alert or offer) or the current lowest.OfferProvider, so a suggestion or plan holds back the generic offer-rescue and new-supply sends for 72 h.DJ can also find events. "Something melodic tonight under $60", "what's good this weekend", "I liked Four Tet, what else". It rides on the recs stack py-mkt already has. Buying and selling mostly move existing demand around, so discovery is where new demand comes from, in nights people wouldn't have found otherwise.
discover(when, vibe, scenes, budget_each, qty, near). when is tonight, this weekend or a date range, vibe is the person's own words, and near defaults to their metro. It returns 3 to 5 events, each with the lowest all in, a rounded count listed, and at most 2 reasons built from things the person did on purpose: Likes, past orders, alerts, artists they bought.
Built directly on events. Hard filters first: the metro, the window, not cancelled, a live listing. Then a score: taste match from a fresh user vector against the event embedding, demand, vibe match, and a bonus when the lowest all in fits the budget. That extends the "More for you" blend, the personalization that has already proven itself. Then variety, so the picks aren't five nights of one headliner.
At most one clarifying question, and only when "when" is missing. Then picks, each with a next step: watch under a max, buy now, or like. The model never invents lineups, set times or facts. It only phrases what discover and the event data return. When a plan's event is sold out or out of budget, DJ offers alternatives that same night.
discover as a real past buyer, at a time before their purchase, and checks whether the event they bought lands in the top 5.All tables live in tktx.*, with prefixed uqid keys, money in *_cents, and _history tables. Append-only tables reject UPDATE and DELETE with a trigger.
erDiagram
dj_plan ||--o{ dj_plan_version : "versions"
dj_plan ||--o{ dj_consent : "approved by"
dj_plan ||--o{ dj_execution : "attempts"
dj_plan ||--o{ dj_buy_scope : "match rows"
dj_plan ||--o{ dj_event : "audit log"
dj_plan ||--o{ dj_thread_message : "card, receipts"
dj_plan |o--o| pricing_policy : "sell plans"
dj_plan ||--o{ dj_live_activity : "deadlines"
dj_suggestion |o--o| dj_plan : "became"
dj_plan {
text uqid PK
text kind "auto_buy, price_alert, sell_deadline, sell_hold"
text state
int max_all_in_cents
int min_net_cents
timestamptz deadline_at
text pinned_pm
int version
int revision
timestamptz queue_at
}
dj_consent {
text uqid PK
text terms_sha256
text rendered_text_sha256
int cap_cents
text challenge UK
text client_request_id UK
timestamptz revoked_at
}
dj_execution {
text uqid PK
text idempotency_key UK "dj:plan:ask:attempt"
text kind "buy, sell_to_bid"
text state
text payment_intent
timestamptz hold_expires_at
}
dj_buy_scope {
text event
text ticket_type
int cap_normal_cents
int cap_surge_cents
timestamptz queue_at
}
| Table | Holds | Guarantee |
|---|---|---|
dj_plan | Side, kind, state, event, types, limits, deadline, pinned card and delivery platform, spent and earned | One live buy plan per user, event and type. One live sell plan per listing |
dj_plan_version | Terms and their hash, pending or current | One pending version per plan |
dj_consent | Text version, rendered text and hash, cap or minimum, card, SetupIntent, IP, device | Immutable. Box must be ticked. Unique request id |
dj_execution | Each attempt: ask or bid, cart, intent, failure codes | Unique key. One in flight per plan |
dj_event | Everything that happened, with the actor: user, engine, ops, system, llm | Append-only |
dj_thread_message | Typed rows: text, plan card, receipt, picker, bank check, suggestion. text_key and frozen params | One plan card per plan. Only resolved or redacted can change |
dj_pool | One price per event and connected ticket types. DJ listings reference it | Stored once, so a move doesn't rewrite every listing |
dj_live_activity | User, plan, kind, push token, start, end and why it ended | One DJ activity at a time. Starting one ends any other |
dj_settings, dj_suggestion, dj_control, dj_llm_turn | Bar mode, pause, opt-ins. Suggestions and mutes. Kill switch rows. Model calls | The global control row ships off |
Every lookup is scoped WHERE uqid = ? AND user_uqid = ?. A mismatch is a 404. Behind the backend flag dj-agent.
| Route | Does |
|---|---|
POST /api/dj/messages | Text, a pick or a reply to a plan, with client_message_id. Streams the reply over the authed response, and cards arrive as their tools finish. Picks and chips answer without the model. 20 per 5 min |
GET /api/dj/thread, /since | Paged thread, catch-up after a reconnect |
GET /api/dj/plans, /bar | The DJ page (running, drafts, past) and the bar's pages: needs you, then news, then soonest |
POST /plans/id/edit | Changes against base_revision. Says whether it needs approval. 409 on conflict |
POST /plans/id/approval, /approve | Challenge, then verified approve (above). The plans on one sheet share one challenge |
POST /plans/id/pause, /resume, /stop | Each posts a receipt. Resuming an ops pause is a 409 |
POST /pause_all, /resume_all, /settings | Turning on wallet ideas writes a consent |
POST /executions/id/auth, /auth_complete | Bank check resume |
POST /plans/id/sell_to_offer | Selling under the minimum with the seller present, its own consent kind |
dj_plan_updated with id and revision, dj_message with a sequence number. Frames never carry data, so the app refetches over authed REST. Price ticks coalesce to one per plan every 3 s.
dj-bought, dj-sold, dj-last-call are transactional. dj-needs-auth expires with the hold. dj-needs-payment, dj-needs-you, dj-price-hit, dj-found. dj-suggestion needs opt-in. When DJ made the sale, the generic bought and sold pushes are suppressed.
cv://dj/plan/id, cv://dj/auth/execution. Live Activities need crowdvolt:// because cv:// is only parsed in JS. The plan cache is shared by the card, bar and page. Typed messages use the support chat outbox. Approve and more-power edits are never optimistic.
A plan can run for days, and a Live Activity is capped at 8 h and meant for something with a clear end. So the Dynamic Island shows DJ in four moments, one activity at a time, most urgent first.
| Moment | Starts | Shows |
|---|---|---|
| 1 Bank check | needs_auth | The cart-hold countdown, 5 min, or 3 near doors |
| 2 Seller last call | 60 min before carting closes | The place in line and what happens at last call |
| 3 Buyer last hours | 6 h before doors, only if the lowest is within 20% of the max. With no real chance, it would just be noise | The lowest against the max |
| 4 Result | Bought or sold | A final update with an alert, then a short dismissal |
crowdvolt:// deep links into the edit sheet or checkout. No App Intents touch money.expo-widgets. Countdowns use a system timer, so they tick without pushes.A kill switch, per-person and per-event spending limits, price-impact pauses and circuit breakers guard every automated buy and sale. The exact thresholds stay in the repository.
Backend issues found while mapping DJ are tracked in the repository.
One team, about 10 weeks to beta and 12 through it. Each phase has an exit test, and nothing charges a card until shadow mode passes.
| Phase | Scope | Done when |
|---|---|---|
| 0 Foundations | The blocking fixes, dj_* tables, dj_control off, flags | Migrations live and dark, fixes tested |
| 1 Thread and agent | Routes, thread model, the agent loop with read, draft and safe tools, the event lookup, aliases, designed cards and the kit, streaming, evals, plan card, pickers. No approval | Eval gates met, staff chat, ask and draft |
| 2 Buy | Approve with SetupIntent, one sheet per side, the signal coalesced per listing, matcher with smart waiting, parallel executors, reconciler, bank check resume, pushes, socket, bar. Shadow, then staff only | p95 under 5 s in shadow, zero duplicate charges, staff purchases reconcile |
| 3 Sell | Pricer goals, pooling on one stored price, match early and lead late with the demand anchor, the move budget, earn to list price, last call at doors_close_time, listing sheet entry, DICE prefill. Selling to offers once the core pricer works. Shadow replay against real seller edits | Beats manual reprices on sell-through without lowering price realized |
| 4 Beta | 5% of users with alerts or listings, breakers on, dashboards | Two weeks without hitting a kill criterion |
| 5 Suggestions | Triggers, settings, wallet opt-in, Live Activities for deadlines, Android fallback | Accept rate above a no-suggestion holdout |
Settled since the first version: selling to an offer within $1 of DJ's price (Sep 28), no hold-off on new listings until DJ holds about a quarter of an event's waiting demand (Sep 28), one sheet per side (Sep 29), a visible DJ offer charged off-session when a seller accepts it, with no hold (Sep 29), pricing above the seller's own price only as a lift shown on the approval, never after it (Sep 29), and what happens at doors: bids are cancelled when doors open, so the seller chooses between keeping the listing up until last call (the default) or taking the best offer just before doors (Sep 29).