Web dashboard (Phase 1)
September 5, 2026 · View on GitHub
Instagram authorization state
The primary Add account action opens one method picker for providers with several separate OAuth
grants. Registry review metadata and TREG_OAUTH_REVIEW_PENDING define the effective experience.
With both Instagram review keys pending, Instagram selects the approved Facebook Page tools flow
for plain Connect. Direct Instagram Login stays available for app-role testing and its registry
text states the review limit. The Page method opens a generic capability picker: core Page tools use
approved scopes, while Facebook Page tools + messages is an explicit widening for reviewers and
test roles. Pending methods and capability choices carry a registry-driven In review badge; an
approved fallback method does not inherit the badge from its optional wider capability. After Page
messaging approval, its key is removed: the badge, warning, and extra Page capability
picker disappear, and the two Page permission cards merge into one full Page-tools card. Page
connections then request messages. After direct approval, its key is also
removed: direct Instagram Login returns as the recommended method and loses its review badge.
Providers with zero or one authorization method keep their existing one-click/capability flow.
Reconnect stays pinned to the connection's stored method and chooses its widest granted capability.
Method, capability, help, and action labels come from oauth_providers.listing(); the shared template
does not know provider or method ids. The registry marks a provider configured when any declared
method is configured.
The provider page has no method-status alert: healthy and optional states live in the connection
rows and permission cards, while alert styling remains reserved for real errors or setup gaps. Each
row retains the shared production layout: connection name and account identity in the first column,
generated tool name in the second, then health/capabilities and actions. Method-specific resource
discovery is unchanged. A direct identity miss shows setup required; it does not show the
connection as working and no direct tool exists.
A single-file Vue 3 dashboard in src/treg/web/index.html, served same-origin by the API
(GET /app → FileResponse, dashboard() in routers.web, 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.
Vue is vendored, not fetched from a CDN
There is no bundler, so Vue arrives as a plain <script src> — but from /vendor/, served off
src/treg/web/vendor/ by an _ImmutableStatic mount in bootstrap.py, never from unpkg. It used to come
from unpkg.com/vue@3, and a visitor whose network could not reach unpkg got a blank signed-in
dashboard with no error (#137: mainland-China
ERR_CONNECTION_CLOSED, then Vue is not defined). The landing has no external scripts at all, so
the symptom read as "sign-in broke the site" when it was only "the dashboard needs one more origin".
Two rules follow, and both are load-bearing:
- Pin the version in the filename (
vue-3.5.41.global.prod.js) and verify new bytes against a second CDN before committing them — seesrc/treg/web/vendor/README.md. A floatingvue@3tag is arbitrary future code running in an authenticated session; that is why it is gone. - Nothing in the dashboard's critical path may be third-party. Still CDN-hosted and not
critical: the
@lobehubagent icons (agentIcon/agentIconInv) and Google Fonts — those degrade to broken images and system fonts rather than a blank page.
A loader guard sits right after the script tag. [v-cloak]{display:none} hides the un-compiled
template until Vue mounts, which is precisely what made #137 silent — so the guard checks whether
#app is still cloaked ~1.5s after load and, if it is, replaces the blank with a readable message,
a reload button, and the issues link. Anything that stops Vue mounting now says so on screen.
index.html's closing <script src="/sitetrack.js"> (also on landing.html, every usecase-*.html,
resources.html, tutorial.html) sets the first-touch treg_utm cookie and initialises PostHog with
pageviews on; initAnalytics() in the SPA defers to it (window.__phInit) and only identifies, keeping
its inline init as the fallback for a stale bundle. Landing-page visitors used to be invisible to
analytics — PostHog first met them on /app after OAuth, as $direct — so this ordering is the whole
point. <script src="/adtrack.js"> — the first-party ad-click capture — loads in <head> on every
page, guaranteed to run during HTML parsing before any app code can navigate away. An ad click landing
on /?gclid=… falls through to the SPA (because of the query string), whose boot redirects logged-out
visitors via location.replace('/'). Placing capture in <head> ensures the click id is stored before
that redirect can drop the query string. No Google tag, first-party cookie only; see
ads-conversions.
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 SPA keeps the hero, key-leak
explanation, footer CTA, and sign-in modal, but its anonymous sandbox studio has been removed. The
backend sandbox routes remain temporarily for a later cleanup (see landing-sandbox).
An OAuth authorization that needs sign-in redirects to /?signin=oauth. The dashboard reads this as
a UI cue, removes it from the visible URL, and opens the same modal with generic connection copy. It
does not create a sandbox session. During this flow the modal hides the agent/CLI token fallback,
because that token does not create the browser session required to resume authorization. The
protected OAuth return path stays in an HttpOnly cookie, so GitHub, Google, and email-code sign-in all
resume the same authorization flow without putting OAuth request data in the URL.
A logged-out use-case CTA arrival at /app?ref=<page> follows the same front door: boot keeps ref
long enough for attribution, strips it with the other one-shot parameters via history.replaceState,
and opens the sign-in modal. It never calls sbxInit or POST /demo/sandbox. A plain logged-out
/app visit still redirects to /.
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 (domain.identity.sessionHMAC). (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_identity(..., 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). New cookies carry signed aud=session and a 7-day
exp; copied API tokens carry aud=identity and no exp, so neither credential can cross into the
other path. Both also carry a tv (token_version) claim; 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 (domain.identity.access): 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, a Top up button that opens the top-up modal, the auto-top-up toggle with its verbatim PSD2/SCA mandate text, and below them Payment history: date, amount, an
automarker, the+$X bonusthe payment earned, 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.The top-up modal (
topupOpen) is one decision, not three: the four preset cards frombilling.topup.presetsplus Other (a whole-dollar input bounded bytopup.min_usd/max_usd), each card naming the tier bonus it earns (tierBonus, fromtopup.bonus_tiers; the referral bonus stacks viarefPresetBonus), a summary box (credit + bonus, total due = the amount — treg charges no fee, so there is no fee line), and an auto top-up toggle that defaults ON for a team with no mandate yet. The toggle's label is the mandate text with the refill amount and threshold (the server defaults, $20 when below $5 — not the top-up amount: a $200 buyer does not want $200 refills), shown in full so what is agreed is what will be charged; the billing page's Edit link on a running policy reopens the same panel to change them (Save re-stamps consent). The monthly cap is not in the copy: it is a server-side runaway guardrail, and the modal sets it totopup.max_usd(effectively unlimited) so a big payer is never locked out by a default sized for $10 refills. Pay with the toggle on POSTs/billing/autotopup(consent: true, those numbers,setup_url: falsebecause the top-up Checkout saves the card itself) before/billing/topup, so consent exists before the card that will be charged under it; the setup webhook then arms the policy. A team that already has a mandate sees a read-only "auto top-up is on" line instead. The preselected card istopup.default_usd, which is per-org: one preset above the last manual top-up, capped at $50 (see money). -
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.
-
- 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; the pick is remembered inlocalStoragetreg-agent) above an "install the treg plugin" link for agents whose entry carries apluginURL (Grok Bot) and 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. A row whose/callspayload carriesasync_task(a metered generation) renders the task's state as a chip next to the HTTP badge (taskStateLabel:generating…/done/failed · refunded/timed out), shows the reserve ashold $xwhile pending (the server nulls the charge until the task settles), and once done appends the artifact to the Action cell: aresult ↗link to the provider's time-limited URL (tooltip fromtaskArtifactTitlecarries thettl_noteand when it was generated) or aresult via CLIchip whose tooltip is the retrieval command for fetch-mode routes. treg never proxies or stores the media itself. The feed shows successes only by default (actOkOnly,activityShownfilters onok); a "Show all (N)" / "Successes only" button toggles failed and refused rows in, and the empty state offers the same switch when every recent call failed. A call row is clickable (openCall→callView, a right-side drawer in theepTrypattern) and loadsGET /calls/{id}/result: the request line (method + vendor URL before injection), the response (status · media type · size · fetch time, JSON pretty-printed bypretty, a Copy button viatoClipboard, rendered text capped at 256 KB with a "show full" link), or the endpoint'snotewhen nothing is on file. Rows whosehas_resultis true show a small "result ›" affordance next to the status badge; own-key, own-tool and failed calls open the same drawer and read the note. Only metered platform 2xx calls ever have a stored answer. - 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 / Grok Bot / 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, except Grok Bot's bundled/logos/agents/grokbot.png;/app?ref=<agent-id>from a landing preselects the card) with Skip/Next; the setup block (step===2— "In your agent's chat, send:", the setup line + team/token as one unit; for apluginagent (Grok Bot) an "Install the treg plugin" link precedes it as step 1 and the line becomes step 2 —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 Tag Manager, 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 can run at least one connect flow). Each
authorization method also has its own configured flag. For a multi-method provider, the registry
sets the provider flag when any one method is available, so a configured secondary grant cannot be
hidden by an unavailable primary grant. The payload is loaded with
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.
Google Tag Manager follows that same generic UI: its capability picker offers cumulative
read/write/manage access, account discovery labels each accounts/{id} resource by name, and the
selected account stamps a runnable containers-list path into the provisioned tool. Its provider and
platform logo assets both carry the Google Tag Manager mark, so the catalog tile, platform header,
provider page, and expanded endpoint rows resolve to the same identity.
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). A method may optionally provide capability_intros and capability_details; the shared
renderer uses them for incremental-benefit copy and falls back to scope_detail for every ordinary
capability. This lets one grant explain what it uniquely adds without provider-specific template logic.
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 view is also the public catalog.
/catalogand/catalog/<slug>serve this sameindex.html, and everything below renders for a signed-out visitor too — the catalog API needs no session.publicCatalog(set in the boot fromcatalogFromPath()) hides what does: the org switcher, vault, activity, team, the "not connected" badge, Try-it and the connect/BYOK buttons. There is no second implementation of any of this; see seo for why, and for the#prerenderfallback that carries the text to crawlers that run no scripts.index.html's ownrobots: noindexis stripped on those two URLs only.The Platform tab fills for signed-out visitors too. The public-catalog boot branch calls
loadConnections(), not justloadPlatforms()—/oauth/providersis an open endpoint, and the/connectionshalf fails and is caught. (It once called onlyloadPlatforms(), and an incognito visitor who reached the tab saw "Platform 0" and a blank shelf.) In public mode the shelf's actions swap: "Add key"/"Connect" opens the sign-in dialog, and a provider row navigates to the server-rendered public page at/tools/<service>viagoPublicTool— a real method, because a Vue template expression cannot reach thelocationglobal (not on the expression allowlist; an inlinelocation.href=fails silently).
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 unmetered 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". Ameteredprovider is the exception, and it is not a special case so much as the same rule: the account you connect is the licence only where the licence is what you are paying for, and X bills treg's app per use, so a connected X account changes who made the call and not who is billed. 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 — unless the provider is metered (catMetered, from /oauth/providers),
where the upstream bills treg's app per call and connecting changes nothing about the price. Reading the
flag off the server rather than naming X here means the display follows TREG_OAUTH_BILLED_PROVIDERS:
throw the kill switch and the rows go back to reading free, because they are. 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 /connections, but endpoint rows intersect
their declared authorization_methods with each connection's stored authorization_method; having one
grant for a provider therefore cannot mark an endpoint that requires a different grant as connected.
Providers without multiple authorization methods retain the provider-level catConnected behavior.
The endpoint-aware label names the sole required method when one exists, but the Connect action
always navigates to the provider connection page; consent never starts unexpectedly inside the catalog
ledger. It is 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 compatible endpoint grant's connected chip, and a ledger row
with a compatible grant, 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 a compatible account method is connected the connect/own-key button
is replaced in place by the green Connected chip. A missing method uses the registry's action label
and missing-message, then routes the user to the provider connection page. The exact CLI connect command
remains in the access response for CLI and agent consumers, but is not rendered in the Manual banner.
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), with --method and a shell-quoted --data JSON body for non-GET
requests; API — the curl -X <method> {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), plus
Content-Type: application/json and the same shell-quoted, editable epTryBody for a non-GET request
that has a body;
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.
When an endpoint supports more than one authorization method, the drawer shows one explicit method selector. Changing it swaps the visible method-specific inputs and updates the AI Agent, CLI, API, and direct-call representations together. The selector label comes from the provider registry, not a method-id lookup in the template. Instagram Login is the first/default method on shared Instagram endpoints. The selector appears only when both grants are connected; one connected grant is selected automatically, while no connected grant keeps the endpoint's recommended method and the ordinary catalog access guidance. If a connected grant lacks an endpoint's scopes, the drawer gives the smallest registry-defined capability upgrade and its registry-defined action label.
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
pins this provider navigation to the platform response itself; it does not disappear while the
separate OAuth connection registry is still loading. The same test
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 unmetered-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).
The Referrals view
A top-level <template v-if="view==='referrals'">, plus a nav button and a second entry point under
the balance chip (where someone is already thinking about what treg costs them).
'referrals' must appear in BOTH view whitelists — viewFromHash() and the popstate handler.
go('referrals') works on click regardless of them; those two lists are what make the view survive
a RELOAD and a BACK button. Missing either is invisible in review and in clicking around, which is
precisely the silent failure CLAUDE.md warns about. Pinned by a test that counts both.
The link is NOT gated behind paying us. Every signed-in person gets one, free tier included —
see money for why that gate was removed. The !eligible branch survives
only for the degenerate case of owning no team to pay a reward into, and a test asserts it never
again asks anyone to add funds.
GET /referrals mints the code as well as sweeping, so the page is one call and link is never
empty on a first visit.
Every status renders a reason (refStatus), including capped and rejected. "I referred
someone and got nothing" is the ticket this program generates, and the answer belongs on the page
rather than in an email to us.
Opening the page has a side effect: GET /referrals runs the payout sweep, so a user checking
whether their reward has landed is the one who makes it land. There is no scheduler in treg, so this
work rides on a request someone is already making.
The billing page's referral prompt
The Billing tab renders billing.referral_offer when the team was referred: a green note naming the
minimum, and +$X bonus on each preset that clears it (refPresetBonus). Both are measured
against remaining_micro, not the full minimum — the threshold is cumulative, so a team that has
already added $5 is asked for "$5 more" and sees the bonus marked on the $5 button. The note says
the referee is credited straight away and the referrer after the hold — that timing is
load-bearing copy, not decoration: the referee has no Referrals page, so an unstated delay is what
made a correct payout look like a failure. Both are needed — the
amount is chosen at the buttons, and the first preset ($5) is below the $10 minimum, so a note on its
own would let the most-clicked button quietly forfeit the reward. Null offer = the page renders
exactly as it did before this shipped.