The catalog had no URLs. ~2,630 endpoints across 80 platform shelves — the whole
substance of the product — existed only as hash routes (/app#platform/<slug>)
behind a login, so a crawler could reach six thin marketing pages and nothing
else. On top of that: no robots.txt, no sitemap.xml, HEAD answering 405 on every
page, no og/twitter tags or image, no structured data, and /docs serving
FastAPI's stock Swagger shell — a kilobyte of JavaScript to anything that does
not run scripts.
Crawler plumbing:
- /robots.txt (bundled, {BASE}-templated) and /sitemap.xml (generated — 80 of
its 88 URLs come from the catalog, lastmod from file and catalog mtimes).
- HEAD widened onto every GET route. FastAPI's APIRoute pins methods to {"GET"}
and never adds HEAD, unlike Starlette's plain Route.
- Canonicals on /support (collapsing /contact and /help), /terms, /privacy,
/tutorial; noindex on the dashboard and on /vendor-listing.md's duplicate URL.
- robots.txt and sitemap.xml 301 from the legacy host, so its name resolves to
one crawlable site rather than a duplicate of it.
Crawlable surfaces:
- /catalog and /catalog/<slug>: server-rendered, no JavaScript, every capability
and price as real text. Registered after the JSON routes, with the reserved
names refused explicitly.
- /docs: a real API reference built from app.openapi(). Swagger UI moves to
/docs/api and is disallowed; the public catalog routes join the schema.
Metadata:
- og/twitter cards everywhere, backed by a new 1200x630 card rendered from
assets/brand/og-card.html. Every brand on it is a real provider.
- SoftwareApplication + Offer + Organization on the landing, ItemList +
BreadcrumbList on the catalog pages, FAQPage over the five questions already
written on /support.
Two things this had to be careful about. Widening HEAD put 58 duplicate
operations into the public openapi.json, so _openapi_without_head() narrows the
widened routes for the duration of schema generation. And landing/legal/tutorial
pages now read-and-substitute {BASE} instead of being served as bare files: a
hardcoded treg.to canonical tells a self-hosted registry's crawler that the real
page lives on someone else's domain.
Counts reconciled to the live catalog (2,630/47); the landing said 2,617/42 and
llms.txt said ~2,600/~48.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
68 KiB
title, status, sources, related
| title | status | sources | related | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| The web dashboard (Ledger, served from FastAPI) | shipped |
|
|
Web dashboard (Phase 1)
A single-file Vue 3 (CDN) dashboard in src/treg/web/index.html, served same-origin by the API
(GET / → FileResponse, dashboard() in api.py, via _WEB_DIR). Same origin = no CORS and it
ships with the server (Render/Fly). Design language: Ledger (warm charcoal + clay accent,
mono-forward, dark default + light toggle) — see docs/style-board.html / docs/DASHBOARD-PLAN.md.
Shell & design system (2026 rework)
The design tokens are now shared across every served page (index.html, tutorial.html,
tour/index.html): system mono (ui-monospace, "SF Mono", … — IBM Plex Mono was never actually
loaded, so this makes rendering consistent for everyone), --r:14 / --rb:9, a 14px base, and one
shared .btn / .iconbtn height so controls align. The logged-out / is now the landing + sandbox
studio (see landing-sandbox), not a login box; sign-in is a modal.
The authed shell is sidebar-first. The top bar is just brand + search. The left sidebar
stacks: (top) an org block — role + team name — that on click opens a switcher dropdown where
each team carries its own ⚙ Settings (orgSettings → switch into it, then open its settings) and
Switch (switchTo) button (long names truncate, actions pinned right; the click-outside handler
keys on .orgblock); (middle) the nav — Tools · Secrets (member+) · Marketplace (member+, the
OAuth-connect view — go('connections')) · Activity · Usage (admin/owner) · Team · Getting
started · Admin, then a Help group of two external links (Open source (the GitHub repo) ·
Discord community) —
the Tutorial nav entry was removed 2026-08-12 in their favor; the help view itself survives and
is still reachable (welcome flow, in-app links); (bottom)
the account block — avatar · email · theme · sign out. The old top-bar org dropdown and top-right
account controls are gone.
Auth — three doors
Two are session (cookie) paths, one is a token fallback:
- GitHub (
githubLogin):Continue with GitHub→/auth/github→ callback sets a signed HttpOnly cookie (session.pyHMAC). (Note: the button routes through agithubLogin()method — a Vue template expression can't reference thelocationglobal.) - Google (
googleLogin):Continue with Google→/auth/google→ callback, same cookie session as GitHub. The button shows when/metareportsgoogle:true. - Email one-time code (
emailStart/emailVerify): enter email →POST /auth/email/start(dev code shown inline) → enter the code →/auth/email/verify, which sets the same session cookie, so the dashboard justlocation.reload()s into session mode — identical to GitHub. - Either way the dashboard authenticates with the cookie (
credentials:'include') + picks the org withX-Treg-Org, detected via/auth/meon load. CookieSecureonly on HTTPS (_is_https). For copy-paste convenience it also fetchesGET /auth/cli-tokenon load intomyToken— sent WITH the active-org header, so the minted identity token is team-pinned (sess.make(..., org=slug), a statelessorgclaim; the endpoint only pins a team the caller is a member of). That is the point: the "API key" then works as a bare bearer — pasted into an MCP server'sAuthorizationheader, where noX-Treg-Orgcan travel, it still resolves to that team (fixing the "several teams, none active" wall a multi-team user hit).require_memberuses the token's org claim when no header is sent (the header still overrides), and the MCP layer surfaces it via_internal_auth.myTokenis re-minted whenever the active org changes (_myTokenOrgguard) so it always names the shown team. The per-tool snippets embed it (+X-Treg-Org, now redundant but harmless), and "Copy API token" (copyToken) puts it on the clipboard. - "For your agents" sidebar — ONE copyable agent instruction (a prompt to paste into Claude
Code / Codex), built client-side by
buildAgentPrompt(kind, inclToken)with the caller's minted token + active org slug baked in. It was two prompts (admin "Setup" / consumer "Connect"), each ~30 lines that re-explained the catalog, the ladder, prices and the balance — knowledge that lives in the skillinstall.shinstalls and in/llms.txt. A prompt can only carry what a FILE cannot: the token, the team, permission, and "do it now". Sokindis now ignored (call sites keep their argument; there is one text to keep true), and one instruction serves everyone because it needs no path chosen up front — step 4 (find the catalog tools that fit this project, price them, call one on approval) works with nothing registered, and step 5 offers the sharing path only if the user has keys worth sharing. The row's ⧉ (copyAgentGuide) copies with the token embedded (paste-and-run); clicking the label opens a preview modal (agentGuide) with an Embed my token toggle (agentInclToken) → off yields a<YOUR_TOKEN>placeholder that's safe to share. Plus the API token copy-row (copyToken). - Token fallback (agents/CLI): paste one
X-Treg-Tokenper org intolocalStorage(treg-dash).
On load, loadAll fetches /invites/mine and shows an invite banner (acceptInvite → POST /invites/{id}/accept). The Organizations view also has ⤷ Join by code (joinByCode → POST /invites/accept {code, email:me}) for a code handed over out-of-band. On first load it lands on the org
with the most tools (from /orgs' tool_count); a tie / all-empty falls back to a team org over
the (usually empty) personal one (isPersonal(o) = org name == your email) — so imports living in the
personal space are no longer hidden behind an empty team. It tags the personal org personal, and the
empty Tools state offers jumpToTeam().
The session cookie is HMAC-signed with TREG_SESSION_SECRET/TREG_SECRET_KEY, falling back to a
random per-process key when neither is set (never a source-visible constant — that would make
cookies forgeable for any uid, incl. a superadmin). It also carries a tv (token_version) claim
(sess.make/read_claims); a mismatch against User.token_version = revoked, so POST /auth/revoke-tokens can invalidate a user's cookies + CLI tokens without rotating the shared secret. In token mode the dashboard carries org_id
on the active org (so activeOrgId resolves and org-admin writes work), fetches me via /auth/me
(so isPersonal + join-by-code work), and persists a newly-created org's returned token so the team is
enterable; leave/delete forget the active org in both modes. On an org switch, loadAll refreshes the
open Secrets panel + Activity log too (was showing the previous org's). Copy buttons fall back to
execCommand and only claim success on success; the app shell has a mobile breakpoint.
Support chat (Intercom): initIntercom() sits next to initAnalytics() and follows the same
opt-in gate — no meta.intercom_app_id, no widget. It boots after /auth/me resolves (not at the
/meta fetch) so the common case boots identified once: email + user_hash (from /auth/me,
see api) + company = the active team slug. Email is never sent without user_hash (that's the
impersonation vector identity verification closes) — no hash or no login means anonymous visitor
chat, which is also what landing.html/support.html do with a tiny /meta-gated inline loader.
switchOrg/team-create call intercomUpdate() so the company tracks the active team; logout()
calls Intercom('shutdown') so the next user on the machine can't read the previous conversations.
Server side (api.py): require_identity (who, from token OR session), require_member (a Caller in a
specific org — token bakes the org in; a session picks it via X-Treg-Org), and require_superadmin
(env token, or a token/session whose user is_superadmin). Every fetch also sends
ngrok-skip-browser-warning: 1.
Screens (all read, plus try-it) — wired to existing endpoints
- Tools —
GET /tools+GET /bundles+GET /health, rendered as a segmented tabular home (toolTab: All / Endpoints / Skills / Recipes): Endpoints = tools with nobundle_id(registered directly), Skills = tools with abundle_id(came from a skill package, carry a recipe), Recipes = bundles with no tool. Per-tool Copy (syntax-highlighted snippet builder — cURL / CLI / Claude Code / Python / Node, cURL default; embeds the real token via aTREG_API_TOKENvar + shows it ellipsized;samplePath(host)/health-check/example fill a runnablePATH) and Try it (a real* /call/<tool>/<path>). Recipes get their own actions: an Install modal (cURL/CLI/Claude Code — install, not call), a view/edit modal (openRecipeView→PATCH /bundles/{id}to save the SKILL.md; creator/admin only), and delete. On first load the app lands on the org with the most tools (/orgs'tool_count; tie → a team). A tool that carries a local-run profile (t.cli) shows a⌘ runchip and a toggle button;toggleLocalRunflipscli.enabledviaPATCH /tools/{id}and, on enable, shows a dismissible restricted-key reminder (the key reaches members' machines during a run — see local-run). A run-tier chip shows where it runs: server (t.server_runnable— the key is injected server-side, never on a member's machine) or local-only (aconfig_file/deviceCLI that authenticates from the member's own machine). - Add a skill — a folder importer (
<input webkitdirectory>): reads the picked folder's files client-side →POST /skills/analyzeclassifies each (recipe/tool/needs-creds, the CLI's own scanner) → a preview with fill-in for any missing secret →POST /skills/importregisters the selected. The preview also shows a local-run line per skill (skillCliNote): contract-declared, catalog-known (available once an owner enables it), or explicitly unsupported with the reason. The raw JSON payload is an "Advanced" fallback.api()retries a WAF-blocked body base64-encoded (see WAF note). - Secrets — its own sidebar view (
view==='secrets',canRegisteronly): a table of secrets (kind chip + owner + delete) + a multi-row add form (secretRows) whose kind select includesparam(a non-secret value like a project id, shown in clear text with a helper line);GET/POST/DELETE /secrets. Pasting a whole.envinto the name field splits it into editable rows client-side (Render/Vercel-style —pasteEnv→parseEnvText: comments/blanks skipped,exportstripped, one balanced quote pair removed). A single-lineNAME=valuesplits only in the name field — a value containing=(base64 pad, connection strings) pastes untouched into the value field; multi-line splits from either field. The name field carries a<datalist>of pasted-secret provider services (keyNameSuggestions—auth_kindkey/token, the names the marketplace ladder's tier 2 matches bySecret.name == service), and each row gets asecretNameHintline: an exact service name is confirmed ("Apollo.io catalog calls will use this key automatically"), a near miss (APOLLO_API_KEY,tikhub-key— suffix-stripped, case-folded) gets the exact name plus a one-click rename link, and any other name gets silence — most secrets back the org's own tools and can be called anything. Providers are loaded on entering the view (go('secrets')also firesloadConnectionswhen the list is empty) so the suggestions exist on a cold deep link. - Team (
view==='orgs') — the active team, now split into tabs (orgTab): Members · Projects · Policy · Billing · Team settings. (The former My teams tab is gone — it duplicated the sidebar picker; its New team / Join by code / Paste token actions live in Team settings now.) Header readsTeam: {activeName} — you are {activeRole}and points at the sidebar picker for switching.- Members — the roster (role, daily cap, today's usage, tool ACL, project scope, local-run toggle).
Inviting is no longer a separate section: it sits here behind an "+ Add member" toggle
(
showInvite), which is where people look for it. The inline access editor now carries project checkboxes (projDraft) beside the tool ones, each collapsing "all checked" back to all so future tools/projects are inherited; a zero-project team gets a pointer to the Projects tab instead of a silently-missing section.list_membersreturnsis_agent, so people and machines are distinguishable in one roster. - Projects — create / list / delete, and the tool count is now a button: it expands an inline
project editor (
editProj/projToolDraft) listing every tool with a checkbox — check to add, uncheck to free back to team-wide; a tool living in another project is chipped and checking it moves it (saved as per-toolPATCH /tools/{id}). The tab copy states the deliberate design: secrets stay team-level. Delete still states that tools become team-wide rather than deleted. - Policy — deny rules (host / path / method / who / project / note), listed and removable.
The "who" select labels agents; the project select scopes a rule to one project's tools. Below,
Per-tool CLI blocks (read-only, from
GET /orgs/{id}/policy/cli-deny) lists each CLI tool's argv deny patterns with their source (skill vs catalog), under a line naming all three deny layers — HTTP rules, argv patterns, OS sandbox — so the whole "what is blocked" picture is one screen. - Billing (admin+) — balance, the top-up presets, the auto-top-up toggle with its verbatim
PSD2/SCA mandate text, and below them Payment history: date, amount, an
automarker, and one link per row — Invoice when Stripe issued one (manual top-ups do; automatic ones can't), else Receipt, else an em dash. Amounts come from our own ledger and the links from Stripe, so when Stripe is unreachable the table still renders and a line under it says the links, not the numbers, are missing. A Manage billing button opens Stripe's hosted portal (card, billing address, tax ID, the full invoice archive); it is hidden untilbilling.portalis true, which needs a Stripe customer, which a team gets on its first payment — so a new team never sees a button that errors. - Team settings — deliberately JUST the Danger zone (leave / delete), visible to EVERY role
(leaving is self-service, and
loadOrgAdminlands a non-admin here). New team / Join by code / Paste token live only in the sidebar picker — cut from this tab on founder review; a personal team shows a one-line explainer instead of an empty page.
- Members — the roster (role, daily cap, today's usage, tool ACL, project scope, local-run toggle).
Inviting is no longer a separate section: it sits here behind an "+ Add member" toggle
(
- Agents live INSIDE Team → Members now — the separate Agents page and its sidebar entry are
GONE (founder call on review: an agent IS a membership, so two rosters was one too many). One roster
(
rosterMembers): each person, then↳the agents they minted (short name + "owned by", never the machine address; Setup / Rotate / Revoke actions inline), then the runtimes detected in their traffic (observedAgents, fromGET /orgs/{id}/agents/observed) as↳ codex · detectedrows with a Scope this agent button (promoteObserved) that opens the Add-agent form prefilled and linked (promotePending→promoted_from, so the detected row disappears on Create and returns on revoke). "Add to this team" toggles + Add member (invite) | + Add agent (mint form: name / role / cap + project pickeragentProjSel, all-checked = omitted = every project). The once-only token card renders at the top of the tab. Rotate (re-POSTs the same name, so the old token dies), Revoke, and an inline cap editor. Rotate sends only{name, role, daily_call_cap}on purpose:create_agentleaves every field the client does not send exactly as it was, so the agent's tool ACL and project scope survive the rotate. They used to be silently cleared by this very button — see multi-tenancy. A minted token appears in an accented card that says it is shown once; under it are three paste-ready snippets (agentSnip: Environment / Give it to your agent / Verify) built by theagentSnippetcomputed, mirroring the Tools snippet block. Because a stored token is hashed and unrecoverable, each row also has a Setup button (showAgentSetup) that reopens those snippets for an existing agent using$TREG_TOKENas a placeholder, telling the user to Rotate if the token was lost. Choosingrole=adminraises a confirmation spelling out that an admin agent can register/delete tools and secrets and manage members. - Tool form — Add/Edit now carries a Project dropdown (default "team-wide");
loadProjectsIfNeededfetches the list on demand so it works even if the Team page was never opened. - Getting started (
view==='start', the FIRST nav item, above Catalog; the sidebar's team half is labeled "Your vault" — Jason's explicit naming call, overriding the no-"vault" vocabulary rule) — two numbered step cards (.start-card): ① Set up your <agent> — an agent dropdown (startAgentOpen, shareswelcome.agent+welcomeAgents/welcomeMoreAgentswith the first-run modal; "Docs" links/tutorial) above the per-agent setup line (welcomeSetupCmd=buildAgentPrompt—set up treg — <proxy>/llms.txt with token <T>, team <slug>; the long multi-step agent prompt is retired, llms.txt itself now carries the setup flow, the do-not-stop authorization framing and the star ask, and its money rules no longer demand per-call price confirmation) and, combined into the same card, Your API key (myToken, masked with astartTokenShowreveal + copy — the key already exists, so there is no "create key" step). ② Try it out — four copyable example prompts (tryExamples, category-labeled.try-cards: Social/Trends/SEO/Enrichment — concrete live-data asks a bare agent can't answer), then an.oauth-divdivider ("also connect OAuth accounts for new agent capabilities") over three grouped rows of.prov-chiplogo chips (tryOauth— Post on social: X/YouTube/TikTok/LinkedIn with a dotted "Ncoming soon".soon-notewhosetitletooltip lists Instagram/Facebook Pages · Manage ad campaigns: Google/Meta Ads · SEO on your own site: GA/GSC/GBP) — each chipopenProvider(service)into the marketplace connect page. Below, a collapsed<details>("Prefer the terminal?") keeps the old two-tab manual walkthrough (startTab: Access install → login → catalog search/call/balance; Setup scan/upload then the team-use commands). Per-block copy viacopyStart. - Activity — one time-sorted feed (
activityRows) mergingGET /calls(proxy calls) +GET /runs(CLI executions). Local runs now arrive via/runs(taggedwhere), so the calls feed excludeslocal_runrows to avoid double-counting, and each run row shows a local/server chip. - Admin — nav auto-appears iff the caller is
is_superadmin(read from/auth/meon boot — not by probing/admin/stats, which would 403 + log a console error on every load for normal users);loadAdminfetchesstats+orgs+usersonly when you open the panel. Showsstats+orgs+users, each with mutations (_admhelper):admGrant/admSuspendUser/admDeleteUser,admSuspendOrg/admDeleteOrg(inline-confirm deletes). Self-actions are hidden for the current user (u.email===me) to prevent lockout. - First-run onboarding — a brand-new user has zero teams (no auto personal org), so
maybeOnboardshows a mandatory "name your team" welcome (welcome.*; team name pre-suggested from the email domain via_suggestTeamName). Step 0 is NOT dismissable — no skip, survives Escape/backdrop — the only action iswelcomeCreate(POST /orgs, marks onboarded). Three more steps follow inside the same modal: an agent picker (welcome.step===1— OpenClaw / Hermes Agent / Claude.ai / Claude Code / Codex, plus a "More" expander with opencode / pi / Cursor / Gemini CLI / Other; LobeHub icons via unpkg, theme-aware light/dark variants throughagentIcon) with Skip/Next; the setup block (step===2— "In your agent's chat, send:", the setup line + team/token as one unit —welcomeSetupFullcopies with the real token,welcomeSetupMaskeddisplays it masked behind a Show/Hide-key toggle) with Back/Next; and "Try it out" (step===3, wider modal — a "waiting for your agent" status line (pulsing.wc-waitdot, no 🎉), thetryExamplescopy cards and thetryOauthconnect chips, footer "Skip"welcomeFinish+ "Browse all catalog →"go('connections')). Skip and Go-to-Getting-started callwelcomeFinish→ close +go('start')(Getting started, also the default landing for any signed-in arrival with no deep link/hash). Exception — an invited user:maybeOnboardcheckspendingInvitesfirst and, if any, shows a multi-select accept-invite modal (inviteChoice/openInviteChoiceseedsinviteSelwith ALL invites checked;sortedInvitesputs the clicked link's team —inviteLinkOrg— first) — "Accept & join N teams →" →acceptSelectedInvites(loopsPOST /invites/{id}/accept, partial failures land ininviteErr, switches into the linked/first joined team, lands on Tools with a "You joined X, Y" notice) ordeclineInvite→ the welcome modal on first run ("Create my own team instead"), plain "Not now" otherwise. The modal ALSO opens for an already-onboarded user when an invite link lands (?invite_org=set by the email link's POST — a second-team invite must surface too); a deadinvite_org(already used/revoked) shows anorgMsgbanner instead. Critical ordering:loadAllfetches/invites/minebefore its!myOrgs.lengthearly-return — an invited user has zero orgs, so the old order skipped the invite fetch and forced create-team. The invite email link signs the invitee in —GET /auth/invite-signin?t=<email_token>(email.send_invite; the token is an inbox-only second secret, split from the admin-visible code): the GET shows a POST-confirm page ("Continue as {email} →"), the POST mints the session (one-time, consumes the token) and 303s to/?invite_org=<org_id>; boot strips the one-shot params (history.replaceState) and stashesinviteLinkOrg. Legacy?code=links never mint a session — they 303 to/?invite=<email>for a normal login (boot prefillsemailInput+ opens the sign-in modal); the invitee proves the email at a real door and the invite auto-appears via/invites/mine(newest-first). Invalid/expired →/?invite_expired=1. Neither path consumes the invite (stillpending); the accept modal does.loadAllshort-circuits the org-scoped fetches whilemyOrgsis empty so no error banner flashes behind it. The old demo/guided stepper (onb.*) is removed entirely. Ademochip marks a sandbox org. - Tutorial (
view==='help'; no nav entry since 2026-08-12) — the full interactive walkthrough, rendered natively (Vue) from the sharedwindow.TREG_TUTORIALdata (tutGo/tutHL/tutCopy, syntax-highlighted command + output blocks, persona chips, and four toggle panels — Concepts · Roles · Auth shapes · Skills). The standalone/tutorialmirrors it and opens a panel from the URL hash (/tutorial#auth,#skills). See below.
Marketplace — the in-browser OAuth-connect UI (view==='connections' / 'provider')
The dashboard now runs the whole hosted connect flow in the browser, so a member can attach a
provider account (Google Analytics, Search Console, Google Ads, Slack, Meta/Facebook/Instagram, X,
TikTok, LinkedIn, YouTube, …) without touching the CLI. loadConnections fetches GET /oauth/providers
(server route oauth_providers_list → oauth_providers.listing(), each row carrying service,
display_name, category, summary, capabilities, scope_detail, auth_kind, supports_discovery,
and a configured flag = whether this deployment holds the provider's client credentials) plus
GET /connections (list_connections — the org's existing grants).
The list view opens on a tab bar (.mk-tabs, mkTabs computed): All, then one tab per catalog
category derived from the data, then Platform. The middle is deliberately not a hard-coded list —
categories keep changing (Social media became Social, China Social is new, and the AI-search shelf
has been called both AI Search and AEO / GEO) and a hard-coded list silently drops the tiles it does
not name. The strip scrolls with its scrollbar hidden (scrollbar-width:none +
::-webkit-scrollbar{display:none} on .mk-tabs); a right-edge mask-image fade is what hints there is
more, and it falls on empty space when the tabs all fit. The rule under the tabs lives on the
.mk-tabs-wrap parent, because a masked border fades out 26px short of the right edge and reads as a
rendering fault. Every tab but the last is the platform axis
(which data you want — see the catalog section below); the last tab, Platform, is this integration marketplace
(which account you hold): providers grouped by category (providerGroups computed →
shownGroups filtered by the mkCat chip row) as a list (.prov-list, one .prov-row per
provider), not the old card grid — this tab is where "bring your own key" lands, and forty cards put
every name on a different left edge; a row keeps them in one scannable column (name + truncated summary,
auth kind, connect state, and an inline Connect / Add key action that calls startConnect(p) right
from the row — the tokenAsk/capAsk modals are global, so the common path is one click). The whole row
still opens the provider page (openProvider(service)); each row has id="prov-<service>" so a
goByok(service) jump can scroll to it and flash it (.prov-row.focus, byokFocus state, self-clearing).
Rows show the provider logo served by convention from
/logos/<service>.svg (.plogo-tile/.plogo, @error hides a missing file) — the StaticFiles
mount _LOGO_DIR (src/treg/web/logos/). connCount labels how many accounts are already connected.
The tab bar itself is v-if'd on plats.list.length and mkTabActive collapses to 'platform' when
the catalog is absent, so a build that predates /catalog renders exactly the old marketplace.
The catalog page's header carries a Request a tool button (reqAsk modal): a short form —
what's missing, an optional note, a contact field only when signed out (!me) — POSTed to
/tool-requests with source: web; the capability input pre-fills from the live search box q,
because the button is most often pressed right after a search found nothing. Beside it sit a
Bring your own key button (goByok() — the ink-fill primary; it only switches to the Platform
tab, but naming the action is what makes the tab findable) and
List as vendor (vendorAsk modal): a vendor pastes the
two-sentence prompt (vendorPromptText, built on proxy = the server's public URL) into their own
coding agent, which follows the hosted GET /vendor-listing instructions and raises the listing PR —
including the vendor's contact email, which is how a test credential gets arranged.
One integration is its own view (view==='provider', mkProvider/mkConns keyed on mkService) at a
shareable path /app/marketplace/<service> (server route dashboard_marketplace — plain SPA, no
og meta, since the page is only meaningful to a signed-in member; client route mkFromPath/openProvider
push /app/marketplace/<service> into history). It lists the connected accounts (each account = its own
tool name so an agent can call a specific one), their health/expiry chips, and a Permissions panel
(mkGranted marks which capabilities are already granted; scope_detail gives the exact upstream scopes
on hover).
Consent disclosure. A provider row may carry a consent_notice, rendered as a .mk-notice panel
in two places: under the summary on the integration page (beside Connect) and inside the capAsk modal,
i.e. everywhere the popup can be triggered from. It is a plain surface, not .banner — .banner is red
and would read as a failure rather than something to read before consenting. Today only the Meta family
(facebook, instagram, meta-ads) sets one: their shared Meta app is registered as Crewlet, so
Facebook's consent screen shows a name the user never saw on treg. The template is v-if'd on the field,
so nothing here names those three services; adding a notice is a registry edit (see
architecture/auth-secrets.md), not a dashboard edit.
Connecting (startConnect picks the shape): a token provider (auth_kind==='token' — "bring your
own bot") opens the tokenAsk modal → submitToken → POST /connections/token (connect_with_token);
a provider with 2+ capabilities opens the capAsk modal (capLabel/capHelp explain each, e.g. TikTok's
weaker draft) → chooseCapability; otherwise it goes straight to connectProvider. connectProvider
does POST /oauth/start then opens the consent screen in a popup and polls GET /oauth/status/{state}
every 2s (state stays in the dashboard rather than relying on the popup talking back); on done it re-reads
/connections + loadAll, and if the provider supports_discovery and no resource is chosen yet it opens
the resource picker. Post-connect setup: openResources (GET /connections/{id}/resources,
connection_resources — a live upstream round-trip, so the modal opens first with a spinner) → chooseResource
(POST /connections/{id}/resource) sets the default account/property; saveExtraCred (POST /connections/{id}/extra-credential, set_extra_credential) supplies a second credential a grant needs to
be callable (e.g. Google Ads' developer token — surfaced by the needSecondCred/mkNeedsCred banners);
enableCapability/reconnect re-run consent to widen scopes or refresh a staleConns credential; disconnect
(inline-confirm → DELETE /connections/{id}, revoke_connection). Server-side, a successful connect
auto-provisions the tool (_autoprovision_provider_tool) and records the account identity/resource labels
(_record_connected_identity, _enrich_resource_labels). The three post-connect dialogs (tokenAsk, capAsk,
resPick) live at app-root level, not nested in the view — a nested copy failed to render on an integration
page (Connect looked dead).
Endpoint catalog — the platform axis of the marketplace (view==='platform')
This is the signed-in browse surface, and it is a hash route (
/app#platform/<slug>), so none of it is reachable by a crawler. The same catalog is now also served as plain HTML at/catalogand/catalog/<slug>from the sameGET /catalog/platformsdata — see seo. The two render from one shared row builder (_platform_rowsinapi.py) so they cannot disagree.index.htmlitself carriesrobots: noindex: every view here needs a session.
The marketplace's second browse surface answers "what data can I actually pull?" rather than "whose
account can I attach?" — see architecture/catalog.md for the data behind it, and it is the marketplace's
default view. loadPlatforms reads GET /catalog/platforms (once per session; cached on
plats.loaded), whose rows carry a category and a featured rank (int|null). platCategories
groups the rows by whatever category they carry, sorts those groups into the founder's canonical
reading order (SEO · Social · Advertising · Enrichment · E-commerce · Reviews & Apps · China Social ·
Community, then anything new alphabetically) and drops Other — the taxonomy's bucket for things like
account, whose capabilities only make sense inside a platform page, never as a tile. The order list is
only an order: a category the catalog invents still gets a shelf and a tab, at the end — but at the end
is where a RENAMED category silently lands, which is how AEO / GEO once sorted after Community, so a
rename means editing this list. The answer engines (ChatGPT, Perplexity, Gemini, Claude, Doubao, AI
search overall) were that shelf twice over; they are unfeatured SEO platforms now, which lands them in
SEO's overflow row rather than in a category of six. platCatGroups is that list whole on the
All tab and filtered to one entry on a category tab, so the section headings never disappear and the
page never loses its place.
Featured shelves. A category of 14 platforms is a wall you scroll past rather than read, so past 8
tiles platCatGroups shows only the rows with a featured rank as full tiles and collapses the rest
into one .pt-more row: a stack of the hidden platforms' little marks (.pt-mini) plus
moreLabel(rest) — "See Zhihu, WeChat Channels, and 6 more" — two names so the row says what kind of
thing is hidden, then a count, then an arrow that slides on hover — the row's whole affordance. It is
deliberately NOT a dashed box: a dashed border reads as a drop zone or a placeholder and drew more
attention than the cards above it. Clicking sets platShelfOpen[category] and the shelf renders whole,
inline — which is how the six answer engines surface under SEO. Tiles sort by rank ascending, then endpoint count descending (the unranked tail has nothing
else left to sort by). Two guards: a category whose rows are all unranked is never collapsed (an empty
grid over a "more" row would hide the whole category behind a click), and the shelf header's count is
g.total — the whole category, not the visible tiles, so "SEO 5" can't sit under a tab reading "SEO 10".
Each shelf is headed by a real heading (.sec-head → <h2 class="sec-h"> at 19px semibold, a count
pill, and the one-line explainer under it) rather than the small muted caption the rest of the dashboard
uses for table groups. The marketplace is browsed by category, so the category has to be the loudest
thing on the page after its title; as a caption the whole surface read as undifferentiated.
The platform card (.pt-card, a 320px-min responsive grid) carries everything needed to choose a
platform without opening it, and every field comes off the /catalog/platforms row — no detail call:
- Head — the platform's own mark (
/logos/platforms/<slug>.svg, a second convention alongside the provider logos), the short label (platShortdrops the— gloss/(parenthetical)the catalog labels carry), and the category as a muted subtitle. The name wraps to two lines rather than ellipsising: "Google Search Con…" is a card that cannot say what it is. - Connection state (
.pt-conn, top-right) — a greenConnected.chip.gowhenplatConnectedfinds any provider serving this platform already connected, else a muted "not connected". This corner used to carry a provider logo stack; it now answers the only browse-time question that changes what you do next — call it today, or sign up first. Which provider serves it is a decision for the platform page, not a fact worth a card slot. - No summary paragraph. It repeated what the name and category already said, and made every card
tall enough that a shelf of twelve became a scroll — cards went from ~200px to ~110px when it came
out, which is what left room for the name to wrap. The data is still served and still used: it is
the card's hover
title. - Footer — the endpoint count on the left, and on the right the starting price from the row's
price_from, viaplatPrice(see Prices are unified USD below). The USD figure stands alone — the native amount ("1 credit") used to trail it in parentheses and broke the card layout on long prices; it lives in the hovertitlenow. Aprice_fromthat exists but publishes no number renders nothing — "from —" says less than silence. "From" is a floor, and anoauthintegration among the platform's providers makes the floor $0 (the account you connect is the licence): any OAuth provider ⇒ "free with your account", even when metered providers also serve the platform and publish a rate — Google Ads is served by its own OAuth integration and by scrapers, and must not read "from $0.00188". The metered rate moves into the tooltip ("without it, metered providers serve this from …"). A key-auth provider with no published rate stays silent. Note thatprice_fromarrives asnullor as an empty{}, and the empty object has to be normalised to null first — being truthy, it otherwise short-circuits the auth-kind branch and silently costs an OAuth-only platform its "free with your account".
Prices are unified USD. Every price the marketplace displays — the card footer, the capability card's
"from", and the per-endpoint cost chip — is the server's computed usd field on cost / price_from,
formatted by usdNum: two significant figures under a dollar ($0.015, $0.00015), cents at or above one.
The FX table lives in the catalog (fx.yaml) so a rate refresh re-prices every surface at once, and the
dashboard carries no conversion constant of its own — one here would drift from the CLI the moment the
table changed. Wherever the provider bills in something else, the native figure follows as a muted
.cost-nat suffix (¥0.10, two decimals — money keeps its cents) with the conversion spelled out in the
tooltip, so nobody has to wonder whether we invented the number. Two carve-outs: type: free keeps its
"free" / "free with your account" wording, and a quota_rows price is excluded before usd is read —
it is a row count, not money, and the server would convert it into dollars quite happily.
Every catalogued platform is currently drawn, but a missing file falls back through
@error → platLogoBad[slug] to a generated initial tile coloured by a
hash of the slug (platTileBg), stable across reloads and needing no colour table. Everything catalog-related is
additive and failure-tolerant — loadPlatforms swallows its error, so a deployment whose build predates
/catalog shows the marketplace exactly as it was rather than an error or an empty section.
A platform is its own view (openPlatform(slug) → loadPlatform → GET /catalog/platforms/{slug}).
Unlike /app/marketplace/<service> it is a hash route — /app#platform/<slug> — because there is
no server route that would serve the SPA for a hard reload of a /app/platforms/<slug> path; boot and
popstate read it via platformFromHash. The page is ONE ledger — a single table sectioned by
DOMAIN (user · video · search · shop · …) — rendered from the response's domains[], which the server
has already ordered and merged (catalog_store.domain_rows). The old per-capability card stack made the
shape of a platform unreadable: every job looked the same size and nothing could be compared without
opening two cards.
Its header is .plat-head, a single stacked column — mark + title on one full-width line, the intro
under it at a readable measure, then the providers as their own wrapping .plat-provs row. It
deliberately does not use the two-column .tut-head the other pages share: a platform can be served
by a dozen providers (people has 8, companies 10), and as a right-hand column that chip list takes
half the width and wraps the title into a three-line ribbon ("People & / contact / data").
Sections are domains, other always last. A domain is the subject an endpoint is about within its
platform, resolved once at load time (catalog_store._domain): an explicit domain: in the yaml, else
the capability id's middle segment (tiktok.video.comments → video), else a keyword read off the
path — never the summary, since prose says "the Live SERP API…" about endpoints that are nothing of
the kind — else the path's grouping segment (/v3/backlinks/anchors/live → backlinks, with delivery
modes like /live, versions and /json stripped first), else other. Sections run busiest-first with
other pinned to the end: it is the junk drawer, and its position is the one that carries meaning.
A domain section renders only if a browse row lands in it, and all the plumbing collapses into one
section. The page loads ?include_hidden=1, so account/utility endpoints arrive tagged by
kind; they are the provider's own machinery (webhooks, saved lists, token exchanges, enum lookups),
not the data anyone came to browse. Filing them per-domain conjured sections that existed only because
a hidden endpoint carried that capability id — the People page grew CAMPAIGNS 0, LOCATION 0, PERSON 0,
SCHOOL 0, TITLE 0, each with nothing in it but an expander. So: a domain needs at least one visible
row to exist, and every management endpoint on the platform lands in a single collapsed
Actions section at the foot of the ledger, its domain ignored, counted in its heading
("Actions · 24"). Inside, they render as ordinary rows plus a kind chip — account vs utility is the
only thing distinguishing one from the next. A platform with no such endpoints (telegram) grows no
Actions section at all. Because Actions is platform-wide rather than a domain, selecting a domain chip
hides it rather than filtering it, and neither the chip counts, the All count nor the
N rows · M endpoints line ever counts it — opening Actions must not make the browse surface appear
to grow.
Within a section, merged rows lead. A capability two or more providers implement is ONE row —
that comparison is the reason the catalog groups by capability at all, and burying it under fifty
single endpoints is how the old page hid it. Everything else is a single row led by the endpoint's
name (its curated short title), falling back to a clipped summary — clip(…, 90) cuts at
a word boundary and the .lsum b two-line clamp catches the rest, because a summary is documentation
prose and DataForSEO's run to a paragraph. The full text is never lost: the clipped row keeps it in a
title attribute and the expansion shows it whole. The capability id stays in the data as a join key
and never becomes a heading.
The merged row's middle cell is a strip of THREE pills and a +N — never four, and it cannot
wrap (flex-wrap:nowrap; overflow:hidden). A wrapped strip gave the shelf ragged row heights and
left the title cell's border ending mid-row; a collapsed merged row is now exactly as tall as a
single one (37.5px on every row of tiktok, web and google). The hidden providers' names ride in the
+N chip's tooltip, and the full list is one click down on the sub-rows.
A pill is per provider (provPills), not per endpoint — TikHub's four takes on the same job
would otherwise repeat four identical pills — carrying the provider's name, its cheapest priced
endpoint's price, and a ✓ if any of its endpoints is verified. Since only three are ever shown they
are sorted cheapest first, then verified, with the providers that have no price to show (whose
pill would be a bare name) at the tail. pillPrice shows a price only when there IS one: a published
number or free; a quota_rows label only if it fits in eight characters ("2 rows" yes); and
nothing at all for a credit-metered or dashboard-only rate. That last rule is why the pills fit —
four copies of "per result · price in provider dashboard" is what wrapped the row in the first place,
and it is also why costShort says credit-priced on the sub-rows while the sentence explaining
the unit and where the rate lives sits in the expanded facts list. The cheapest across the whole row
stays in the price column, and the provider/endpoint counts live in the cell's tooltip rather than on
a second line of their own.
Merged rows expand in TWO levels. Clicking one opens its providers as collapsed .lsub sub-rows —
one line each: logo, name, costShort, ✓/·, the connected chip, and a truncated METHOD path.
Clicking a sub-row (toggleEp → epOpen[e.id]) opens that provider's instruction. Dropping six
full parameter tables on one click buried the comparison the merge exists to make. A single row has
nothing to compare, so it skips the middle level and renders its detail straight away — the SAME
.lep block either way (v-if="r.kind!=='merged' || epOpen[e.id]"), so the two paths cannot present
the instruction differently. Inside a merged sub-row the detail drops the provider/route header the
sub-row above already shows, and leads with the chips.
The filter bar is sticky under the top bar, and the section headings stick under it (--lbar-top /
--lsec-top); the domain chips scroll rather than wrap, because a bar that grew a second row as you
filtered would push the headings out from under it. Text, verified only and the domain chips narrow the
same row list (platRowsPreDomain → platLedger); a section with no surviving rows disappears rather
than showing an empty heading, chip counts are taken after the other two filters so a chip never promises
rows they have already removed, and a live N rows · M endpoints line counts both — a merged row stands
for several endpoints. Both the wrapper and the table drop their overflow clip (an overflow:hidden
ancestor is a scroll container, and a sticky heading inside one never escapes it) and the table is
table-layout:fixed, so a nowrap path or treg call line scrolls inside its cell instead of widening
the table past the page.
No ledger cell may carry its own display. A <td> with display:flex stops being a table-cell: the
browser wraps it in an anonymous cell that stretches to the row height while the flex box sizes to its
content and keeps the border. The separator under column one then lands ~1px above the one under column
two — a seam running the length of the table, with the hover and connected-row backgrounds split along it.
The layout flex lives on .lsum-i, a wrapper INSIDE the cell. A markup test asserts it, and a DOM sweep
over four platforms at two widths, collapsed and expanded, found every cell of every row sharing one top
and one bottom.
The price column is the same unified USD as everywhere else (capCheapest → costUsd, native figure as a
muted .cost-nat suffix). Two things can never win "cheapest": an endpoint with no published rate, and a
quota_rows price (a row quota is not a price, and "from —" would be worse than naming the cheapest rate
we do know). A connected own_account or free row counts as free (capFree) — the OAuth account
you already hold is the licence. When nothing is priced but a row carries a cost.note, the cell reads
"see provider" rather than an em-dash: the enrichment providers (Apollo, PDL, Hunter, Coresignal,
Lusha, Diffbot…) bill in their own credits, so their price is documented, just not in dollars — and that
is the whole People/Company half of the catalog.
Each .lep block is provider logo + name, METHOD path (mono), a compact cost chip (costLabel:
$0.015/success (¥0.10), 1 row, free, and per success · price in provider dashboard when the
billing unit is known but the rate is not published), a verified <date> / unverified chip, a scope
chip, and a tier chip. Scope is the load-bearing distinction in a mixed list: own_account rows (the
OAuth providers) read your account in teal with the hint "reads the account YOU connect via OAuth,
not arbitrary public accounts", while any_account scraper rows read a muted any account. Under them
sit the parameters block, the provider-wide facts (epFacts: the cost note, limits and the rate card,
served once per provider in the response's providers map rather than copied onto 2,000 rows), the
paste-ready treg call line the row carries as call_template with a Copy button, the docs link and
the lazy example toggle. Connection awareness reuses the marketplace's own state: catConnected reads
connCount (from /connections), a row with a connected provider carries a green rule down its leading
edge (.lrow.go td:first-child), and the Connect button deep-links to the provider's existing
integration page — shown only when mkKnown(service), since the catalog can name a provider this
deployment carries no client credentials for.
The runnable green. .chip.ok is not styled anywhere in the file, so it renders as muted grey — which
is how a ready capability came to look identical to an unavailable one. .chip.go (+ the haloed .godot)
is the marketplace's single "you can call this right now" green, used in exactly three places: the
platform card's Connected corner, a connected provider's connected chip, and a ledger row whose
provider is connected, which carries the green as a rule down its leading edge (.lrow.go td:first-child)
so it survives being skimmed. Everything unconnected stays muted.
An expanded row leads with the endpoint's chips and summary, then splits into two tabs: Request
(the parameters, the provider facts, and the treg call line) and Example response (the captured
JSON). Stacked, those two documents made the expansion a page you scrolled rather than read. Request
leads — it is the half that tells you whether the endpoint is callable at all — and the response tab is
not rendered at all when has_example is false. Not greyed out, and no "no example captured"
placeholder either: a disabled tab is a promise the catalog can't keep, and it draws the eye to the one
thing that isn't there. Those endpoints show a single tab, which reads as a label for the pane under it.
epTabOf also folds a stale res state back to req, so an endpoint can never be left showing a pane
whose tab is gone.
Both panes are the same bounded box: .prm and .cat-ex pre cap at 320px and scroll inside
themselves. A DataForSEO body carries thirty parameters, and uncapped a single expansion pushed every
row below it off the screen.
The tab bar's right side carries the provider's docs (falling back to its pricing page), then the
two run actions — and which one is primary depends on the provider's auth_kind (mkOauth(service)).
For a key/token provider, ▶ Try it (openEpTry) is the ink-fill primary — trying on treg's
own key is what most visitors want — and Bring your own key (goByok(provider) — jumps to the
Catalog's Platform tab with that provider's row scrolled into view and flashed, so the user sees where
their key lives among the rest; formerly openProvider straight to the detail page) is the secondary
ghost beside it. The same goByok jump is offered from a platform page's provider row (passing the
provider only when the platform has exactly one) and from the Try-it drawer's "can't run this here"
banner, which now carries a real Connect / Bring your own key button instead of prose alone. For an OAuth provider treg can't serve on
its own key (calls act as your account), so the order flips: Connect {provider} is the ink-fill
primary and Try-it is secondary. Once an account is connected the connect/own-key button is replaced in
place by the green Connected chip. Everything here renders identically in a single row's expansion
and in a merged row's provider sub-row, because both paths share the one .lep block.
The Try-it drawer (epTry) is four tabs (epTryTab, default AI Agent): AI Agent — the
one-line setup (epTrySetupLine, with team + token embedded here only, a copy-and-run-now context;
the setup line everywhere else stays clean) plus a ready "Use treg to call <id> — <summary>" prompt
(epTryAgentUse); CLI — install/login, treg catalog get <id>, and the filled treg call <id> --query … (epTryCliCall); API — the curl {BASE}/call/<id>?<query> passthrough with the token
header (epTryCurl, adding X-Treg-Org in session mode since the minted token is an identity token);
and Manual — the live test form (params + ❯ Run, disabled with a reason when the access dry-run
says this org can't call it) that the drawer used to be by itself.
Example responses load when their tab is FIRST opened (setEpTab → loadExample, guarded by
if(this.platEx[e.id]) return), never with the page and never twice — a platform can carry hundreds of
endpoints and the captured responses are the heaviest thing in the catalog.
Parameters come from the row's own input field ({pathParams?, queryParams?, body?, bodyType?, note?}, each param map being {name: {type, required, note, example}}) and render before the example
toggle: the response half was already there and this half was not, which made every endpoint look
uncallable until you left for the provider's docs. paramSections groups them query → path → body,
the order you fill them in for the common GET; the body section labels its bodyType, and input.note
becomes a hint line above the whole block. fmtExample stringifies object/array examples so they don't
render as [object Object]. input is null on every extended endpoint (~1850 of them, against 250
mapped ones), so the empty case is the common one and gets an explicit "the provider's docs have them"
line rather than an empty table. .prm-t explicitly resets the global table/th chrome (panel
background, border, radius, filled header bar), which otherwise reads as a stray highlight inside the
.prm box and clips the first column against the table's own border. Navigation runs both ways: an integration page carries a
Covered in the catalog chip row (mkPlatforms) into the platform pages, and each platform page
header links back out to the providers that serve it (platProviders). tests/test_dashboard_markup.py
locks the structure (top-level view, the row/detail <template> pair inside the .ttable, the
v-if'd tab bar and its platform fallback, the derived tab list and category order, tiles wearing the
platform's own logo with the generated-initial fallback, the Platform tab still carrying the provider
shelves and their connect flow, the category heading being a real heading, the card's four regions
(mark + name + category, the connected-state corner, the count/price footer — and NO summary
paragraph, with the name wrapping instead of ellipsising), the
unified-USD price rule (server usd, no local FX constant, native suffix, {}-normalisation,
quota_rows excluded first) and its OAuth-only "free with your account" branch,
the runnable green on all three of its surfaces, the stacked platform header, the always-both
provider/endpoint counts, the credit-priced fallback ranking ahead of "price not published",
the parameters block sitting before the example
toggle with its query/path/body order and its no-params fallback,
the featured-shelf split and its two guards, the ledger being one table
with other-last domain sections that need a visible row to exist, the single platform-wide Actions
section holding every management endpoint, and merged-before-single rows, a row title that is a name or a clipped
summary and never a paragraph, the collapsed merged row's non-wrapping three-pills-and-a-count strip, its pills being per-provider,
sorted cheapest-first and priced only when the price is a real number,
the two-level expansion (provider sub-rows, then one detail block shared with the single-row path), the
long metered phrasing never reaching a collapsed line, the filter bar's three controls and their chip
counts, both sticky layers and the overflow rules that let them stick, the two-tab expansion (Request first, no response tab at
all without an example, both panes capped at 320px), the prominent Connect in the tab bar with its
Connected state, the treg call line and the provider facts, the cross-currency cheapest rules, the credit-priced "see provider" fallback, the scope
chips, and lazy examples). tests/test_catalog_api.py locks the server half: the section order, the
merged/single split, the domain resolution ladder, and a delivery-mode path segment never becoming a
subject.
Code surfaces (every page)
Snippet blocks (.lc-codewrap on Getting started, the in-app CLI tutorial's .term panes, the
standalone /tutorial, the connect/setup instruction panes, the ledger's treg call line and captured
responses) are theme-aware, surface and ink together. They were not: the generic
[data-theme=light] pre rule outranks .lc-codewrap pre, so light mode painted a light panel INSIDE
the dark wrapper while the syntax ramp stayed drawn for a dark block — a near-white command on a
near-white background, bright cyan URLs, and a dark copy pill. That is also why the earlier
"terminal surfaces stay dark in both themes" rule is gone: in light mode a code block is now a light
block with dark ink, which is what makes the ramp legible.
Two token sets carry it (index.html §3.8, mirrored in tutorial.html, which has its own copy of the
sheet): --code-bg / --code-ink / --code-line / --code-btn for the surface, and
--sx-cmd / --sx-var / --sx-str / --sx-flag / --sx-cmt / --sx-punct for the ramp. Every
light value clears 4.5:1 on --code-bg (measured worst case across all pages: 4.67 light, 5.62
dark, including the muted "expected result" panes, which use the ramp's comment grey rather than
--muted at 3.7:1). The inner <pre> rule is :root-qualified so it out-specifies BOTH generic
pre rules, and a markup test forbids a hex literal inside any .hl-* / .s-* rule — a literal is
one theme's value, and three earlier generations of this ramp each left one behind.
Shareable detail pages (/app/skills/<name>, /app/tools/<name>)
A skill or a tool has its own deep-linkable page so a member can share the exact thing (view==='detail',
detail={kind,name}). Server routes dashboard_skill_page (/app/skills/{name}) and dashboard_tool_page
(/app/tools/{name}) both call _spa_with_og, which serves the same SPA but injects per-resource
og:/twitter: meta so a pasted link unfurls — the meta echoes only the URL's own name segment
(HTML-escaped via _esc_html, no DB read, so an unauthenticated crawler learns nothing). The client
resolves the record by name (not id, so the URL is stable): openDetail/loadDetail fetch
/bundles/by-name/{name} (get_bundle_by_name) for a skill or /tools/by-name/{name}
(get_tool_by_name) for a tool. A skill page renders a "Use with your agent" copy-prompt (detailPrompt
— no token embedded), a bundled-tools/secrets chip row, and a file browser over the SKILL package
(detailTree/detailFileContent, detailFile); a tool page shows upstream + credential chips
(credChips), examples, and the CLI/guardrails block, linking back to its parent skill (detailParentSkill).
Actions: ⧉ Copy link (detailShareUrl), ▶ Try it / ⚙ Configure (tryDetailTool/configureTool
via fullTool, which resolves the full record from a skill's tool summary), and Share… (canAdmin only).
The Tools list routes its rows through rowTarget/rowHref/rowOpen — a skill-born tool (has bundle_id)
opens its skill page (the shareable thing), a bare endpoint opens its tool page. Share…
(openShare/sendShare) invites someone by email with a landing field on the invite
(POST /orgs/{id}/invites, server-validated to a /app/skills|tools/<name> path) plus optional scoped
tool_access (unchecked "full access" → the skill + its bundled tools only). The emailed one-click link IS
the consent: on arrival autoAcceptShare accepts the matching pending invite silently and enters that team.
If the link resolves to a team the caller is already in but a different one, findDetailOrg probes the
caller's other teams and switches silently on a unique hit; a 404 with no match shows an "ask for an invite"
message. Boot + popstate route these paths (routeFromPath); a detail/marketplace path is stashed in
localStorage['treg-next'] across an OAuth sign-in hop (the callback always lands on /app, which would
otherwise drop the path).
The tutorial (one source, two renderers)
src/treg/web/tutorial.js is the single source of truth: window.TREG_TUTORIAL (concepts, roles,
personas, steps[] = {part,who,title,explain,cmd,out,notice}, plus the two focused arrays
importShell[] and access[], same step shape) + a self-contained tregHL(text,lang) shell/json
highlighter. (The CONCEPTS proxy analogy was reworded from "a coat check" to "a bank teller" — you
hand over your token, the teller fetches the real secret from the vault and makes the call for you; the key
never crosses the counter.) It's served at /tutorial.js with Cache-Control: no-cache, and /tutorial rewrites the
<script src> to carry tutorial.js's own mtime (?v=<mtime>) — not _app_version(), which
hashes index.html and would not move when only the tutorial changed. Both are needed: the page
includes the file by a bare path, so a browser that cached it before the header existed applies a
heuristic lifetime and never revalidates, and an edited tutorial silently keeps serving the old
steps. Consumed by both the dashboard Help view (native Vue
render) and the standalone src/treg/web/tutorial.html (vanilla render, served at /tutorial;
renders steps only) — so they can never drift. docs/tutorial.html is now a redirect to /tutorial;
the prose walkthrough is docs/TUTORIAL.md. Editing steps means editing tutorial.js only.
Two focused tutorials as cards — Import & shell (importShell, auto-import + shell mode + the
local-run sandbox) and Team access control (access, per-member tool access + the local-run dial)
are cards on the tutorial chooser (view==='help'), rendered by one shared stepper template in index.html
(helpMode === 'import-shell' || 'access'), with its own xtut*-prefixed state/computed/method names
(xtut.i, xtutSteps, xtutStep, xtutTitle, xtutGo) so they never collide with the CLI tutorial's
tut* names. Two extra persona chips: you (green) and sam (amber). Each also has a prose twin
served as markdown: web/tutorial-import-shell.md at /tutorial-import-shell.md and
web/tutorial-access.md at /tutorial-access.md (both _serve_md, {BASE}-templated) — kept as the
agent-friendly versions; the main tutorial (tutorial.md + docs/TUTORIAL.md) links them near the top
and tutorial.js ends with a "Further tutorials" step pointing at the cards + URLs.
Dashboard tour (the web-UI walkthrough — screenshots, not commands): the tutorial view
(go('help') — no side-nav entry anymore)
opens a chooser (helpMode = cli | dashboard | import-shell | access, plus the Guided-setup
replay) with five cards; the dashboard card renders a native
stepper (tourGo via tourI, personaTour, per-Part tourMatColor mats) from window.TREG_TOUR
(src/treg/web/tour/tour.js, one source shared with the standalone page). WebP images live at
src/treg/web/tour/img/ and are served via a StaticFiles(html=True) mount at /dashboard-tour/
(which also serves the shareable standalone tour/index.html). Images are generated by
docs/dash-tour/capture.py (Playwright drives the live dashboard as tom/bob/alice via session-cookie
login → WebP); prose mirror is docs/DASHBOARD-TOUR.md. The dashboard shell is served with
Cache-Control: no-cache so UI edits show on a plain reload.
Write UI — Phase 2a shipped (org lifecycle)
The Organizations view is a management surface (all endpoints already existed; this is pure
front-end). + New team → createOrg; for the active non-personal org a Manage panel (visible to
admin+ via canAdmin) shows loadOrgAdmin (members + pending invites), with sendInvite (client-side
email-format guard before POST; the admin role option is owner-only, mirroring the server rule that
only owners invite admins), setRole (owner-only dropdown), removeMember, revokeInvite, and a danger zone
(leaveOrg, deleteOrg — confirm-by-name). Destructive actions use inline two-step confirms
(confirmRemove/confirmLeave/confirmDel), never native confirm(). loadOrgAdmin refreshes on
go('orgs') + after each switch. The members table also shows each member's used_today + an inline
Daily cap editor (setCap → PATCH …/members/{id}/cap; -1 = unlimited), and every member (not just
admins) sees a "Your usage today: N / cap" line from loadMyUsage (GET /usage/me) when a cap is set.
The members table also carries the per-member tool access control: a Tools cell (All chip, or
N tools ▾ opening an inline checklist of every org tool — openAccess/saveAccess → PATCH …/members/ {id}/access; all-checked collapses to null = all) and a Local run on/off toggle (setLocalRun,
preserving the member's current tool_access); the owner row's controls are disabled (never
restricted). The invite flow adds All tools / Customize (a pre-checked checklist via
openInviteCustomize) + a Local runs allowed toggle, sent as tool_access/local_run_enabled on the
invite. saveTool calls remindCustomizedAccess after a create — if any member has a customized
selection, a tut-notice toast reminds the owner the new tool won't reach them (explicit allow-list).
Usage view (view==='usage', admin/owner only): loadUsage (GET /orgs/{id}/usage?days=, a 7/30/90
selector) renders a totals stat-grid, a by-member table with the call/local/server split, top
tools, and a per-day table — the visibility half of usage-metering v1. Refreshes on go('usage') +
after a switch; usage is in the popstate allow-list.
Every view switch runs resetConfirms() first (via go(v)), so a half-armed inline "click again to
delete" state can't survive into the next view and cause an accidental delete. It now also clears
confirmDelBundle (recipe delete) — that one was missing, so navigating away with a recipe delete armed
could delete it on the next matching click. Browser Back/Forward (popstate) navigates between
dashboard views rather than leaving the app; its allow-list now includes the secrets and
start (Getting started) views too, so those are reachable by Back/Forward like the rest.
Write UI — Phase 2b shipped (resource registration)
The Tools view registers resources (members+ via canRegister; viewers can't). The Secrets view
(own sidebar tab) — loadSecrets (values never shown) + addSecrets (posts each filled secretRows row,
per-name errors, encode:true body for the edge WAF) + deleteSecret (surfaces the 409
bound-secret guard). Both surface their errors on the Secrets view via a dedicated secretErr banner
(they used to write toolErr, which only shows on the Tools view, so a secret failure was silent while
on Secrets). + Add tool / the ✎ row button open one modal (openAddTool/openEditTool → saveTool) — name (locked
on edit) + base_url + a multi-binding builder: tForm.bindings[] of {secret_id, injector, location, name, format, secret_field} with addBinding/removeBinding, each carrying a secret picker + placement
(header/query) + {secret} format. Create → POST /tools (bindings list); edit → PATCH /tools/{id}
(base_url + bindings). Tool cards get an inline-confirm delete (deleteTool). Delete methods clear the
error banner on success. + Skill (openAddSkill/addSkill) registers a bundle via a pasted
/skills payload (recipe + inline-value secrets + tools; bindings reference a secret by local_name),
with client-side JSON validation.
Not yet
OAuth-connect in-browser (the hosted consent + poll flow, /oauth/*) has now shipped — see the
Marketplace section above. Everything in DASHBOARD-PLAN (org lifecycle, resource registration incl.
multi-binding + edit, skill bundles, super-admin mutations, OAuth connect, shareable detail pages) has
shipped. Packaging: src/treg/web lives inside the treg package, so the wheel's packages
inclusion ships every asset (incl. tutorial.js/tutorial.html) — no force-include (a redundant
one double-adds each file and breaks the wheel build).