Lenovo and Amazon order emails, matched to their PSA purchasing tickets and kept up to date, with every change approved by a buyer first, an order ledger of what each email said, and Ask, a chat panel that answers purchasing questions from both. Built for Jennifer and Joe (procurement); Robbie and Adam also have access. JSM stays the source of truth.
Operational state, setup and deploy commands: HANDOFF.md.
procurement@hillspire.com; Amazon emails the
buyer who ordered, and a Google Workspace rule copies Jennifer’s and Joe’s Amazon
order mail there (HANDOFF). The order appears under Needs you. The buyer links
it to the PSA ticket once (open tickets are suggested, unprefixed ones and those
already naming the order’s vendor first). On approval the desk:
Lenovo (K…) or Amazon (114-…) to the title in the buyer’s own style
(, , ; or &, or inside an existing group, with or without a colon after
it); the title is editable in the preview;customfield_10883) and Total Items Ordered (customfield_10884); a ticket
often holds several vendors’ orders, so it never overwrites;ORDER CONFIRMATION UPDATE: Lenovo (K…)
with Order Date, Entity, Item and Tracking/eta (Lenovo’s “Get it by” or
Amazon’s arrival date, editable);Tracking/eta: Shipped (or Amazon’s date), and the “Ordered” transition, In
Progress to Shipped. A second shipment of a split order only comments. Lenovo’s
link is its own tracking page. Amazon’s email names no carrier, so the preview
takes the carrier’s tracking number from Amazon’s tracking page (optional) and
posts it the buyers’ way, USPS_9361… to the carrier’s page (UPS, USPS, FedEx;
Amazon Logistics links Amazon’s tracker); left empty, it links Amazon’s tracker
as Amazon_114-….DELIVERED <DAY>, M/D, then
“Confirm Delivery”, Shipped to Closed. If the title tracks other vendors’
orders, the preview lists them first. Nothing closes on its own.Prices. The confirmation email’s total is what the desk adds. Amazon’s shipped and delivered emails print none, so an order whose confirmation never reached procurement@ shows the ticket’s own Total Order Cost (“From the ticket”) or “Needs a price”, and its shipping or delivered preview offers an optional Order total. With one, the same approval adds it to Total Order Cost; it is asked only once and never goes in the comment.
If the buyer types the order number into the title herself, the desk matches it and treats step 1 as done by hand: it adds no totals and posts no confirmation.
Comments are public replies, as the buyers’ own updates are, so the requester gets them. Nothing is screenshotted; the comment carries the link and details.
Today. The top of Orders says what the day holds: orders to review, orders arriving today, orders running late (the vendor’s date passed, shipped or not) and the open request longest without an update (7+ days). Each item opens the order, or jumps to its section. Every card shows Ordered → Shipped → Delivered with each step’s date, the last leg filling toward the vendor’s date, a countdown (“in 3 days”, “Arriving today”) and a red late badge once the date passes. Late orders sort first. Display only: nothing here writes to Jira or changes when an order comes back to Needs you.
Requests lists every open PSA purchasing request (issue type 10824), with search, status and assignee filters and a sort. Clicking a request or an order opens a side panel with its details and a timeline: for a request, Jira’s status and assignee changes, its latest comments (as short excerpts, replies and internal notes told apart) and the desk’s own approvals; for an order, its vendor emails and each approval, with the next step on top. Approvals happen in the order’s panel.
purchasing-desk (who approved, when, the comment ID). A title that
already names the order counts as done by hand. Retries never double-count; a
comment that failed is offered again on its own.lenovomail@lenovo.com; Amazon’s
auto-confirm@, shipment-tracking@ and order-update@amazon.com, each only for
its own kind (ordered, shipped, delivered). DKIM and DMARC must pass in Google’s
own Authentication-Results. An unrecognised layout is refused, and the buyer is
told to update that ticket by hand. Names, addresses and PO numbers are not read./api/requests/<key>/activity: the issue’s changelog and comments) takes only a
key matching PSA-<n> and checks the ticket the same way.What each vendor email said, for the questions Jira can’t answer: items, part numbers, prices, and each vendor’s own total on a ticket that holds several orders. JSM stays the record of the ticket.
desk.ledger line: vendor, kind, order number and date, when the email was sent,
each item’s description, part number, quantity and amount (bundled parts marked),
the email’s total and its parts, and whether they add up. A test pins the fields:
never names, addresses, PO numbers, Lenovo account IDs or tracking links.purchasing-desk-ledger copies these lines into BigQuery
hillspire-procurement.purchasing_desk. The runtime gains no permission: it only
logs. ledger_bigquery.sql defines the views: emails, one row per email (an email
is logged again after each cold start; the mailbox’s copy wins over a dropped-in
one, whose headers a buyer wrote); orders, one row per order from its latest
confirmation, where orders and spend are counted; and items, one row per item
from each order’s confirmation in orders..eml is whatever the buyer
dropped, body included. For one order, the mailbox’s confirmation always wins, even
over a newer drop-in. An order known only from a drop-in (its confirmation never
reached procurement@, like PSA-2643’s) is counted, with source = 'upload', and
every report or answer built on the ledger says how many orders and dollars rest on
drop-ins alone. Same trust as the rest of the desk: each buyer can edit the ticket
anyway.orders and items views.A chat panel (the Ask tab) for “how many Lenovo laptops did we order this
month?”, “what have we spent with Amazon since October?” or “what have we paid per
X1 Carbon?”. Claude, the same backbone as the other agents (claude-opus-5-5, one
setting), with a fixed set of read-only tools:
| Tool | Reads | Answers |
|---|---|---|
purchase_requests |
PSA, requests by day | Counts, Total Order Cost, items, by status, entity, vendor, month |
vendor_orders |
Ledger orders |
Orders and the vendors’ own totals, by vendor and month |
ordered_items |
Ledger items |
Quantities and amounts per item, unit price per order by date |
jira-client.mjs,
ledger-reader.mjs), BigQuery gets only named parameters, and a test fails if a
tool takes a query, path or URL.purchasing-desk-runtime@ (dataset READER, project jobUser).desk.ask with who asked, the tools and
their filters, rounds, time and tokens; never the question or the answer./ or Ctrl+K opens Ask from anywhere on the desk. The answer is never HTML: the
page builds every piece as an element.reportFor in ask.mjs, specFromAsk in
report-engine.mjs). Names match whatever their case, as Ask’s do. Open requests
looked up from 2015 start at PSA’s first request (21 January 2026), losing
nothing; any other range Reports can’t show the same way gets no link. The model
is told Reports exists, so it writes the headline, not every row.purchase_requests returns spend with and without them, its breakdowns by
entity, tax status, vendor and month leave them out (by_status still lists
them), and the model uses those for spend, as Reports does.server/entities.mjs with the names PSA uses for each (an alias only when the
sheet backs it, by name or by one of the entity’s email domains). Entities the
sheet doesn’t list (Aperium, Pricasa, 11th Hour Project, EAP, OpCom Management,
Other, as of 7 November) stay Not classified until the sheet says otherwise; a
change to the sheet is a PR to that file.PROCUREMENT_ANTHROPIC_API_KEY is set; the tab is hidden then.
node scripts/try-ask.mjs "question" asks against real PSA and the ledger from a
laptop, with secrets read through your own gcloud.The Reports tab builds a report from PSA or the ledger, for spend by entity, for-profit against non-profit, vendors, buyers’ open work, days to close, and what was bought at what price.
POST /api/report: a source and two days, nothing else),
up to two years; PSA reads one search per calendar month, the ledger runs Ask’s
fixed SELECTs, and ledger orders take their entity from the PSA ticket that names
them. A whole year of PSA (776 requests) reads in about two seconds.src/report-engine.mjs, no imports, so
the tests pin it): filters (tax status, entity, vendor, open or closed, status,
buyer, requester, an amount range, search terms separated by commas), a grouping
and an optional second one across it (a cross-tab), the measure (Total Order Cost,
requests, items, average, median days to close; order totals; quantities and unit
prices), and the comparison with the period before, like for like (a month with
the month before, a month to date with the same days of the last one, a year to
date with the same days last year; a whole month or quarter with the whole one
before). Groups by month, week or quarter are compared only as a whole, in the
headline figures, since their dates differ between the periods. Changing any of
it never goes back to the server; a read is kept five minutes. While a new range
loads the last report stays up, dimmed, with its exports off; a failed read takes
it down rather than show it under the new dates.requestState).#reports?source=…), so “Copy
link” hands someone the same report; “Save” keeps it in this browser; “Print”
(or the browser’s own print) prints the report without the controls, every group
and record included; each table exports as CSV (cells that
would run as spreadsheet formulas are defused).desk.report with who asked, the source, the
range, how many rows and how long; never the rows.node scripts/try-report.mjs requests 2026-10-01 2026-10-31 reads real PSA or the
ledger from a laptop and prints the headline figures and the tax split, with the
Jira token read through your own gcloud.Apple’s emails will drive Apple orders; Apple Business Manager adds each device’s
serial number (ROADMAP goal 5). server/abm-client.mjs reads ABM’s device list and
nothing else. Nothing calls it yet.
iss and sub the client ID, aud https://account.apple.com/auth/oauth2/v2/token,
five minutes here), exchanged at https://account.apple.com/auth/oauth2/token for a
one-hour business.api token.https://api-business.apple.com/v1/orgDevices, and a test fails the build
otherwise. A next page takes only Apple’s cursor: the filters and fields stay the
request’s own.scripts/abm-preflight.mjs (local only) checks the key and ABM’s order numbers:
HANDOFF, “Apple”.schmidtentities.okta.com): an OIDC web app, login initiated by the
app only, authorization code with PKCE. Okta assignment decides who can sign in;
the app also checks PROCUREMENT_ALLOWED_EMAILS on every request, so removing an
address ends that person’s session. Sessions are signed __Host- cookies
(HttpOnly, Secure, SameSite=Lax), 10 hours.X-Purchasing-Desk header and, for POSTs, the desk’s own Origin.purchasing-desk-mail@, scope gmail.readonly, subject
fixed in code to procurement@hillspire.com. The query is fixed to each
vendor’s senders (Amazon’s by subject too), mail since each went live (Lenovo 20
October, Amazon 29 October), last 90 days, the newest 500 messages..eml instead (Gmail: ⋮ › Download
message). The same parsers reject anything that isn’t a known vendor’s email, but they
cannot prove a dropped file is unaltered: the person dropping it wrote every
header. That is acceptable only because each signed-in buyer can edit the ticket
anyway; do not treat it as a control. Drops are held in memory, so one not yet
approved is gone once the instance goes idle (min instances is 0).api.atlassian.com. Writes are off unless PROCUREMENT_JIRA_WRITES=on.| Path | What it does |
|---|---|
server/hosted-server |
Routes, security headers, origin and session checks |
server/okta-auth |
OIDC sign-in, ID-token verification, session cookies |
server/desk |
Reads mail and Jira, previews, applies approved changes |
server/orders |
Pure rules: grouping, matching, titles, totals, comments |
server/jira-client |
The fixed Jira reads and writes |
server/gmail-reader |
Keyless delegation and the fixed mailbox query |
server/title |
Reads the orders a PSA title names; shared with the page |
server/vendors |
The vendors the desk reads; picks each email’s parser |
server/ledger |
The order ledger’s line for each vendor email |
server/ask |
Ask the desk: the Claude loop, its prompt, the record of use |
server/ask-tools |
Ask’s three read-only tools, counting and adding in code |
server/ledger-reader |
The ledger’s fixed BigQuery reads |
server/entities |
The entities’ tax status, from Business Operations’ sheet |
server/reports |
Reports’ reads: one source and one range, rows the page adds up |
server/abm-client |
Apple Business Manager’s device list, GET only (not wired in yet) |
ledger_bigquery.sql |
The ledger’s BigQuery views |
server/lenovo-mail |
LenovoPRO confirmation and shipping email parser |
server/amazon-mail |
Amazon Business ordered, shipped and delivered email parser |
server/mail-message |
Minimal RFC 822/MIME reader and sender verification |
src/ |
React UI: Orders, Requests, side panels, previews |
src/ui.jsx |
Shared pieces: status badges, avatars, stat tiles, panel, timeline |
src/TodayStrip.jsx |
Today strip: to review, arriving today, running late, quietest |
src/journey.mjs |
Countdown, late and progress per order, against the vendor’s date |
src/Reports.jsx |
The Reports tab: builder, headline figures, tables, sharing |
src/report-engine |
Reports’ periods, filters, groups, cross-tab, chart series, CSV |
src/ReportChart.jsx |
The report’s column chart, plain SVG |
src/DataTable.jsx |
A report’s records: sortable, a page at a time |
src/Ask.jsx |
The Ask tab: starting points, the conversation, answers as entries |
src/ask.css |
Ask’s own styles (the ruled hero, entries, figures, ticket chips) |
public/ |
Landing page, app shell, logo (built app.js/app.css here) |
scripts/dev-server |
Local only: real UI with invented data and a stand-in Okta |
scripts/verify-ui |
Headless Chrome walkthrough of the whole flow |
scripts/try-ask |
Local only: one question to Ask against real PSA and the ledger |
scripts/try-report |
Local only: one report’s figures from real PSA or the ledger |
scripts/dev-reports |
Local only: invented history behind Reports on the dev server |
tests/ |
Unit tests; fixtures/ holds invented copies of vendor mail |
Node 22+. From this folder (npm.cmd on Windows if PowerShell blocks npm.ps1):
npm ci --ignore-scripts
npm test # 191 unit tests
npm run build
npm run verify # 90 checks in headless Chrome, needs Chrome installed
npm run dev # http://127.0.0.1:4317, an invented queue, no Okta
PROCUREMENT_CHROME_PATH selects another Chrome; PROCUREMENT_SCREENSHOTS=<dir>
saves screenshots. The dev server and tests use invented requests (PSA-9001…) and
the invented Lenovo fixtures; they never call Okta, Gmail or Jira. npm run dev
dates its seven invented orders from today, so one is always due today, one late
after shipping, one late without shipping, one in transit and one closed. Real vendor
emails stay outside Git (*.eml is ignored at this folder’s root).
Non-secret settings: hosted-env.json.example (copy outside Git, pass with
--env-vars-file). Secrets, from Secret Manager only:
PROCUREMENT_OKTA_CLIENT_SECRET, PROCUREMENT_SESSION_KEY (32+ random bytes,
base64url), PROCUREMENT_JIRA_API_TOKEN and, for Ask, PROCUREMENT_ANTHROPIC_API_KEY
(unset: Ask is off). PROCUREMENT_ASK_MODEL (default claude-opus-5-5) and
PROCUREMENT_ASK_EFFORT (default low) are plain settings. Missing or malformed settings refuse
startup without echoing values. PROCUREMENT_MAIL_SERVICE_ACCOUNT unset means the
mailbox is off and the desk works from dropped-in emails.
PROCUREMENT_JIRA_ASSIGNEES (email=accountId,…, people on the access list only)
maps each buyer to their Jira account for the assignee; the desk’s token cannot look
users up. Unset means the assignee is never touched.
To count the desk’s tickets in Jira:
project = PSA AND issue in updatedBy("712020:641e70b0-d5ba-4215-895a-bc029ea1622f")
(the Purchasing Desk account; add dates for a range).