The desk

The personal instrument panel, and the interpreter — what grounds it and what it will not do.

The landing page answers what is this. The desk answers I'm here, what do I do.

Route /desk.


What a holder actually has

Not a prediction. An edge on venue and timing:

  • Which pool is cheapest and which is a trap, right now, per token.
  • When the reference is frozen and the pools are untethered — every weekend, every holiday, roughly 136 of the week's 168 hours.
  • When a corporate action is about to pause a token's oracle while its pools keep trading.
  • Once history exists: how fast gaps close, and which ones don't.

Every one of those is measurable, timestamped and checkable. None of them is advice. The desk is a personal instrument panel over those four things.


The interpreter

It explains readings. It does not, and will never, recommend a trade.

It sits open, and says what it is

It first shipped inside an expanded watchlist row, which meant the most interesting thing on the desk was invisible until a visitor happened to click a token — and once found, nothing on screen said what it was for or what it would refuse to do.

It now renders on load, below the table, with its own token picker over the watched list. Clicking a row still aims it at that token, so the click does something useful without being the only way in. The panel states the contract in the open: plain English, never a recommendation, no text box on purpose, every sentence naming the field it came from. Before a first question the answer area says what will land there rather than sitting blank, which reads as broken.

One bug this surfaced. The daily-usage counter read localStorage during render. That was invisible while the panel only mounted after a click; rendering on load made it a hydration mismatch — 0/12 from the server against 1/12 in the browser. It now starts at zero and adopts the stored count in an effect, the same pattern Watchlist uses for the list itself.

That is not caution, it is the only version that can exist here. basis's whole claim is that every number can be checked without trusting us. A model saying "AMC looks cheap" would be advice, and it would be invented — the model knows nothing the reading does not already contain. Bolting an oracle onto a verifiable instrument makes the instrument unverifiable.

So the job is inverted: plain language for someone who does not read basis points.

Why is AMC flagged WATCH? → "The deepest pool sits 114 basis points above the reference — just over one percent. Three of its seven pools hold under $10,000, so most of the spread you can see is dust rather than tradeable price. The $85k USDG pool is the one a router would actually reach."

The grounding is mechanical, not hoped-for

MechanismEffect
The only input is the reading objectNo web, no history, no other tokens. A fact not in the payload has no route into the answer.
Structured output (response_format with a strict JSON schema)Every factor must name the dotted field it came from — reference.price, spreadBps, pool.liquidityUsd.
Server-side field resolutionverifyFactors() drops any claim whose field does not resolve against the reading that was served. The count of dropped claims is shown to the reader.
Questions are chips, not free text"Should I buy" is not expressible. The API rejects any chip outside the fixed set with a 400 before a model call happens.
The payload names roles, not just valuesA field called plainly pool invited the model to attribute the basis to the wrong one. Structure that cannot be misread beats prompt text asking it not to.

The last one is the important one. The refusal is structural rather than a rule the model has to remember — verified: POST /api/v1/interpret {"chip": "should i buy this"} returns 400 unknown_question.

The question set

flag · router · weekend · multiplier · spread — defined in src/lib/interpret.ts.

Inference configuration

One chat completion per question: no tools, no loop, nothing to wander off into. A plain fetch rather than an SDK — it is a single POST carrying a strict JSON schema the answer has to satisfy.

Where inference runs is not load-bearing and is not compiled in. Endpoint, model and key are environment configuration (INTERPRETER_ENDPOINT, INTERPRETER_MODEL, INTERPRETER_API_KEY). What makes an answer trustworthy is in this repository and is not provider-specific: the frozen system prompt, the output schema, and verifyFactors() dropping any claim it cannot trace to a field of the reading that was served.

Low reasoning effort is not a cost compromise — it measured better. On the same question:

ConfigurationCompletion tokensFactorsMulti-field cites
Default effort, no form rules2,530 (1,856 reasoning)90
Default effort + form rules2,46140
Low effort + form rules46440

Roughly a fifth of the tokens and tighter output — four focused claims instead of nine sprawling ones.

max_tokens is 4,000. A model that reasons before answering spends completion tokens on both, so the ceiling has to cover the thinking as well as the answer; a finish_reason of length returns 502 truncated rather than surfacing a JSON parse error.

The response is the reading, not the machinery. An answer carries the summary, the factors with their fields, the caveat and the timestamp. It does not report which model produced it or what it cost — that is operational detail, and publishing it would invite readers to weigh the provider rather than the grounding, which is the part that can actually be checked.

Free tier: 12 questions a day, counted in localStorage. Not a security boundary — it is a courtesy limit on an unauthenticated endpoint, and it is why per-IP rate limiting belongs on the route before this is public.

Two things that had to be fixed after live testing

Multi-field citations. The model occasionally packed several paths into one field ("pool.price, reference.price"), and the verifier dropped otherwise-good claims. It now splits on commas and keeps the factor when every path resolves. The prompt also asks for exactly one path per factor, which removed the behaviour entirely in testing.

