@rackbops/plugin-tracker
v0.33.1
Published
The task tracker for Rackbops Clerk: reminders, renewals, price drops, one-off research (on the web, or on pages your own browser gathers and sends in through the task API, with a fuller pass on pages the research could not open) and an interest scout (th
Downloads
3,898
Readme
tracker
The task tracker for Rackbops Clerk: the host of Rackbops/docket
on a rackbops-discord-bot instance. Plan of record: Rackbops/Tooling
research/city-hall-task-tracker.md (sections 0, 5.1-5.3, 5.5, 5.8, 5.10). This version is the core
(rackbops-bot-plugins#78), the Discord surface of the first slice (#79) -- reminders by slash
command, delivered by DM, answered by button, with history -- the first slice of the web area
(#80): sign-in by one-time link, my tasks, a task's history, and settings -- renewals and
the price tracker (#81, plan E6): two more types on the notify side, no model, nothing sent to
city-hall -- the web area's task editor (#80, slice 2): make, edit, pause, resume and delete
your own tasks from the web, under the commands' own rules -- the admin view and forget-me (#80,
slice 3) -- the JSON task API with personal API tokens (#80, slice 4), for a program acting as
you, such as a later intake agent (plan E10) -- and the one-off research request (#82, plan E8,
category 5): a question looked into on the web by a model run, checked by a second run against its
sources, and DMed once, with its claims kept as findings -- and an admin raising one person's
daily model ceiling from the web (#82, plan 5.7), with an operator switch that turns the daily
ceilings off for the alpha and an admin page of daily model usage to evaluate it by -- and the
interest scout (#83, plan E9, category 1): a model run every few days that looks on the web for
things that fit someone's interests through a gift lens, DMs what it found, and does not show the
same link twice -- and the want-list watcher (#83, plan E9, category 2): a watch on one wanted thing
that reads a listing page you paste, or BoardGameGeek's marketplace through BGG's API once its
token is set, every few hours with no model, or, for eBay (and BGG without that token), the
listings you send in through the task API every hour -- the watcher never opens eBay, nor any site
for these; your own browser finds them, such as Claude in Chrome running in it -- and DMs each new
listing within your limits once, until you press Done, and, while the model runner is set up, has
the model look at each new listing first -- is it the thing, and what does it show about the
seller -- before DMing it -- and the daily "today and
overdue" digest (plan 5.5): at most one DM a day at each person's preferred hour listing their
reminders and renewals due today or overdue, never two due within 20 hours of each other. That
skips one local day after a move east into a day whose hour has come, after moving the preferred
hour more than 4 hours earlier, and after a move west across the date line. The model runs never happen here: they go
through city-hall to Rackbops/docket-runner on roshne's own host (plan 5.12), and the whole execute
lane stays off until the city-hall Executor is configured (Configuration below).
Commands
Every answer is ephemeral. The commands register globally, so they work in the bot's DMs as well as in a server (plan 5.5, item 39).
| Command | Who | What |
|---|---|---|
| /allow user | a tracker admin | Admits a person: they can now /register. With the usr link on (0.21.0), run in a server, it also allows them in usr as tracker:member and keeps their usr user id. The admin must be linked in usr and hold tracker:register and tracker:member there. Run again, it asks usr again (usr only ever adds), so it links someone not yet linked and relinks someone whose usr link was made anew. The usr user id is final only once they have signed up on usr: one who finishes signing up while signed in to a usr account they already had is moved to it by usr, and the account /allow made is dropped, so their usr sign-in fails until a second /allow, or an admin's Refresh usr links, links the one they have. A usr refusal or outage leaves the admission standing and says what to do; in a DM it says to run it in the server. |
| /register [hour] [zone] | an admitted person | Signs up, or changes the preferred hour (0-23, default 9) and time zone (default America/New_York). The reply says an admin can see every task. With the usr link on (0.21.0), run in a server, it also carries a linked person's one-time usr sign-up link, until usr says they have signed up; in a DM it says to run it in the server. If usr says they are not allowed yet (a usr admin removed their link), it forgets their link and says to ask for /allow again. See First admin and usr below. |
| /remind text [when] [repeat] | a registered person | A reminder by DM. when is docket's parseWhen grammar (in 20 minutes, tomorrow 9am, fri at 17:30); repeat is once (the default), daily, weekly or monthly. A repeating one with no when starts today at the preferred hour. |
| /renewal name amount currency renews [unit] [every] [lead] [note] | registered | A subscription, domain, warranty or membership (docket's renewal, a period schedule). amount is from 0 to 1,000,000 (MAX_AMOUNT in src/limits.ts; /task decide, the web editor and the task API take the same range); renews is the next renewal or expiry date, YYYY-MM-DD, today or later; unit yearly (the default), monthly, weekly or daily, every how many of those; the ask comes lead days before (default 7) at the preferred hour, with Keep, Cancel, Renewed and Snooze. If that ask is already past but the date is not, the first ask comes within a minute (a zone or hour change before it fires drops it for the next period's). Keep and Renewed record what was paid; Cancel ends it. |
| /price url [name] [hours] [drop] [baseline] [near] | registered | A price (docket's price, a poll schedule every hours, default 12, at most 168). url may be any public web page (Page reads, under What it does), eBay's and BGG's included, or a link that redirects to one; only the want-list watcher refuses eBay. The page is read once at once, and nothing is created unless a price is found in it; then the first check, within a minute, DMs the starting price, and a drop of drop percent or more (default 10) from the baseline -- the last (default), first or highest price seen -- DMs the owner once per crossing. near is the words just before the price, for a page with no structured price. At most 20 per person. |
| /research question [context] [deadline] [at] [sources] | registered | A one-off research request (docket's research, a once schedule at at, default now). A research run splits the question into its parts and looks each up on the web (read-and-web tools only, plan 5.6), naming each part's best source and trying it -- BoardGameGeek included, read honestly, a page that refuses it listed as one it could not open (with the web area; without it, named as a gap) -- and never opening eBay (eBay's User Agreement, section 3; since 0.30.0, docket-types 0.10.0), a reviewer run checks the draft against its sources, and only a passed answer is DMed to you (and accepted recipients); its claims are kept as findings on the task's page. deadline (docket's parseWhen) goes into the request; a run that would start past it makes no call and tells you. question must hold the question itself: fewer than 3 characters, or no letter at all (a stray 0), is refused before anything is made. At most 5 waiting per person (a request waiting for pages for a fuller pass, below, not counted: its first answer is delivered); every run counts against the daily budget (below). sources: browser (0.28.0, docket-types 0.8.0's sources: "sent"; default web) answers from pages your own browser gathers instead -- ask Claude in Chrome to gather them and send them in through the task API (POST /api/v1/tasks/<id>/sources, then /sources/ready; Task API below), for pages a web run cannot or may not read, such as a BoardGameGeek page that refuses it or eBay, which the research never opens: it answers only from the pages sent in, and the request waits for them, its run queued 14 days out or 10 minutes before the deadline, whichever is sooner (a deadline less than 20 minutes away is refused; the margin makes a missed deadline unlikely, not impossible, since a run held by the budget or behind another Job can still start late), and moved to now by ready; a request whose ready never comes runs then on whatever pages are in, and with none makes no model call and DMs you so. at does not go with it, and it needs the web area. A fuller pass (0.29.0, docket-types 0.9.0; with the web area only): when the research run lists pages it needed and could not open (a best source that refused it, BoardGameGeek's included), the reviewed answer is DMed with a "Couldn't open:" list, and the request waits for pages for up to 14 days (or until 10 minutes before its deadline), sending, running and charging nothing; ask Claude in Chrome to gather them and send them in as for a browser request, and ready starts a second pass that DMs an "Update to ..." of what it adds or corrects. With no ready by then, the second pass runs on whatever pages are in, and with none the request ends and the first answer stands. Answers that research is not available while the city-hall Executor is not set up. |
| /scout new interests [lens] [for] [notes] [every] | registered | An interest scout (docket-types' scout type, since 0.6.0; plan 1.2 row 1): every every days (default 1, at most 30) at your preferred hour, a model run looks on the web for 5 to 10 things published or available in the last 30 days that fit the interests (separated by commas, at most 20, each at most 80 characters) through the lens -- general (the default), birthday (fun or a little grandiose), anniversary (a romantic angle) or christmas (tied to their interests) -- for for (someone else, e.g. Anne; empty means you), weighing notes, and DMs them to you (and accepted recipients). A link it showed before is not shown again (it remembers the newest 300); fewer than five come with the run's reason. At most 3 scouts per person; each run counts against the daily budget (below), up to 1.50 USD. Answers that the scout is not available while the city-hall Executor is not set up. |
| /want name source [target] [max] [currency] [hours] [judge] | registered | A want-list watch (docket-types' wantlist type, or wantjudge when judged, since 0.6.0; plan 1.2 row 2). source is page: target is a shop's listing or search page, checked like /price's page and read once at once -- nothing is made unless its structured product data (JSON-LD ItemLists and Products) lists something; bgg: target is a BoardGameGeek game's address or id, read through BGG's XML API while TRACKER_BGG_TOKEN is set; shopify (since 0.32.0): target is the words to search Shopify stores for (the name when empty), one Shopify catalog search per look through Firecrawl's Alexandria while TRACKER_FIRECRAWL_KEY is set (refused without it), for new copies in stock at or under max; a product counts only when its title has every word, and each look costs 5 Firecrawl credits, so hours is 24 to 168 for it; or ebay: with the eBay keys (TRACKER_EBAY_CLIENT_ID and TRACKER_EBAY_CLIENT_SECRET, since 0.33.0), one search per look through eBay's own Browse API (docket-types' ebaySource; eBay US, the newest fifty, narrowed by max), for listings whose title has every word of target (the words, the name when empty), new and used, an auction at its current bid (or its Buy It Now price, when it has one), the seller's name, feedback percentage and score shown; every hour by default, and the watcher still never opens an eBay page. Without the keys, ebay is an inbox watch (since 0.19.0; docket-types' inbox source), whose listings are sent in rather than read -- the watcher never opens eBay. A watch made before the keys were set stays an inbox watch; one made with them that outlives the keys fails each look and tells its owner once. An eBay API watch takes no alert emails: an alert for its words is left without a DM. It keeps no eBay data, so that the keyset may opt out of eBay's account-deletion notices: no finding is stored, its listings are in the DM only, six hours after a run its stored copy of the DM keeps only its first line and a link to eBay's own search (ebay-forget.ts; a send still owed then sends that), and it is never judged, whatever judge says, since a judged watch's Job is kept by city-hall's queue. For an inbox eBay watch, target is the words to search eBay for (the name when empty); the answer gives the watch's id, how listings reach it -- you search eBay in your own browser, such as with Claude in Chrome, and send what it finds to POST /api/v1/tasks/<id>/listings (Task API below), or, while the alert-email route is on (0.25.0, Alert emails below), forward eBay's saved-search alert emails to your alert address, which the answer gives -- and the eBay search itself, with max as eBay's own top price, to save on eBay too. bgg while the token is unset (BGG declined the application) is an inbox watch the same way: target is then a BGG game (kept as its id) or the words to look for (the name when empty). An inbox watch needs the web area (TRACKER_WEB_URL), since its listings arrive through the task API; without it, it is refused. It also needs the bot's HTTP_PORT, which the tracker cannot see: with TRACKER_WEB_URL set and HTTP_PORT unset, the watch is made but nothing can send it listings. Every hours (default 12 for a page, 24 for BGG and Shopify, 1 for eBay's API and for an inbox watch, whose reads cost nothing, at most 168) on the poll tick, the watch DMs each listing at or under max (then only listings that show a price) and in currency (when set) that it has not DMed before, up to 5 lines with the rest in /task history's second message (up to 20, as many as fit in one message, the newest kept; the task's page in the web area shows the newest 50), with a Done button that ends the watch. Each listing DMed is a finding (an eBay API watch's excepted, above: its extra listings are left to eBay's own search). Three reads in a row that find nothing tell you once (a page whose list is empty is a search with no results, not nothing read). At most 20 new listings a run, the rest the next run. A redirect to eBay is refused on every hop. At most 20 watches per person, judged or not. eBay's pages are refused as a page. Judged (judge, default yes while the model runner is set up; judge: true without it is refused; fixed once made): the reads stay plain code, and new listings within the limits go to one claude -p Job first (docket-types' wantjudge, plan 5.4, item 63), which may open only those listings' pages on the watch's own site (never eBay, no web search) and says for each match, maybe or no, why, and what its page shows about the seller, as signals; the DM shows the match and maybe ones with those notes, and every listing is a finding with its verdict. 12 turns, 0.50 USD, 5 minutes a look; a look that fails for good sends the listings unchecked. Its reads are uncharged and its looks charged, like any model run; both wait while its owner is at a daily ceiling (with the budgets on). The judge may open only pages on the watch's own host (the pasted page's, or BGG's), and none at all for an inbox, Shopify or eBay watch: it judges from what each listing says. |
| /scout edit task [interests] [lens] [for] [notes] [every] | registered | Changes your scout: interests replaces the whole list; anything left out stays; for or notes set to - clears it. A change of every keeps the start day. |
| /tasks | registered | Your active tasks and the ones you receive, each with its next run; then your paused ones, each naming up to three people it could not DM (then how many more). A list longer than one message (Discord's 2000 characters) is cut between whole lines and keeps your paused tasks in preference to the active ones that do not fit, since their ids are what /task resume needs (any paused ones that still do not fit are counted); it says how many active ones it left out and, when the web area is set up, points to /web, which lists them all. |
| /task done task / /task snooze task [until] | the task's owner | Answers the task's latest reminder (snooze: an hour, or until until). |
| /task decide task choice [amount] | the renewal's owner | Answers the renewal's latest ask -- keep, cancel or renewed -- with the amount actually paid when it changed (a button cannot carry one). Use it instead of the button, not after it: a run is answered once. |
| /task history task | the owner, an accepted recipient, or an admin | The newest 10 runs -- due, status, how it was answered, text replies -- and the newest 5 of the task's changes, older ones counted (only the shown rows are loaded; SQLite still counts the rest, and for the runs sorts them too); sent with Discord's suppress-embeds flag, so a link in a reply, a finding or an error does not unfurl into a preview; for a renewal, what each period cost and the total; for a price, the last check and the readings with the low and the high; for a want-list watch, how many listings it has kept, and once there are any, a second message with the newest of them, up to 20 and as many as fit in one message, then how many more there are (and, while the web area is set up, that the task's page there shows the newest 50). |
| /task share task user | the task's owner | Invites an admitted, registered member who can be DMed: they get one consent DM with accept and decline. An invitation still open when the task is deleted or finishes is withdrawn, with a history event; a late Accept or Decline is refused with "That task was deleted." or "That task is done." and records nothing (one on a paused task still counts). |
| /task resume task | the task's owner | Resumes a paused task: one paused because a recipient could not be DMed goes on without that recipient; one its owner paused on the web simply goes on. |
| /settings hour hour | registered | The preferred hour; recurring reminders with no time of their own move to it. |
| /web | registered | A one-time link to sign in to the web area (below), good for 10 minutes and one use. Says so when TRACKER_WEB_URL is unset. |
Gates. Every command and button checks, in order: membership of a TRACKER_GUILD_ID
server (when set; a member of any listed server passes, and in a server TRACKER_GUILD_ROLES names
roles for, only while holding one of them), admission (in the tracker's store), then
what the action needs (an admin for /allow, admission for /register, registration for the
rest). A person nobody admitted is told to ask an admin to /allow them. Decline and opt-out skip the membership and registration gates,
so anyone can always stop the messages; the Reply button checks membership when its modal is sent.
Buttons. The owner's DM carries Done, Snooze 1h and Reply; a recipient's copy carries the
opt-out and Reply; the consent DM carries Accept and Decline. Reply opens a modal whose text is
kept on the task as one plain line: each run of line breaks, spaces, control characters and
invisible format characters becomes one space, but for a few format characters real text needs
(the zero-width joiner in an emoji, the non-joiner in a Persian or Indic word, the soft hyphen,
and the signs Arabic, Syriac and Kaithi write with a number). A reply of only spaces, line breaks,
zero-width characters or blank fillers is refused as empty. /task history, the web task page and
the task API show up to 80 characters of it, on one line; in /task history a masked link,
mention, timestamp, spoiler or code in it is shown as typed, except that a mention or timestamp
typed right after a bare link may still show as one. A mention there never pings. docket decides
who may answer what (the owner alone answers a run, item 34). A pressed DM is edited to say what
happened.
Pause after failed DMs (plan 5.5). Three DMs in a row that the host says cannot be delivered
pause the person's delivery and every active task that would DM them, theirs and the ones they
receive (a paused event in each task's history; one for a recipient is shown only to that
recipient, the task's owner and admins -- a row from before 0.6.0, only to the owner and admins;
another recipient sees paused: delivery paused in its place, as they see the owner's own delivery
pause, and resumed: delivery resumed for the resume after either, and a failed run's error that
names another recipient, forgotten or not, reads a recipient cannot be messaged);
a DM to them still owed waits for the resume.
A task that comes to DM them while they stay paused -- one made on the web or through the task API,
one its owner had paused and resumes by hand, one a pause stopped part way never reached -- pauses
the same way at its first DM, which then waits too. Only a send for a task already finished (a
research answer, say) is not held: docket's eight deferrals of it run out (about two hours), and it
fails; a research answer's claims are still on its task page (Findings, below).
The owner of a task paused for a recipient is DMed once and sees why in /tasks; the task
resumes when the recipient next uses the tracker, or /task resume goes on without them. A person
paused for their own DMs is resumed, with every task nothing else holds, by the next command or
button they use (all but Decline and opt-out), which tells them why (a command tells them above its
answer, or in a message of its own after it when the two would not fit in one); by Resume DMs, on
the banner every web page shows them while they are paused; by resuming a paused task of their own
(/task resume, or Resume on the web or through the task API); or by an admin's Resume delivery.
Nothing else on the web or through the task API resumes them.
A DM that goes through clears the count. Only a DM the host says cannot reach that person (Discord's
50007, or 10013 for an account that is gone) counts, once per run: docket fails it for good at once. A DM refused for
its content is retried by docket (three tries, a minute apart and doubling) and does not count; nor
does one whose outcome is unknown (see Delivery below).
Delivery (docket 0.4.0). docket records each DM in the store's deliveries table before it is
sent (a claim) and settles it after: sent, failed (for good, or owed and retried later), or
unconfirmed -- the send may or may not have reached Discord, so it is never resent, and a warning is
logged. A run has fired once its record is written; the other recipients of a run still get their
copies when one of them cannot be reached, and the run is done, not failed, whatever a DM did.
At start the plugin runs docket's recover(): a run left running is requeued, and a claim left open
by a stop mid-send is settled unconfirmed and logged.
Research and the execute lane (docket 0.5.0, #82). A research request is two model Jobs, run
one after the other through city-hall's execute lane (Lepid-Labs/city-hall#18) by the runner that
carries TRACKER_CITY_HALL_CAPABILITY -- only docket-runner, on roshne's subscription (plan items
70, 71). The tracker submits each Job with POST /api/execute/jobs under a prefixed key,
rackbops-tracker:<database id>:<docket's Job key> -- the database id is random, made once and kept
in tracker_meta, so two tracker databases (two instances, or one whose database was replaced)
never share a key at city-hall. A 200 (a key city-hall already knew) must carry this Job's own
prompt, or the run is refused and the mismatch logged. The tracker stores city-hall's job id in its
own executor_jobs table before going on -- if that write fails, the run is asked again and the
same key hands back the same job -- and asks GET /api/execute/jobs/:id each minute until it is
done: one Job at a time across everyone (plan 5.3), on a tick of its own that runs in the
background, so a slow city-hall never holds up a reminder. While that tick waits on city-hall it
holds its tasks: the notify tick sends their DMs a minute later, and a command, button or web
action on one of them answers "That task is with the model runner right now; try again in a
minute." at once, so no one else's command waits behind it. The one exception is a zone or hour
change by the owner of a scout the tick holds: it waits for the tick, and so does every command,
button and web write queued behind it (see One task at a time below). city-hall unreachable or
timed out (logged once an hour, naming only the error's kind, never its message), a 5xx, or a
refused credential or edge redirect (logged once an hour as such, and the admins told once per Job)
holds the run and asks again; a Job still not back after six hours is given up (docket's
PENDING_LIMIT_MS). A record in executor_jobs goes once its run is gone, or once it was
submitted over 30 days ago and its run is no longer queued or running.
A runner that stops: city-hall requeues, the tracker waits. city-hall puts a job back in its
queue when the runner's claim ends in usage_limit or auth_failed, or its lease expires, so
docket does not see that result then and the job reads queued again. Both are capped: once
city-hall has requeued a job after usage_limit or auth_failed its configured maximum number of
times (ten by default, CITY_HALL_MAX_REQUEUES), the next claim that ends that way fails the job
instead, as does the expiry that brings its expired leases to their configured maximum (three by
default, CITY_HALL_MAX_EXPIRED_LEASES). The tracker reads how the last claim ended -- city-hall's
job.lastOutcome, or the last entry of runs where the field is absent -- and treats a queued job
whose last claim ended in any of those ways (usage_limit, auth_failed or an expired lease) as
"unavailable", not "pending": the run is held and asked again, never given up after six hours while
the runner is paused, and nothing new is submitted meanwhile. docket counts its
six hours from the submission, so once a Job has been seen paused (executor_jobs.paused_at) every
unfinished answer for it -- queued or running -- stays "unavailable": a runner that resumes after
six hours is collected, not given up and run a second time. That hold is capped at 48 hours from
the first sight (a judgement, not a measured figure): past it the Job answers "pending" again, so
docket gives the run up, and the admins are told once. It logs "the model runner is paused" naming
the outcome once an hour per Job, and tells the admins once per Job and outcome. docket's own
usage-limit pause (which waits for the reset the CLI named) applies only to a usage_limit result
that reaches docket, and that happens only when the job ends failed with that result still stored.
Mainly, that is the requeue cap (CITY_HALL_MAX_REQUEUES): the job fails with the usage_limit
(or auth_failed) result of the claim that went past it, and the tracker answers docket with that
result, if docket is still asking (within the 48-hour hold above). The other way is the lease cap
(CITY_HALL_MAX_EXPIRED_LEASES), when a job requeued after usage_limit or auth_failed later
fails at its maximum of expired leases with that earlier claim's result still stored.
The plugin never calls a model, holds no Claude credential and no ANTHROPIC_* variable; model
output is data, cleaned by docket before it reaches a DM or a finding, and every research DM goes
out with no allowed mentions: the host sends every dm with allowedMentions: { parse: [] }
(CONTEXT.md). The wire is city-hall#18's
(Lepid-Labs/city-hall#18, merged as 90a06ec),
but its decision record 0002 is still "proposed" pending Nazu's review
(Lepid-Labs/city-hall#17): the source pair is
a stand-in for the source and responder contracts, so it may change, and this adapter with it --
the requeue reading above and lastOutcome (present in the merged 90a06ec) included.
Research while the lane is off. If an instance that had city-hall configured loses that
configuration, its research requests stay queued: nothing runs them until research is available
again, a request whose deadline passes meanwhile makes no call and tells its owner when it next
runs, and those waiting requests still count toward the 5 a person may have waiting. /research
says research is not available; it does not list the waiting ones (/tasks does).
Budgets (plan 5.7, docket's defaults): 2 USD and 20 model calls a person a day, 10 USD and 100
calls in all. At a ceiling the person's research waits until midnight Eastern; the person gets one
DM and the admins one each. Reminders, renewals and prices never count. An admin can raise one
person's ceiling from their admin page (0.13.0, below); the global ceiling still applies to everyone
together. For the alpha the ceilings can be switched off (0.14.0; roshne, 2026-10-02, choosing option
A: "A. but lets build in an unlimited budget "flag" during alpha, evaluate usage during alpha, then
test budgets during beta"): with TRACKER_BUDGET_UNLIMITED on (Configuration, below) no daily ceiling holds a run,
while each Job's own caps (15 turns and 1 USD for a research run) still bound it and every run is
still charged, so /admin/usage (below) shows what the defaults would have held.
The interest scout (#83). A scout is one model Job per run on the execute lane, under the same
switch, budgets and runner as research, with no reviewer run (plan item 62 keeps one for research
only). Its prompt, JSON schema and caps are the web-search spike's scout case (docket-runner
spike/cases.json; plan items 59 to 61): five to ten items, each with the page it was confirmed
on, why it fits, and a price if shown; a shortfall reason rather than padding; 30 turns, 1.50
USD and 10 minutes a run. Every item is cleaned and kept only with an http(s) URL. What it showed
is remembered in the task's state (the newest 300, by a digest of the URL without its fragment);
the next prompt names the latest 40, and an item already shown is dropped from the DM whatever the
model returns. Plan 5.2 and #83 word this as a check "against findings"; the state is the same
record kept beside the run, so the check needs no store read. Dedupe is by URL: the same thing at
another address is caught only by the prompt's list of titles. Those titles are model output fed
back to the model, cleaned and cut to 150 characters. Each item shown is also a finding, keyed by that digest. A run that fails with
schema_miss, a malformed answer, timeout or error is retried once at once, auth_failed
once an hour later; turn_cap and budget_cap are not retried. A run that fails for good sends one
line to the owner (and accepted recipients) and the scout goes on to its next run. A scout runs at its owner's preferred hour,
so /settings hour moves it with their reminders. With budgets on, mind the arithmetic: a run can
cost up to 1.50 USD of a person's 2 USD day, so two or three daily scouts, or a scout and a
research request, can reach the ceiling; a held run waits until midnight Eastern, and the next
scheduled one is made only once it has fired. The type lives in this plugin, not in
@rackbops/docket-types (0.5.0 has no scout); giving it back to docket is a follow-up.
Findings. A passed research answer's claims are stored with their first source (docket's
findings), shown on the task's page to its owner, accepted recipients and admins (docket's
visibleFindings), listed in /task history, and returned by the task API's GET /tasks/<id>.
Each source is a link with rel="noopener noreferrer nofollow"; every value is escaped. A recipient
sees a task without its config and state (docket 0.5.0): the request's context and draft are the
owner's.
One task at a time. A task's runs, the answers to them (buttons, /task done, Reply) and its
edits (the editor, pause, resume, delete, a zone or hour change) take that task's lock
(src/locks.ts), so none of them overlap. The notify and poll ticks run one pass per task that has
work; an answer that lands while its own task runs waits for that pass. The execute tick is the
exception: it can wait on city-hall, so an answer or edit about a task it holds does not wait but
answers "That task is with the model runner right now; try again in a minute." A zone or hour
change (/register, /settings hour, the web area's settings page) is the one edit that waits
instead, since it re-times every recurring task that follows the zone or hour and may skip none.
Of the tasks the execute tick holds, only a scout is one of those: a zone or hour change by its
owner waits for the rest of that tick's pass, each city-hall call or page read in it bounded at 15
seconds, and every command, button and web write queued behind it waits too.
Web area
Served under /tracker/ on the bot's own HTTP (it needs HTTP_PORT) and reached from outside
only through the instance's tunnel, at TRACKER_WEB_URL. Server-rendered HTML with forms and no
script, styled with @rackbops/styles' rackbops-noir theme (bundled into dist/plugin.js and
served at a hashed path, cached for a year).
| Path | What |
|---|---|
| / | My tasks: the same list as /tasks (active tasks owned and received, next run in your zone; paused ones and why), each linking to its history, and links to make a new one. |
| /tasks/<id> | A task's history: the same as /task history, for the owner, an accepted recipient or an admin. Anyone else gets the same 404 as an unknown id. The owner also sees Edit (not for a research request), Pause or Resume, and Delete. |
| /new/wantlist | A new want-list watch (GET, POST), by /want's rules (judge too, yes or no): a page, a BGG game, or eBay (an inbox watch: target is the words to search for, the name when empty). Its edit changes the name, top price, currency and hours; an emptied top price or currency clears it, and where it looks -- an inbox watch's key and words too -- stays as made. |
| /new/reminder, /new/renewal, /new/price, /new/research, /new/scout | The editor's new-task forms (GET), and making one (POST). /new/research and /new/scout are linked from My tasks only while the model runner is set up; without it the page says the type is not available instead of showing a form. A scout's interests go in a text box, separated by commas or one per line; its edit form shows the list one per line, and an emptied "Who it is for" or "Notes" clears it. |
| /tasks/<id>/edit | The owner's edit form (GET) and saving it (POST). |
| /tasks/<id>/pause, /resume, /delete | POST only. Delete answers a confirmation first; only a second post carrying confirm=yes deletes. |
| /resume-dms | POST only: Resume DMs, the button on the banner every page shows a person whose DMs are paused (Pause after failed DMs, above). It resumes them as their next command would, and My tasks then says how many tasks are back on; pressed when they are not paused, it changes nothing. |
| /settings | Preferred hour and time zone, checked as /register checks them. Links to API tokens and Forget me. |
| /tokens | Your API tokens (below): each one's name, when it was made, last used and expires, with Revoke; and the form to make one (POST), which sends the new token to you by Discord DM -- never on the page. |
| /tokens/<id>/revoke | POST only: revokes one of your own tokens. Anyone else's answers the same 404 as an unknown id. |
| /forget | Forget me (below): what it deletes (GET); a POST asks for the confirmation; a POST with confirm=yes and the word forget erases. Linked from Settings. |
| /admin | Admins only: everyone on the list, the decline blocks in force (each with Lift), and a form to allow a person by Discord id. |
| /admin/tasks | Admins only: every task in the store, with its owner, status and how many receive it, each linking to its page. |
| /admin/deliveries | Admins only: the DMs that did not arrive (below), newest first. |
| /admin/usage | Admins only: model usage per budget day (below). |
| /admin/people/<id> | Admins only: one person -- Discord id, registration, zone and hour, delivery, task counts, their tasks -- with Make admin or Revoke admin, Resume delivery (when paused), and Remove from the tracker; their daily ceiling, with the form to change it; and their API tokens, each with Revoke. |
| /admin/usr-link | Admins only, POST only, with the usr link on: Link everyone to usr (below). |
| /admin/usr-refresh | Admins only, POST only, with the usr link on: Refresh usr links (below). |
| /admin/allow, /admin/people/<id>/grant, /revoke, /resume-delivery, /ceiling, /forget, /admin/blocks/<id>/lift, /admin/tokens/<id>/revoke | Admins only, POST only: the acts below. |
| /api/v1/... | The JSON task API (below): bearer tokens only, never the cookie. |
| /signin | Where a request that is not signed in is sent (a POST, or any request with the usr link off): says to run /web, and with the usr link on offers Sign in with usr. |
| /login?t=... | The link /web gives. |
Signing in (plan item 41; the one-time link is the method for now, kept in
src/web/signin-link.ts so another, such as Discord OAuth2, can sit beside it and feed the same
sessions). /web answers, ephemerally, <TRACKER_WEB_URL>/tracker/login?t=<token>. Opening it
does not use it up -- a link preview or a prefetcher would otherwise burn it -- it shows a Sign in
button; pressing it uses the token (once, within 10 minutes), starts a session and redirects to
/tracker/ so the token leaves the address bar. The login pages send Referrer-Policy:
same-origin and Cache-Control: no-store (every page does). Not no-referrer: under it a browser
sends a form post's Origin as null, which the Origin check refuses, so no form would work; the
pages link nowhere else, so the token never leaves in a Referer either way.
Signing in through usr (0.22.0, while the usr link is on: TRACKER_USR_URL). A GET with no
session reads usr's nz_id cookie, which usr sets on the parent domain named by its
USR_SSO_COOKIE_DOMAIN. That domain must cover TRACKER_WEB_URL's host: otherwise usr refuses the
return address and keeps the browser on its own site. The token is checked offline, as usr's
docs/sso-verifier.md says: an ES256 signature against usr's /.well-known/jwks.json (cached five
minutes, fetched again on an unknown key at most every 30 seconds), iss "usr", not expired, and
never a delegation token. A valid token whose account holds tracker:member and is linked to a
registered person (users.usr_subject) opens a session as the one-time link does, once server
membership is checked (as /web checks it), and the browser goes on to the page and query it asked
for, marked ?usr=in: arriving there without the session (a browser that refuses the cookie) says
so instead of signing in again. A signed-in GET still carrying a ?usr= mark goes on to the same
address without it. With no valid token the browser is sent to usr's /api/auth/sso/refresh once, returning to
the same address marked ?usr=1; coming back still without one (usr's keys could not be fetched,
the clocks disagree) says the sign-in could not be confirmed, instead of going round again. A token
without the member role goes the same way once, since it can predate an /allow (an nz_id lives
30 minutes by default); still without it, or for an account linked to no one registered here, the
page shows what to ask an admin for. While signed in, a usr cookie for the person's own account that
no longer carries tracker:member signs them out of every session and revokes their API tokens, but
only on a GET back from usr's refresh: any other request with it, a POST included, is first sent
there (a POST's form is not kept: the browser comes back to that page, or to My tasks). That happens
only in the web area: the task API never reads usr, so a token keeps working until the person next
uses the web area while still signed in to it; opened signed out, it only shows the not-member page
and the tokens stay (revoking their tokens or admission in the tracker ends them at once). Sign out
ends the tracker's session only, not usr's: for 12 hours after it, a GET does not sign back in from
usr's cookie unless it carries ?usr=go (the sign-in page's "Sign in with usr" button, whose trip to
usr ends those 12 hours); a /web link signs in as always. Any site under usr's cookie domain can set an
nz_id of its own, so every host under that domain must be trusted. The one-time /web link stays
beside it. src/usr-identity.ts uses WebCrypto only.
Sessions and forms. The session cookie is __Secure-tracker-session, HttpOnly; Secure;
SameSite=Lax; Path=/tracker/, for 7 days from sign-in; a browser never sends it to the bare
/tracker, which redirects to /tracker/ first. A person holds at most 20 sessions: a 21st ends
their oldest. The store keeps only SHA-256 hashes of
link tokens and session ids. Every request re-reads the person: one no longer in the tracker's
store, or no longer registered, is signed out of every session. Every state change is a POST
carrying the session's CSRF token (compared in constant time), and a POST with an Origin other
than TRACKER_WEB_URL's is refused. The sign-in post has no session yet, so it carries a
double-submit token from a SameSite=Strict cookie the link's page sets: another site cannot sign
you in as someone else. Sign out is a POST too.
The task editor (slice 2). The forms take the options of /remind, /renewal, /price and
/research (#82), and the same functions check and make them: the same defaults, limits and messages, the page read
once before a price tracker is made, at most 20 price trackers per person wherever they were made,
and near turned into the same bounded pattern; a person has one first page read (a new price
tracker's or page watch's) in flight at a time. Every person may have at most 200 active or paused tasks of all types together,
made anywhere. The length limits are one set (src/limits.ts) for the slash options, the forms
and the server's checks. An edit changes a reminder's text, time and repeat
(an empty time keeps the one it has, unless the repeat changes); a renewal's name, amount,
currency, date, unit, every, lead and note (a new amount is the one the next ask quotes; a new date
gets /renewal's first-ask rule, but a date a run already asked about is not asked again); a price's name, interval,
drop and baseline, never its page. A schedule change cancels and replaces what is queued and keeps
snoozes, as a zone move does, and each change is in the task's history. An empty field keeps what
the task has (an empty note clears it; an empty price name is the page's address). A renewal date
that is one of the schedule's own period dates keeps the schedule and its anchor as they are, so the
31st stays the 31st; only a different date re-anchors. A finished task cannot be
edited. Pause holds everything the task would send until Resume, and a person's delivery resuming
does not undo it; a run missed while paused fires once, late, on resume, and a zone or hour change
re-times the run a paused task holds -- same period date or local day, new zone or hour, still one
run -- so it is neither lost nor sent at the old time. Resume on a task paused
for failed DMs is /task resume. Delete archives the task: it leaves every list, its queued runs
are dropped and nothing more is sent, but its history is kept (an admin can see every task) and
its page stays for the owner. Every editor post passes the session, CSRF and Origin checks; the
task is loaded by the id in the path and must be the viewer's own, checked again in the write
queue, and anyone else's -- even one an admin can see -- answers the same 404 as an unknown id. A
refused form comes back with what was typed and why. A research request made on the web is
/research's exactly -- the question, the optional context, deadline and start in /research's
words, where its pages come from (the web, or your browser), the cap of 5 waiting, the daily budget -- and, as in Discord, it is never edited: its page
has Pause, Resume and Delete but no Edit. Making one only queues its run; nothing reaches city-hall
until the execute tick. Form bodies are capped at 32 KiB, read no
further than that.
Headers. Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'none';
form-action 'self'; frame-ancestors 'none'; base-uri 'none', X-Content-Type-Options: nosniff,
X-Frame-Options: DENY. Every value on a page is HTML-escaped. An unknown path is 404; a method
other than GET or POST (or the wrong one of the two for a path) is 405.
Membership on the web. With TRACKER_GUILD_ID set, /web issues a link only to a member,
and the session remembers when that was confirmed. A web request more than 15 minutes after the
last confirmation re-checks membership (one lookup per listed server, all at once, the first yes enough; about 3
seconds at most, outside the write queue), through the discord.js Client of an interaction the
plugin has handled since it started (the host API has no member lookup of its own; the Client is
held in memory, never stored). Not a member of any listed server: every session of theirs ends, on a page that says why. A member: the time is
refreshed. A failed or slow lookup, or no interaction yet since a restart: they stay signed in
while the last confirmation is under 24 hours old, and are then signed out and told to run /web
again. Concurrent requests share one lookup per person, and after a failed one that person is not
looked up again for a minute, so an outage or rate limit does not pile up calls. A confirmation
time in the future (a clock set back) counts as stale. Every lookup asks Discord (force: true),
never discord.js's member cache, which with only the Guilds intent never learns that someone left.
The admin view (slice 3, plan 5.8, 5.10). An admin is a person whose row in the tracker's store
has the admin flag -- the one admin definition; /allow checks the same flag. TRACKER_ADMIN_DISCORD_IDS
grants it at start; admins grant and revoke it here. The flag is read from the store on every
request, and again inside the write queue before any admin act, so an admin whose flag is revoked is
refused on their very next request. To a signed-in person who is not an admin, every /admin path --
any method -- answers the same 404, byte for byte, as an unknown page; a visitor who is not signed in
is sent to sign in, as on every other signed-in path. An admin sees every person, every task
and every task's page and history, read-only: an admin never edits, pauses or deletes another
person's task (the owner's acts answer them 404, as in slice 2). What an admin can do, each through
the function the command path runs:
- Allow a person by Discord id:
/allow's ownallowPerson, with the same server-membership check whenTRACKER_GUILD_IDis set (one bounded lookup; no Discord client yet since a restart refuses, as an unknown answer does). A form cannot tell a bot's id from a person's; a bot's row can never/register. - Make admin / Revoke admin (plan 5.8). The last admin can be neither revoked nor removed. An
admin who revokes themselves lands on My tasks. Each change is logged (
u1 made u2 an admin). An admin named inTRACKER_ADMIN_DISCORD_IDSis made an admin again at every start, so their page offers another admin neither Revoke nor Remove, and both are refused: take them out of the configuration first. Such an admin may still forget themselves (if another admin is left): their own page offers Remove but not Revoke, and the pages say that the next start makes them again, as a new person, unless the configuration no longer names them. - Resume delivery of a person paused after failed DMs:
DeliveryHealth.resume, what the person's own next command does -- the count clears and the tasks nothing else holds go back on. - Lift a decline block in force: docket's
liftBlock, recorded as that admin's lift. - Revoke an API token of anyone's, from their page, which lists their tokens (never the secret). It stops working at once; the owner is not told. Logged by token id.
- Raise a person's daily ceiling (0.13.0, plan 5.7: "The admin raises a person's ceiling from
the web area"). Their page shows what they spent today (the budget day ends at midnight Eastern),
their ceiling now, a form with both numbers -- dollars to the cent and whole model calls -- and
the latest 20 changes an admin made, newest first (older ones stay in the table). Each number
must be at least the default (2 USD, 20 calls) and at most the global ceiling (10 USD, 100
calls), which docket checks first anyway; those bounds are proposed on #82, roshne's to confirm.
Setting exactly the default is a reset to it, not a raise.
A raise stands until an admin changes it -- raises it again or presses Back to the
default. That default is proposed on #82 and is roshne's to confirm; a today-only raise
would be a small change (
limitsOfandisRaisedplus a clock inCeilings, and the page's badge). Enforcement stays docket's: the execute lane hands docket'sbudgetHolda policy whosepersonForreads the person's newest change, before every run, so a raise lets a held request run on the next tick. docket sends its ceiling notices once a day per person (noticeOnce, keybudget:person:<id>:<day>), so every change -- raise or reset -- deletes that person's key for the current budget day in the same transaction: meeting the new ceiling the same day DMs the person and the admins again (plan 5.7: "nothing fails silently"). Every change is an append-onlyceiling_changesrow (who, for whom, the values, when) and a log line (u1 set u2's daily ceiling to 4 USD / 40 calls (c1)). - Remove from the tracker: forget-me for that person (below), with the same confirmation.
Usage (0.14.0). /admin/usage, linked from every admin page, shows the model spend per budget
day (midnight Eastern) for the last 14 days, newest first: dollars and calls for everyone together,
then per person. Each row is measured against the default ceilings, not a person's raise -- a
"would have hit the 20 call limit" (or "2.00 USD") marker on a day that reached one, and how many
calls were charged after it had, roughly what that ceiling would have held (docket checks before a
run and charges at its end). It reads docket's usage table, which every run is charged to whether
budgets are on or off -- the way to evaluate the alpha with TRACKER_BUDGET_UNLIMITED on
(Configuration, below). Read-only, and like every admin page answers anyone who is not an admin
with the unknown page's 404.
Deliveries (0.10.0). /admin/deliveries, linked from every admin page, lists the DMs that
settled failed, unconfirmed or deferred in the last 30 days, newest first, at most 200 rows
(the page says how many matched when the bound cuts it). Each row: the task (its title, linking to
its page, its id and owner), the run's due time, the recipient (linking to their admin page), the
status, attempts and deferrals, the error text, and when it settled. unconfirmed means the DM may
have gone out and is never resent; deferred is still owed (a recipient whose delivery is paused,
say). Before 0.10.0 these showed only in the bot log, where the warnings still go. The page is
read-only, and like every admin page answers anyone who is not an admin with the unknown page's 404.
Not here yet, though plan 5.10 lists them: grants and retry, and an admin pause of someone else's
task. Of budgets, raising one person's ceiling and the usage page are here; the global ceiling and
the defaults are docket's constants, and turning budgets off is the operator's
TRACKER_BUDGET_UNLIMITED, not a web control. While it is on, the ceiling section says the ceiling
is not enforced; a change is still recorded and applies once it is off.
Forget me (plan 5.8). A signed-in person erases themselves from /forget (web only: the plan
asks for no Discord command, so without TRACKER_WEB_URL the only path is an admin with the web
area). Two posts: the first asks, the second must carry confirm=yes and the word forget. Then,
in one SQLite transaction in the write queue, deleted, not archived:
- every task they own, archived ones too, with everything under it: runs, run events, replies (anyone's), history, series, recipients, deliveries (anyone's), charges and pauses;
- on everyone else's tasks: their recipient rows, their replies and answers, the history rows they
made or that name them (an invitation of them, a pause for them), the run events and deliveries
of DMs to them (docket's
deleteDeliveries), charges made for them, and their pause rows; - the budget notices kept for them (
budget:person:<id>:...) and the changes to their ceiling; - every decline block they are either side of;
- their admission, delivery health, web sessions, unused sign-in links and API tokens, and their person row.
What stays, and why: another person's task that they received stays that owner's; one paused only
because DMs to them failed goes back on (one that cannot finish, on a store error, is named in an
admin's answer and logged with its status then -- still paused, or active with its next run maybe
missing -- and the rest of the erasure stands). Another person's row keeps its own audit fact but not their
id -- admitted_by of someone they admitted, lifted_by of a block they lifted, and set_by of a
change they made to someone's ceiling become
forgotten ("an admin since forgotten"; null already means "the configuration"). Text is matched
only in the forms the code writes a person's id into -- docket's consent rows (u5, u5 24h), the
tracker's u5: ... removals and delivery to {u5} paused pauses, docket's u5 <message> delivery
events, and the notifier's error phrases (recipient u5 cannot be messaged, ...; both written only
before 0.9.0, when a run's own row recorded its DMs) -- never as any
id-like word, so an address with /u5/ in someone else's text is left alone. A history row or a
delivery event of theirs is deleted; the error of someone else's run keeps its row and has the id
replaced with (forgotten). A pause for a recipient now names them by id and shows their current
name when read; a pause row written before 0.6.0 names the recipient's display name of the time,
and is deleted when it is on a task they were a recipient of, names their current name, and no
other recipient of that task has that name. A row written under an older name of theirs, or shared
with a same-named recipient, is kept: the one known gap. A reply other people wrote on the erased
person's own tasks is deleted with those tasks. Outside the store, nothing is touched: the DMs the
bot sent stay in the person's Discord DMs, and the host's log may hold their tracker id. With the
usr link on, their usr account -- which holds their Discord id and tracker:member -- is usr's:
forget-me never calls usr, so it stays until it is removed there. city-hall
keeps its own copy of a research Job -- the spec (the question and context are in its prompt) and
the result -- under city-hall's retention, not the tracker's (Nazu, plan 5.8): forget-me deletes the
tracker's executor_jobs rows for the erased runs (no tombstone is kept; the keys name only the
database and a run id), but cannot reach city-hall's job.
Bytes: the database runs with secure_delete on, so a deleted row is overwritten, and the first
start of 0.6.0 on an existing database runs one VACUUM, so pages freed before that are rewritten
too. That one-time VACUUM is recorded in a small tracker_meta table, not by a schema bump (0.6.0
kept the schema at 3; 0.7.0's API tokens are schema 4, see the CHANGELOG). If the VACUUM cannot run (a
reader holds the file), the start goes on, a warning is logged, and the next start tries again.
Not a VACUUM per erasure: it rewrites the whole file under an
exclusive lock, and secure_delete already covers every later delete. After an erasure the
write-ahead log is checkpointed without waiting; when a reader keeps it busy, that is logged and
SQLite's next checkpoint copies it back.
Timing: forget-me first waits, outside the write queue, until no notify or poll tick is running (at
most 6 seconds, under the bot's 10-second bound on a web request, so the answer reaches the browser;
then it answers 503, try again, and deletes nothing; the queue turn after the wait has no bound of
its own, but nothing slow runs in the queue), so a reminder DM to them
already in flight cannot be recorded after they are gone -- and no one else's command waits behind
it. Inside the queue it only checks that no tick started meanwhile (else the same 503). A DM sent
outside any tick -- an invitation's -- that fails after they are gone writes nothing: the failure
count is not kept for a person no longer in the store, and the withdrawn invitation is not
recorded. A /price whose page is read while its person is forgotten makes nothing. No execute
tick starts from forget-me's wait to its erasure, so a model run every minute cannot keep it busy;
one already running is waited for like the others. Their sessions
are among the rows, so they are signed out everywhere at once, and the browser's cookie is cleared. They can come back only as someone new: an admin
/allows them again, and the tracker never reuses an id.
Choose the web origin's domain with care. A host under the same parent domain as
TRACKER_WEB_URL that you do not control can set a __Secure- cookie on the parent domain
(cookie tossing), and so plant its own session or sign-in cookie in your browser: a login-CSRF.
The __Host- prefix, which would stop that, requires Path=/ and so cannot be used with
Path=/tracker/. Put the bot on a host whose sibling subdomains are all yours.
Alert emails
eBay's saved-search alerts reach eBay watches by mail (0.25.0), while TRACKER_MAIL_DOMAIN and
TRACKER_MAIL_KEY are set. The tracker still never opens eBay: it reads only the alert eBay mailed.
- Each person has one alert address,
m-and 24 random hex digits at the domain, made the first time it is shown: in/want source: ebay's answer and on the web area's settings page. It is theirs alone (whoever knows it can send listings to their eBay watches), and forget-me deletes it. - The route. Cloudflare Email Routing receives the domain's mail and hands every message to the
Email Worker in
mail-worker/worker.js(a catch-all rule, "Send to a Worker"). The Worker posts it, as it came, toPOST /tracker/mail/inboundwithAuthorization: Bearer <TRACKER_MAIL_KEY>,X-Envelope-To: <the address>and, while the web area is behind Cloudflare Access, a service token'sCF-Access-Client-Id/-Secret; the Access application covering/tracker/*then needs a Service Auth policy that includes that token, or Access answers with a redirect and every message bounces asstatus 302. Up to 1 MiB; bigger is413and refused. An address that is no one's is404. The Worker refuses back to the sender, with a permanent error, anything the tracker does not answer200(a503while it starts too), so a mistyped forwarding address, or an outage, bounces to the mailbox that forwarded it; that day's alert is not retried. The Worker is deployed by pastingmail-worker/worker.jsinto the Cloudflare dashboard's Worker editor; it is not in the plugin's npm bundle. - What a message does. Gmail's forwarding confirmation: its code is DMed, to enter in Gmail's
Forwarding and POP/IMAP settings. Since 0.26.0 it is any sender at Google naming forwarding. Since
0.27.0 the code is a run of digits standing alone in the subject or shortly after the word
"code" in the body, and Gmail's verify link (a
/mail/vf-address onmail.google.com,mail-settings.google.comor a subdomain of either, never its cancel link) is DMed too; digits inside an address are never the code. An eBay alert: the matches above "You might also like" (never eBay's suggestions) -- title, the item's bare/itm/<number>address, the price's lower end -- go into the inbox of each of the person's live eBay watches whose words are the saved search's (case and spacing aside), exactly as listings sent through the task API; the watch's next look DMs the new ones. Words match with case, spacing and the marks` * _ ~ | < > @ # [ ] ( ) \ : /ignored. An alert no watch searches for gets a DM saying so, with the/wantthat would watch it, each time eBay sends it. Anything else is dropped. A message is never stored, only the listings read from it, and its words are never logged: one that does nothing is logged only with why (the owner is not registered, or its sender's domain -- a Google no-reply sender whole -- which bodies it had, and whether its subject names forwarding; for Google's no-reply senders also its subject's shape, lettersa, digits9and each address<addr>, and counts from its body as read, plain text else the HTML's text). - Only eBay's own domains (ebay.com, .co.uk, .de, .ca, .com.au and the rest of eBay's sites;
never a lookalike such as
ebay.com.de) count as eBay, for the sender and for every item link. The sender is itsFromaddress, not a checked signature: the private address is the guard, and anyone who learns it can send listings to its owner's eBay watches. A price's own sign gives its currency ($on ebay.com,C $,AU $,£,€); otherwise none is set. The HTML read is capped at 512 KiB, in bounded steps. - Setting it up in Gmail. Settings, Forwarding and POP/IMAP, Add a forwarding address: the alert
address. Gmail mails a confirmation code there, and the bot DMs it; enter it. Then add a filter --
from
[email protected], has the words"Turn off alerts for"-- that forwards to the alert address. Never turn on "Forward a copy of incoming mail" for everything. - The format was read from the first real alert (2026-10-13, one match), kept with its
tracking parameters and account names taken out as
src/fixtures/ebay-saved-search-alert.eml. An alert with several matches is read the same way, which is inferred until one arrives.
Task API
A JSON API over your own tasks, for a program that acts as you -- a script, or the plain-language
intake agent the plan defers (E10, item 31), which would end its dialogue by posting a task here.
It is served under /tracker/api/v1/ on the same origin as the web area, and only when
TRACKER_WEB_URL is set. Every write goes through the same functions as the web editor and the
slash commands -- the same fields, defaults, limits, caps and messages -- in the same write queue.
The Claude in Chrome route (#125) is one such program: this repo's clerk-browser-search skill
(.claude/skills/clerk-browser-search/SKILL.md) searches eBay and BoardGameGeek in the owner's own
Chrome, looking only, and sends listings to inbox watches and pages to research requests through
scripts/clerk-browser.ts, which reads the Access service token and the tracker token from files.
Tokens. Make one on the web area's /tokens page (linked from Settings): a name, and an expiry
of 30, 90 (the default) or 365 days -- every token expires. The token -- trk_ and 43 characters --
is sent to you once, by Discord DM from the bot; it never appears in any web page or response, and
the tracker keeps only its SHA-256. If that DM cannot be delivered (DMs closed), the token is
deleted at once and the page says so: open your DMs and try again. At most 10 live tokens per
person, and at most 10 made per person in any rolling hour, revoked ones counted (each is a DM, so
a script riding your session cannot flood your DMs by making and revoking); the count is kept in
memory only, so a restart clears it. Each lists when it was made, last used (recorded at most once a minute) and expires, and
can be revoked there at once; an admin sees and revokes anyone's from the admin view's page for
that person. Forget-me erases a person's tokens with the rest. The log names a token by its id
(k1), never by its secret.
Authentication. Authorization: Bearer <token> on every request. The web area's session
cookie is never read here: every plugin shares one browser origin, so a cookie-authenticated JSON
API could be called by any script on it. A token acts as its owner with the owner's rights only,
and only on the owner's own tasks -- an admin's token included; an admin's wider reads stay on the
signed-in web pages. Every request re-reads the owner, as the web does a session: a person no
longer on the tracker, or no longer registered, is refused and their tokens deleted; with TRACKER_GUILD_ID set, membership
is re-checked on the web's schedule (after 15 minutes, one check, shared with the web), and one who
has left every listed server loses every token and every session; a lookup that keeps failing
lets them on for 24 hours from the last c
