Files
zeekay 5d3f151883 Gate the retired /api prefix in CI
A test fails on any first-party /api/ path in the app, the handlers or
the sidecar, and hanzo.yml runs it with the Go suite before the image is
built. The release lane is named where it now lives: hanzo.yml passes
the version the build cuts.

Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
2026-09-29 18:55:48 -07:00

5.6 KiB

Hanzo World — agent guide

Vite + TypeScript SPA (world). Real-time global-intelligence dashboard served at world.hanzo.ai. Home: github.com/hanzo-apps/world. Same-origin data plane under /v1/world/*; any other /v1 or /api path is a 404, never the SPA shell (isAPIPath, cmd/world). The desktop sidecar serves the same /v1/world/* paths from api/*.js.

Browser control — prefer the Hanzo MCP extension over Playwright

When the Hanzo browser MCP (the mcp__hanzo__browser / mcp__claude-in-chrome__* tools, backed by the local extension in ~/work/hanzo/extension) is available, use it by default to drive a real browser for interactive UI work — inspecting the globe, tweaking layers, checking live feeds, capturing screenshots against a running dev server. It talks to a real Chrome/Firefox with the real WebGL/deck.gl context, so what you see is what a user sees.

Fall back to Playwright only when the MCP extension is not connected, or for the deterministic e2e suite (e2e/*.spec.ts) — those run headless in CI with mocked /v1/world/cloud/* feeds and must stay reproducible offline. E2e is not interactive editing; keep the two lanes separate.

Order of preference for "look at / poke the UI": Hanzo MCP browser → Playwright.

The map / globe

  • Basemaps: dark · dot · satellite · terrain. dot is the default for every variant (DEFAULT_BASEMAP_STYLE in src/config/variant.ts) — the Kaspersky-style cybermap: land drawn only as a glowing dot-lattice over a black ocean sphere, no country fills/borders/imagery.
  • The lattice is one pure value — getLandDots() in src/services/land-dots.ts — consumed by BOTH the 2D mercator map (DeckGLMap) and the 3D globe (GlobeNative). One source, two projections. Don't fork it.
  • Cloud view layers (default-on, ?variant=cloud): live request-origin dots + animated traffic arcs (AnimatedArcLayer, a travelling pulse advanced on RAF), validator chain-nodes, BYO-GPU rings, datacenter clusters. Feeds are best-effort and degrade to honest empty states — never fabricate volume.
  • satellite/terrain need VITE_MAPBOX_TOKEN (from KMS hanzo/deploy/, never in git); dark/dot are keyless CartoDB.

Sources & topics

  • A source is a Feed (src/types/index.ts) in src/config/feeds.ts, fetched by fetchCategoryFeeds (src/services/rss.ts). There is no second fetcher.
  • Publishes real RSS/Atom → add the host to rssDomainList (internal/world/rss_domains.go, the SSRF boundary) and wrap it in rss(). Doesn't → emit RSS 2.0 on our own origin with buildRSS (internal/world/handlers_social.go) and reference the route as a plain Feed. A same-origin emitter must call s.ingestFeedItems itself — it never passes through handleRSSProxy, which is what normally folds items into the lake.
  • Social: Reddit + YouTube are keyless. X / TikTok / LinkedIn need X_BEARER_TOKEN (or TWITTER_BEARER_TOKEN), TIKTOK_ACCESS_TOKEN, LINKEDIN_ACCESS_TOKEN + LINKEDIN_ORG_URN — from KMS hanzo/world-secrets, never in git. Absent, each serves an empty channel and logs one skip line. A credential is only reachable if it is listed in worldSecretKeys (internal/world/kms.go) — that list IS what world asks KMS for.
  • NOT PROVISIONED (measured 2026-07-28, logged in as world's own KMS client): hanzo/world-secrets does not exist and none of the 24 names are anywhere under hanzo/prod — world: KMS injected 0 secret(s) of 24 requested. Every credentialed source is therefore serving its keyless degradation, X / TikTok / LinkedIn included. Storing the secrets is an operator action; no code change will light them up.
  • Keyless does NOT mean free. A feed host budgets by IP and the whole fleet is one egress IP. Reddit grants ~one anonymous request per window and says so on every response (x-ratelimit-remaining: 0.0, x-ratelimit-reset: 57); YouTube's /feeds/videos.xml soft-404s a busy IP per channel, with no header to pace by, so its feeds fill in opportunistically over several warm cycles. fetchFeedBody waits out whatever pause a host states (hostGate) and warmFeeds walks host by host — parallel fetches of one host just become 429s. Adding feeds on a rate-limited host costs the ones already there.
  • A user topic is a Monitor (per-user SQLite, namespace monitors), not a separate store. topicFeeds() turns it into Feeds so it pulls; the seed list in TOPIC_KEYWORDS is only a default, unioned with the user's own topics. Trending-noise suppression applies to that seed and only there — a term the user typed is deliberate and always signals.
  • Warming is demand-driven and decays: a request calls FeedCache.Want, the warmer refreshes what is wanted, and demand nobody renews within warmTTL is dropped. Put writes bodies, never demand — otherwise the warmer would renew its own work and every topic anyone ever typed would be fetched forever.

Release

Bump package.json PATCH (x.y.z → x.y.z+1, never a lazy major) and push tag v<version>; .github/workflows/cicd.yml (hanzoai/ci, buildx on linux-amd64) builds ghcr.io/hanzoai/world:<version> with the KMS build_secrets from hanzo.yml. CI never deploys: pin tag + digest in universe charts/app/values/hanzo/world.yaml and cd.hanzo.ai rolls it. Test/doc-only changes need no release.

Image

FROM scratch: the static binary, the CA bundle, zoneinfo, the built sites at /srv and /srv-react, and a world-writable /tmp (the embedded store's fallback when no /data volume is mounted), running as 10001. Nothing else is present to run.