A mislabelled relationship. The model described basisBps as measuring the cheapest pool. The numbers were right and the relationship was wrong — the more damaging kind of error, because it reads as authoritative. The cause was structural: the payload called the field plainly pool, and the ladder beneath it happened to list cheapest first. Fixed by naming the role in the data (role: "deepest — this is the pool basisBps is measured against") and putting deepest first in the ladder. Making the structure unconfusable beat adding another sentence to the prompt.

Without a key

The interpreter's configuration is optional. Unset, the panel says it is not running and the rest of the desk is unaffected. The endpoint returns 503 interpreter_not_configured — after validating the request, so a caller with a bad symbol still gets 400 unknown_symbol rather than a misleading answer about credentials.

Put the real key in .env.local, which is gitignored. Never in .env.example.


Layout — measured, then fixed

The first build gave the desk a hero: a title, two paragraphs, then the data. Measured at 1440x900 it read as a document rather than an instrument.

BeforeAfter
First number on screen556px down212px
Page height, nothing open3,822px (4.2 screens)1,504px (1.67)
Opening one row adds+998px+214px
Watchlist columns95 (4 below 640px)
Calendar rows165, with a count for the rest

Four changes did it.

No hero. A landing page earns one; a desk does not. / already answers what is this. The desk opens on a StatusLine — one row, worst-flag-wins, reading either All 3 tracking the reference or 1 of 3 drifting — AMC, with the live session beside it. That single line is what most visits are for.

PoolStrip replaces PriceLadder on expansion. The ladder is one row per pool: 31 rows and 982px for NVDA. Nobody reads thirty-one rows — it is a wall, not a chart. The question is are the pools clustered or scattered, and where does the reference sit in that, which is one axis. The strip is ~46px: a tick per pool positioned by price and sized by log depth, the deepest one doubled in width, the reference as the single warm mark, and a footer giving cheapest, count, deepest and dearest. PriceLadder is still there behind [ list all N pools ] — the detail is not deleted, it is just no longer the default.

Five columns, not nine. Token, real price, pool price, gap, state. Depth, spread and pool count moved into the expansion, where someone has already asked for detail. Below 640px the pool price drops too — it is recoverable from the reference and the gap, and losing it is better than a sideways scroll that hides the gap and the flag.

The calendar shows five. Sixteen scheduled actions in a fortnight is real, but rows six through sixteen are a fortnight away. Five, then [ 11 more in the window ]. The imminence badge moved from every row to the header once (6 PAUSING IN 48H) — when all five visible rows carry the same red badge it distinguishes nothing; rows now carry a small warm tick instead.

The session strip and the calendar sit below the working area in one two-column row, and the three unbuilt features are a single sentence rather than three cards.


Sections

#SectionStateTier
1Watchlist — your tokens, live; click a row for its ladder and an explanationlivefree (12 in localStorage) · holder: synced, unlimited
2Interpreter — open on load, own token picker, each factor linked to its fieldlive (needs a key)free: 12/day · holder: uncapped
3The clock — session state, this week's strip, frozen banner when closedlivefree
4Action calendar — 14-day window, imminent ones marked "oracle will pause"livefree
5Alertsneeds a database and a schedulerholder
6Weekend boardneeds persistencefree to view
7Historyneeds persistencefree summary · holder export

Sections 5–7 are rendered on the page as stated placeholders with their gate named, in the same voice as docs/roadmap.md. Nothing is implied to exist that does not.

Alerts are the real holder value. Nobody watches a dashboard on a Saturday — which is exactly when the reference is frozen and the gaps open. A rule that fires only when the market is closed and a token drifts past a threshold is the product's thesis delivered to a phone. That is worth holding a token for in a way a faster feed never was.


The holder tier

Deferred until there is persistence, because without a database there is nothing to sync and the free tier already delivers the desk.

When it lands, the gate is identity, never a transaction:

  1. A wallet signs a message (SIWE-style). This proves control of an address.
  2. The server reads balanceOf(address) on chain 4663 over RPC — a read.
  3. Balance at or above the threshold → a signed cookie carrying tier: holder.

Nothing is custodied and nothing moves, consistent with hard rule 1 in docs/engineering.md. Until $BASIS exists the gate reads BASIS_TOKEN_ADDRESS from the environment and can be exercised against any ERC-20 on the chain.


Files

PathRole
src/lib/interpret.tsFrozen system prompt, output schema, chip set, context builder, field verifier
src/app/api/v1/interpret/route.tsValidate → check config → build context → one call → verify factors
src/components/desk/Interpreter.tsxChips, answer rendering, per-field attribution, daily cap
src/components/desk/Watchlist.tsxlocalStorage list, five columns, expandable rows
src/components/desk/StatusLine.tsxThe glance answer — worst flag wins, plus the live session
src/components/desk/PoolStrip.tsxPool distribution on one axis; the default expansion view
src/components/data/PriceLadder.tsxThe full per-pool ladder, behind a toggle
src/components/desk/ActionCalendar.tsx14-day window with imminence marking
src/app/desk/page.tsxComposition; server component