From 0a5dc81c24062cf56f0d437884ab6a67a50f89e7 Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Tue, 1 Sep 2026 22:45:57 +0530
Subject: [PATCH 01/15] Site: make the terms, the policy and the pricing page
say the same thing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Terms section 1 said local use is free and paid plans add remote control, which contradicts section 5 and the free plan the whole funnel rests on. The wrong version came first, in the operative agreement.
The privacy policy described the relay briefly buffering messages for offline recipients. It does not: server.ts refuses the frame with PEER_OFFLINE and drops it. The uniform refusal is deliberate — an unauthorized sender must not learn which devices are online — so the policy was describing a weaker relay than the one we ship.
Support named Gemini CLI, which is not in the agent registry at all; Cursor is. SSO, audit log and IP allowlist were claimed as shipped on the marketing pricing page, in web's plan UI and in support: the capability flags exist on the plan model but nothing reads them, so all three now say roadmap and cross-reference each other.
Enterprise leads went to /support, which put a budget holder on the troubleshooting page.
---
site/src/config.ts | 8 ++++++++
site/src/pages/pricing.astro | 8 ++++++--
site/src/pages/privacy.md | 2 +-
site/src/pages/support.md | 4 ++--
site/src/pages/terms.md | 2 +-
web/src/ui/pricing.tsx | 5 ++++-
6 files changed, 22 insertions(+), 7 deletions(-)
diff --git a/site/src/config.ts b/site/src/config.ts
index ae2b5c52..cc7d4631 100644
--- a/site/src/config.ts
+++ b/site/src/config.ts
@@ -21,6 +21,14 @@ export const links = {
downloadWindows: "https://get.microsoft.com/installer/download/9N0P7ZRL4D9W?referrer=appbadge&cid=site",
downloadLinux: `${RELEASES_URL}/releases/latest/download/antgrid-linux.AppImage`,
support: "/support",
+ // Enterprise leads go straight to a human. Pointing them at /support put a
+ // budget holder on the troubleshooting page; the subject line sorts them out
+ // of general support mail on arrival.
+ enterprise: "mailto:contact@radhaai.com?subject=Antgrid%20for%20teams",
+ // The mobile apps ship through TestFlight and Play internal testing today, so
+ // the hero has to route the ask somewhere. "Coming to the App Store" read as
+ // "you can't have it yet" while invites were in fact open — see get-started.
+ mobileInvite: "mailto:contact@radhaai.com?subject=Antgrid%20mobile%20invite",
privacy: "/privacy",
terms: "/terms",
refunds: "/refunds",
diff --git a/site/src/pages/pricing.astro b/site/src/pages/pricing.astro
index cd39dbb1..4161aa31 100644
--- a/site/src/pages/pricing.astro
+++ b/site/src/pages/pricing.astro
@@ -32,9 +32,13 @@ import { links } from "../config";
+ {/* SSO, audit log and IP allowlist are named as roadmap, not as shipped:
+ the capability flags exist in web's plan model but nothing reads them
+ yet. Asking for the buyer's requirements is also the better opener —
+ it starts a conversation where a feature list ends one. */}
+
Unlimited seats · invoiced annually · SSO, audit log and IP allowlist on the roadmap — tell us your requirements and your timeline.
All plans include end-to-end encryption. The relay never sees your code.
diff --git a/site/src/pages/privacy.md b/site/src/pages/privacy.md
index c19b6e44..c04ad71c 100644
--- a/site/src/pages/privacy.md
+++ b/site/src/pages/privacy.md
@@ -60,7 +60,7 @@ Because of our end-to-end encryption, we do **not** have access to, and do **not
All agent-to-app traffic is end-to-end encrypted after a handshake using X25519 key exchange and AES-256-GCM authenticated encryption. Encryption keys are generated on your devices, are per-connection, and are never persisted by us. As a result, **we cannot read the contents of the data you transmit through the Services.**
-For full transparency about what the relay *can* see: the relay authenticates devices using their public keys and routes messages by device identity, so it processes device identifiers and the public keys exchanged during the handshake. When a recipient device is temporarily offline, the relay briefly buffers a small number of still-encrypted messages in memory so they can be delivered on reconnect; these buffers are never written to disk and remain encrypted. The relay does **not** store your IP address in a database or link it to your account; IP addresses on the relay are held only in memory for the duration of a connection (for rate limiting) and may appear in short-lived operational logs.
+For full transparency about what the relay *can* see: the relay authenticates devices using their public keys and routes messages by device identity, so it processes device identifiers and the public keys exchanged during the handshake. When a recipient device is not connected, the relay does not hold the message: the frame is refused and dropped. Nothing is queued, buffered, or written to disk. The relay returns the same response whether the recipient is offline or the sender is not permitted to reach it, so it never discloses which of your devices are online. The relay does **not** store your IP address in a database or link it to your account; IP addresses on the relay are held only in memory for the duration of a connection (for rate limiting) and may appear in short-lived operational logs.
## 4. Analytics, Crash Reporting, and Tracking
diff --git a/site/src/pages/support.md b/site/src/pages/support.md
index e0cd59a3..78499188 100644
--- a/site/src/pages/support.md
+++ b/site/src/pages/support.md
@@ -13,7 +13,7 @@ Need a hand? Email **[contact@radhaai.com](mailto:contact@radhaai.com)** and we'
## What is Antgrid?
-Antgrid lets you run AI coding agents such as **Claude Code**, **Codex**, and **Gemini CLI** on your own computer, then monitor and control them from your phone, tablet, or desktop over an end-to-end encrypted connection. You see the agent's live terminal output, browse the project's files, and stay in control from anywhere — and Handler, the built-in assistant, marks nothing done without evidence.
+Antgrid lets you run AI coding agents such as **Claude Code**, **Codex**, and **Cursor** on your own computer, then monitor and control them from your phone, tablet, or desktop over an end-to-end encrypted connection. You see the agent's live terminal output, browse the project's files, and stay in control from anywhere — and Handler, the built-in assistant, marks nothing done without evidence.
## Supported platforms
@@ -44,7 +44,7 @@ Antgrid supports three sign-in options:
**Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are the launch prices.
-Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams, SSO, audit logs and IP allowlisting are **Enterprise** — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details.
+Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams are **Enterprise**, where SSO, audit logs and IP allowlisting are on the roadmap — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details.
**Manage or cancel your subscription:**
diff --git a/site/src/pages/terms.md b/site/src/pages/terms.md
index ee644009..c754d37e 100644
--- a/site/src/pages/terms.md
+++ b/site/src/pages/terms.md
@@ -25,7 +25,7 @@ The Services are intended for users who are at least 18 years old.
## 1. Our Services
-Antgrid is a command centre for AI coding agents that you run on your own machine and can monitor and control remotely from your other devices. **Local use of Antgrid is free.** Paid plans add encrypted remote control and related features (see Section 5).
+Antgrid is a command centre for AI coding agents that you run on your own machine and can monitor and control remotely from your other devices. **Antgrid has a free plan, and encrypted remote control is included on it.** Paid plans add more worker machines per person, the Handler assistant, and team seats (see Section 5).
The Services are not intended for use in any jurisdiction where such use would be contrary to law or would subject us to any registration requirement. You access the Services on your own initiative and are responsible for compliance with applicable local laws.
diff --git a/web/src/ui/pricing.tsx b/web/src/ui/pricing.tsx
index a3a1fedd..3a057a31 100644
--- a/web/src/ui/pricing.tsx
+++ b/web/src/ui/pricing.tsx
@@ -46,7 +46,10 @@ const PRO_YEARLY_FEATURES = [
const ENTERPRISE_FEATURES = [
"Unlimited seats",
"Run agents on up to {workers} — per person",
- "SSO, audit log & IP allowlist",
+ // Roadmap, not shipped: the capability flags exist on the plan model but
+ // nothing reads them yet. Keep in lockstep with the Enterprise strip on the
+ // marketing site (site/src/pages/pricing.astro) and support.md.
+ "SSO, audit log & IP allowlist — on the roadmap",
"Invoiced annually",
] as const;
From f0b283059f9de8e2528e2eef687d05c8d4cc237f Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Tue, 1 Sep 2026 22:46:10 +0530
Subject: [PATCH 02/15] Site: answer "does this work with my agent" before
selling the gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The headline spent the largest type on the overview ("One screen."), which is a watching claim for a product that acts, while the argument the ProofCard beneath it makes went unstated in anything above body copy. The kicker now carries scope and the search terms; the headline carries the wedge. Fleet still owns the overview claim, being the section that proves it.
Leading with the gate needs the roster to be honest about who gets it. The new band under the hero lists every supported agent and reserves signal for the three handlerObservable answers true for — a terminal session needs the integration to POST /handler-event, a chat session needs a driver. In this palette the accent means the system is doing something (see .live-cells, and why Eyebrow gave the colour up), so ten glowing marks would promise the paid feature to six agents that cannot run it. The sentence below names the three in words as well: colour reinforces, it never carries alone.
The roster left CrossAgent because section seven is too late to answer a question that decides whether someone keeps scrolling.
Typography: the kicker's first sentence needed 783px in a 672px box and stranded a two-word tail above a display headline; the availability line was four clauses in a 21rem column and split "iOS &" from "Android". Both are now one sentence per line.
Mobile availability read as "you can't have it yet" while TestFlight and Play internal testing invites were in fact open.
---
site/bun.lock | 3 +
site/package.json | 1 +
site/src/components/sections/Compat.astro | 61 +++++++++++++++++++
site/src/components/sections/CrossAgent.astro | 15 +----
site/src/components/sections/Hero.astro | 38 +++++++-----
site/src/pages/index.astro | 2 +
6 files changed, 93 insertions(+), 27 deletions(-)
create mode 100644 site/src/components/sections/Compat.astro
diff --git a/site/bun.lock b/site/bun.lock
index bdddff5d..524a8c7d 100644
--- a/site/bun.lock
+++ b/site/bun.lock
@@ -9,6 +9,7 @@
"@fontsource-variable/archivo": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
+ "@iconify-json/simple-icons": "^1.2.94",
"@iconify-json/tabler": "^1.2.0",
"astro": "^5.0.0",
"astro-icon": "^1.1.5",
@@ -129,6 +130,8 @@
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="],
+ "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.94", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-l8UWzVxKaqZd9ABsE/M/9p6NyGkQnmCnOoZyhQmjlXCtY5PuL2rcWxOFk2l9pk7ux3ERMPkTLE4jl6kQpTkwxA=="],
+
"@iconify-json/tabler": ["@iconify-json/tabler@1.2.35", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-/sJMqHvh5ZWrEERVfDCT5NjVDeKJdhosFtKjJofAVl+P/3AzLiryOQw7WvrfDF25Xa5N/eoOQ15Y1jnhYXxBoQ=="],
"@iconify/tools": ["@iconify/tools@4.2.0", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.3.0", "cheerio": "^1.1.2", "domhandler": "^5.0.3", "extract-zip": "^2.0.1", "local-pkg": "^1.1.2", "pathe": "^2.0.3", "svgo": "^3.3.2", "tar": "^7.5.2" } }, "sha512-WRxPva/ipxYkqZd1+CkEAQmd86dQmrwH0vwK89gmp2Kh2WyyVw57XbPng0NehP3x4V1LzLsXUneP1uMfTMZmUA=="],
diff --git a/site/package.json b/site/package.json
index 0518f97a..c94fe576 100644
--- a/site/package.json
+++ b/site/package.json
@@ -15,6 +15,7 @@
"@fontsource-variable/archivo": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
+ "@iconify-json/simple-icons": "^1.2.94",
"@iconify-json/tabler": "^1.2.0",
"astro": "^5.0.0",
"astro-icon": "^1.1.5"
diff --git a/site/src/components/sections/Compat.astro b/site/src/components/sections/Compat.astro
new file mode 100644
index 00000000..90d5ae79
--- /dev/null
+++ b/site/src/components/sections/Compat.astro
@@ -0,0 +1,61 @@
+---
+import { Icon } from "astro-icon/components";
+import Chip from "../ui/Chip.astro";
+import Eyebrow from "../ui/Eyebrow.astro";
+
+// Keep in lockstep with bridge/src/agents/registry.ts. `handler: true` is what
+// `handlerObservable` answers true for — a terminal session needs the agent's
+// integration to POST /handler-event, a chat session needs a driver, and only
+// these three clear either bar.
+//
+// They are also the only three that get signal. In this palette the accent means
+// the system is doing something (see .live-cells in global.css, and the note in
+// Eyebrow.astro on why section labels gave the colour up), so a row where all
+// ten marks glow would promise the paid feature to six agents that cannot run
+// it. The sentence below names the three in words too: colour reinforces here,
+// it never carries alone.
+const agents = [
+ { name: "Claude Code", icon: "simple-icons:claudecode", handler: true },
+ { name: "Codex", icon: "simple-icons:openai", handler: true },
+ { name: "opencode", icon: "simple-icons:opencode", handler: true },
+ { name: "Cursor", icon: "simple-icons:cursor" },
+ { name: "GitHub Copilot", icon: "simple-icons:githubcopilot" },
+ { name: "Antigravity" },
+ { name: "Kilo" },
+ { name: "Kimi", icon: "simple-icons:kimi" },
+ { name: "Mistral Vibe", icon: "simple-icons:mistralai" },
+];
+
+// Grouped into two rows rather than left to wrap: the ten chips are ~100px wider
+// than the shell at every desktop width, so a single flex row always broke 9 + 1
+// and stranded the dashed chip alone on the second line. Splitting on the
+// distinction the row already encodes costs no words and fixes the orphan.
+const groups = [agents.filter((a) => a.handler), agents.filter((a) => !a.handler)];
+---
+
+
+ Handler supervises Claude Code, Codex and opencode today. Every other agent runs as a named session — terminal, files, git and alerts.
+
+
+
diff --git a/site/src/components/sections/CrossAgent.astro b/site/src/components/sections/CrossAgent.astro
index bee900a2..f93ed2ef 100644
--- a/site/src/components/sections/CrossAgent.astro
+++ b/site/src/components/sections/CrossAgent.astro
@@ -1,13 +1,8 @@
---
import { Icon } from "astro-icon/components";
import Eyebrow from "../ui/Eyebrow.astro";
-import Chip from "../ui/Chip.astro";
import UseCase from "../ui/UseCase.astro";
-// Keep in lockstep with AGENTS in bridge/src/agents/registry.ts — these are the
-// agents wired for notifications and session naming. Anything else still runs (the
-// "any terminal agent" chip), it just gets no integration, so it must not be named here.
-const agents = ["Claude Code", "Codex", "opencode", "Cursor", "GitHub Copilot", "Kilo", "Kimi", "Mistral Vibe"];
const steps = [
{ n: 1, icon: "tabler:download", title: "Install on your machine", body: "The desktop app runs your agents in real terminals and links them to your phone — Windows, macOS, Linux.", accent: false },
{ n: 2, icon: "tabler:terminal-2", title: "Run any agent", body: "Start Claude Code, Codex or Cursor exactly how you do now. antgrid wraps them — no workflow change.", accent: false },
@@ -23,13 +18,9 @@ const steps = [
antgrid doesn't replace your coding agent — it's the command centre over the ones you already run. No new model, no new CLI to learn.
-
-
+ {/* The roster moved to Compat.astro, directly under the hero: "does this work
+ with my agent" gates whether a reader keeps scrolling, so it cannot be
+ answered in section seven. This section keeps the promise and the setup. */}
Pick the agent per task, not per tool — the command centre stays the same.
How it works
diff --git a/site/src/components/sections/Hero.astro b/site/src/components/sections/Hero.astro
index 3575fb22..f1bd51d5 100644
--- a/site/src/components/sections/Hero.astro
+++ b/site/src/components/sections/Hero.astro
@@ -70,17 +70,19 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100);
type, where the default wrap strands a two-word tail ("stuck since 2am.",
"machine.") on its own line at some widths and not others. A hard
fixes the one width you tested and makes narrow viewports worse. */}
-
- Agents on your laptop, your workstation, a cloud box.{" "}
+
+ Claude Code on your laptop, Codex on your workstation, another on a cloud box.{" "}
One's been stuck since 2am.
- {/* The headline claims the overview, so the tension has to sit in the kicker
- above it — on its own "One screen." is a watching claim, and the product
- acts. The stuck agent is what the lede's proof loop then answers. */}
+ {/* The kicker carries scope — every agent, every machine — so the headline
+ is free to carry the wedge, and the ProofCard below is its evidence. It
+ used to claim the overview ("One screen."), which is a watching claim
+ for a product that acts, and left the argument the card was making
+ unstated in anything larger than body copy. Fleet.astro still owns the
+ overview claim; it is the section that proves it. */}
- Every agent.{" "}
- Every machine.{" "}
- One screen.
+ Your agent says it's done.{" "}
+ Make it prove it.
@@ -88,20 +90,26 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100);
rather than pinned to the shell's edges — pushed apart, the buttons read
as stranded rather than as a deliberate second column. */}
- {/* Carries the proof loop, because the headline no longer does — it claims
- the overview instead. Does not re-open on "your agents run on your own
- machines": the kicker and headline directly above have just said that,
- and the paragraph runs long on a phone as it is. */}
+ {/* Carries the mechanism behind the headline's claim — the headline asserts
+ the gate, this says how it closes. Names "your agents" rather than
+ opening on "They": the nearest noun above is now the singular "your
+ agent" in the headline, so the pronoun read as a reference to it. */}
- They run on your own machines — your repos, your branches, your existing subscriptions. Hand antgrid the sequence in plain text: it reads every result, refuses a "done" that arrives with no test output, exit code or diff, and moves to the next phase.
+ Your agents run on your own machines — your repos, your branches, your existing subscriptions. Hand antgrid the sequence in plain text: it reads every result, refuses a "done" that arrives with no test output, exit code or diff, and moves to the next phase.
-
- No VPN or port forwarding · End-to-end encrypted · Desktop out now, iOS & Android coming to the App Store & Play.
+ {/* Two lines because there are two jobs here: the guarantees are a
+ dot-separated list of properties, availability is a sentence. Run
+ together they made a four-clause line that wrapped to three in this
+ 21rem column and split "iOS &" from "Android" across rows. */}
+
No VPN or port forwarding · End-to-end encrypted
+
+ Desktop out now on Windows, macOS & Linux.{" "}
+ iOS & Android in private beta — request an invite.
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index d553e28b..a61458b1 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -1,6 +1,7 @@
---
import Base from "../layouts/Base.astro";
import Hero from "../components/sections/Hero.astro";
+import Compat from "../components/sections/Compat.astro";
import Phases from "../components/sections/Phases.astro";
import Fleet from "../components/sections/Fleet.astro";
import Reach from "../components/sections/Reach.astro";
@@ -22,6 +23,7 @@ const DESCRIPTION =
path="/"
>
+
From b8536e03b9cb318f8506e15a99f6cf72682b86de Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:27:32 +0530
Subject: [PATCH 03/15] Site: publish the security page, and let the reader
check it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The repo is public, SECURITY.md exists, the handshake spec is written down and the relay client is Apache-2.0 and auditable. None of that was reachable from the site, so the strongest evidence we have was also the least visible.
The page claims architecture only: what the crypto is, what has to be true before a phone can drive a machine, and — the section that does the most work — exactly what metadata the relay does see. Every claim that has a limit prints the limit beside it, including the ones that are unflattering. No SOC 2, no DPA, no residency, no compliance language of any kind, because none of it is true today and a reader can check.
Adds .well-known/security.txt per RFC 9116, and the text/plain MIME type Azure needs to serve it: the global X-Content-Type-Options: nosniff means a wrong content type would have been fatal rather than cosmetic.
home.spec.ts asserted nothing links to /security. It now asserts the opposite.
---
site/.env.example | 1 +
site/public/.well-known/security.txt | 6 +
site/public/staticwebapp.config.json | 1 +
site/src/components/Footer.astro | 1 +
site/src/config.ts | 22 ++
site/src/pages/security.astro | 397 +++++++++++++++++++++++++++
site/tests/home.spec.ts | 2 +-
site/tests/security.spec.ts | 90 ++++++
8 files changed, 519 insertions(+), 1 deletion(-)
create mode 100644 site/public/.well-known/security.txt
create mode 100644 site/src/pages/security.astro
create mode 100644 site/tests/security.spec.ts
diff --git a/site/.env.example b/site/.env.example
index 664ec636..1a83f475 100644
--- a/site/.env.example
+++ b/site/.env.example
@@ -1,2 +1,3 @@
PUBLIC_SITE_URL=https://antgrid.ai
PUBLIC_APP_URL=https://app.antgrid.ai
+PUBLIC_WEB_URL=https://app.antgrid.ai
diff --git a/site/public/.well-known/security.txt b/site/public/.well-known/security.txt
new file mode 100644
index 00000000..4e60fd12
--- /dev/null
+++ b/site/public/.well-known/security.txt
@@ -0,0 +1,6 @@
+Contact: mailto:contact@radhaai.com
+Contact: https://github.com/antgrid-ai/antgrid/security/advisories/new
+Preferred-Languages: en
+Canonical: https://antgrid.ai/.well-known/security.txt
+Expires: 2027-06-30T23:59:59.000Z
+Policy: https://github.com/antgrid-ai/antgrid/blob/HEAD/SECURITY.md
diff --git a/site/public/staticwebapp.config.json b/site/public/staticwebapp.config.json
index 9bb43526..53dcfdc1 100644
--- a/site/public/staticwebapp.config.json
+++ b/site/public/staticwebapp.config.json
@@ -12,6 +12,7 @@
{ "route": "/og/*", "headers": { "Cache-Control": "public, max-age=86400" } }
],
"mimeTypes": {
+ ".txt": "text/plain",
".webmanifest": "application/manifest+json",
".svg": "image/svg+xml"
}
diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro
index 38da3e49..1a669ef0 100644
--- a/site/src/components/Footer.astro
+++ b/site/src/components/Footer.astro
@@ -25,6 +25,7 @@ import Wordmark from "./ui/Wordmark.astro";
DownloadGet startedSupport
+ Security
diff --git a/site/src/config.ts b/site/src/config.ts
index cc7d4631..af14803e 100644
--- a/site/src/config.ts
+++ b/site/src/config.ts
@@ -1,5 +1,11 @@
export const SITE_URL = import.meta.env.PUBLIC_SITE_URL ?? "https://antgrid.ai";
export const APP_URL = import.meta.env.PUBLIC_APP_URL ?? "https://app.antgrid.ai";
+// The web service's API origin. Same deployment as APP_URL today, but declared
+// separately because it is overridden for a different reason: pointing a preview
+// build's waitlist POST at a local web server must not also move sign-in and
+// checkout off production. The site is a static build on another origin, so
+// anything under here is a cross-origin request the web service must allow.
+export const WEB_URL = import.meta.env.PUBLIC_WEB_URL ?? "https://app.antgrid.ai";
// Public releases repo. `releases/latest/download/` redirects to the
// newest stable release's asset of that exact filename, so these URLs never
@@ -21,6 +27,19 @@ export const links = {
downloadWindows: "https://get.microsoft.com/installer/download/9N0P7ZRL4D9W?referrer=appbadge&cid=site",
downloadLinux: `${RELEASES_URL}/releases/latest/download/antgrid-linux.AppImage`,
support: "/support",
+ security: "/security",
+ // Verification surfaces for /security. `HEAD` rather than a branch name:
+ // GitHub resolves it to whatever the repo's default branch is, so renaming
+ // that branch never turns these into 404s under a page whose whole argument
+ // is that the reader can go and check.
+ repo: RELEASES_URL,
+ securityPolicyFile: `${RELEASES_URL}/blob/HEAD/SECURITY.md`,
+ securityAdvisory: `${RELEASES_URL}/security/advisories/new`,
+ handshakeSpec: `${RELEASES_URL}/blob/HEAD/docs/protocol/e2e-handshake.md`,
+ handshakeVectors: `${RELEASES_URL}/blob/HEAD/evals/fixtures/e2e-handshake-vectors.json`,
+ relayClient: `${RELEASES_URL}/tree/HEAD/packages/antgrid_relay_client`,
+ wirePackage: `${RELEASES_URL}/tree/HEAD/packages/antgrid-wire`,
+ securityEmail: "mailto:contact@radhaai.com?subject=Security",
// Enterprise leads go straight to a human. Pointing them at /support put a
// budget holder on the troubleshooting page; the subject line sorts them out
// of general support mail on arrival.
@@ -33,5 +52,8 @@ export const links = {
terms: "/terms",
refunds: "/refunds",
company: "https://radhaai.com",
+ // Interest capture for founding pricing. Posted to by the inline script in
+ // WaitlistCta.astro — never rendered as an href, since a GET on it does nothing.
+ waitlist: `${WEB_URL}/api/waitlist`,
checkout: (planId: string) => `${APP_URL}/checkout?planId=${planId}`,
};
diff --git a/site/src/pages/security.astro b/site/src/pages/security.astro
new file mode 100644
index 00000000..5e3848d0
--- /dev/null
+++ b/site/src/pages/security.astro
@@ -0,0 +1,397 @@
+---
+import { Icon } from "astro-icon/components";
+import Base from "../layouts/Base.astro";
+import Chip from "../components/ui/Chip.astro";
+import Eyebrow from "../components/ui/Eyebrow.astro";
+import Readout from "../components/ui/Readout.astro";
+import { links } from "../config";
+
+// Written for someone deciding whether to run this on a work machine, so it
+// leads with mechanism and puts the source links above the fold: the page is
+// only worth anything to a reader who does not take its word for it. Two rules
+// hold the whole file together — every claim here is one the public repository
+// proves, and no strong claim ships without the limit that bounds it in the
+// same block. A sentence that cannot be traced to source does not go on this
+// page, and a gap is never written as if it were a feature.
+//
+// Colour discipline (see Readout.astro and Eyebrow.astro): amber means a human
+// is needed, so the gaps below deliberately do NOT use it — they are facts, not
+// alerts. Signal stays on the outbound source links, which are the one thing on
+// the page a reader is meant to act on.
+
+const relayHolds = [
+ { it: "Your account id", why: "taken from the verified token — it is the routing key" },
+ { it: "Device ids", why: "one per machine, one per app slot" },
+ { it: "The device name in the hello frame", why: "on a machine this defaults to its OS hostname" },
+ { it: "Each device's Ed25519 public key", why: "and its connection epoch" },
+ { it: "The licence credential id presented", why: "kept so a revocation can find the socket" },
+ { it: "Your IP address", why: "in memory only, for per-IP connection caps and rate limits" },
+ { it: "The Host header of the upgrade", why: "rebuilt into the signature body so a hello cannot be replayed elsewhere" },
+ { it: "The hello timestamp and nonce", why: "for replay rejection and equal-epoch arbitration" },
+ { it: "Connect time and last-seen time", why: "per open connection" },
+ { it: "Who is sending to whom", why: "the destination in each frame's cleartext route header" },
+ { it: "Which channel a frame is on", why: "control or preview — it keys the rate-limit bucket" },
+ { it: "The size and timing of every frame", why: "and its kind byte, forwarded without interpretation" },
+ { it: "Which of your devices are online", why: "and it tells your other live connections" },
+ { it: "Push routing", why: "the destination token and provider transit the relay" },
+ { it: "Device ids in operational logs", why: "a ping timeout names the device it dropped" },
+];
+
+const relayNeverHolds = [
+ { it: "Message payloads", why: "sealed on the sending device" },
+ { it: "The stream envelope inside them", why: "including stream ids" },
+ { it: "Project names and project ids", why: "" },
+ { it: "File paths and file contents", why: "" },
+ { it: "Terminal output", why: "" },
+ { it: "Your prompts and the agent's replies", why: "" },
+ { it: "The text of a push notification", why: "it forwards a sealed blob and a placeholder" },
+];
+
+const gaps = [
+ {
+ title: "No second factor.",
+ body: "Sign-in is single factor: an emailed magic link, GitHub, Google, or email and password. There is no TOTP, no passkey and no hardware-key support. The primary path is the magic link, which has no password to steal, and a password set on an unverified address is dropped as soon as someone proves that address another way.",
+ },
+ {
+ title: "No audit, no penetration test, no certification.",
+ body: "What exists is a published protocol specification, cross-language test vectors that both implementations must pass, and a security policy. None of those is an external review, and we are not going to describe them as one. We hold no SOC 2 report and no ISO certification, and this page makes no compliance claim of any kind.",
+ },
+ {
+ title: "Telemetry is on by default.",
+ body: "It is opt-out, in app settings. Events carry no account id, no device id and no content — an event name, your platform, the app version, and a random install-scoped id that goes only to our own backend and never to the analytics host. Switching it off stops new events and discards anything still queued; crash reporting follows the same toggle and picks the change up at the next launch.",
+ },
+ {
+ title: "Crash reports are scrubbed on the device, not on receipt.",
+ body: "File paths are redacted throughout a report before it is sent, and raw source lines and local variables are dropped rather than redacted. That is a scrubber, and a scrubber is a best effort against a stack trace it has not seen before.",
+ },
+ {
+ title: "Denial of service is out of scope.",
+ body: "There is per-IP and per-channel rate limiting, but it protects the relay, not your availability. A rate-limited frame is dropped unrecoverably and only the sender is told. Do not read it as an uptime guarantee.",
+ },
+ {
+ title: "A machine that is already compromised is out of scope.",
+ body: "Physical access, a malicious local user, and an attacker who already has your shell are all outside what the bridge defends against. That user already has everything your agent has.",
+ },
+];
+
+const sources = [
+ { label: "Repository", href: links.repo, note: "the whole product, source-available" },
+ { label: "SECURITY.md", href: links.securityPolicyFile, note: "scope, reporting, what to expect" },
+ { label: "packages/antgrid_relay_client", href: links.relayClient, note: "the client-side encryption, Apache-2.0" },
+ { label: "packages/antgrid-wire", href: links.wirePackage, note: "the wire protocol, Apache-2.0" },
+ { label: "docs/protocol/e2e-handshake.md", href: links.handshakeSpec, note: "the handshake, specified to the byte" },
+ { label: "e2e-handshake-vectors.json", href: links.handshakeVectors, note: "the vectors both implementations must pass" },
+];
+---
+
+
+
+
+ Security
+
What runs where, and who can reach it.
+
+ antgrid runs your coding agents on your own machines and carries your traffic to your phone through a relay that holds no key to it. This page is the architecture, not a set of assurances — every claim below is one you can check in the public repository, and every claim that has a limit is printed next to it.
+
+
+ If you are evaluating this for a work machine, the three sections that matter are what the relay sees, what has to be true before a phone can drive a machine, and what doesn't exist yet. Nothing here is a compliance statement.
+
+ Traffic between your devices and your machines is end-to-end encrypted. Keys are made on the two devices, and the relay is never given one.
+
+
+
+
+
+
+ 1 Both sides generate an ephemeral X25519 keypair, fresh for this connection, and exchange the public halves.
+
+
+ 2 Both sign a canonical transcript with their long-term Ed25519 identity key. The transcript binds both device identities, both ephemeral public keys, a 32-byte fresh nonce, the machine binding and the protocol version.
+
+
+ 3 Each side verifies the other's signature against the key it already holds for that device. A key exchange someone tampered with in transit produces no signature that verifies.
+
+
+ 4 Both sides check an HMAC key-confirmation tag, compared in constant time. No application data is sent or accepted before that passes.
+
+
+ 5 Transport is AES-256-GCM, with a separate key for each direction.
+
+
+
+
+
+
+ Because the session keys are ephemeral, a long-term signing key stolen later does not decrypt sessions that already happened. Session keys live in memory for the life of one connection, are never written to disk, and every teardown path overwrites their buffers.
+
+
+
Limit
+
+ That overwrite is best effort. Both implementations run in garbage-collected runtimes, so residual key material in a process dump is expected and is not treated as a vulnerability.
+
+
+
+
Scope
+
+ This covers app-to-machine traffic that crosses the network. The desktop app driving the machine it is running on does not use this channel at all: it talks to its own bridge over a loopback socket on 127.0.0.1, authenticated by a per-process token compared in constant time. That traffic never leaves the machine. The file carrying that token is written owner-only on POSIX, as are the machine's device inventory, its phone list, its remote-access switch and its session records.
+
+
+
+ ephemeral X25519 per connection
+ Ed25519 transcript signatures
+ AES-256-GCM, one key per direction
+ constant-time key confirmation
+
+
+
+
+
+
+
+ The relay
+
What the relay does see.
+
+ Zero-knowledge is a claim about content, and only about content. Everything the relay needs in cleartext to admit a socket and route a frame, it has. Here is that list in full, beside the list of what it never holds.
+
+
+
+
+
+ {relayHolds.map((r) => (
+
+ {r.it}
+ {r.why}
+
+ ))}
+
+
+
+
+
+ {relayNeverHolds.map((r) => (
+
+ {r.it}
+ {r.why && {r.why}}
+
+ ))}
+
+
+
+
+ no database, no file, no queue anywhere in the relay — an entry exists only while a socket is open
+
+
+
+
+
+
+
+
Routing
+
+ The relay routes only between devices that belong to the same account, and that check runs fresh on every frame. It is the only routing authorization there is. A blocked route and an offline device get the identical answer, so the relay cannot be used to discover which of your machines are up, and presence is announced only to other live connections on your own account.
+
+
+
+
Admission
+
+ Every connection is admitted by a single signed hello frame, verified in a fixed order: schema, clock window, Ed25519 signature over a body that binds the relay hostname, replay cache, licence, then connection arbitration. Nothing is queued for a device that is not connected — a frame addressed to one is refused and dropped.
+
+
+
+
Push notifications
+
+ The notification body is sealed to your phone's own key on the machine — ephemeral X25519, HKDF, AES-256-GCM — before it ever reaches the relay, and the relay hands Apple or Google a generic placeholder that your phone replaces once it decrypts. Apple and Google see delivery metadata and an opaque blob. The destination push token and provider do transit the relay, which is why they are in the list above.
+
+
+
+
What we are still trusted with
+
+ This design takes the relay out of the trust boundary. It does not take our account service out of it. A relay operator who tampers with the key exchange cannot produce a signature your device accepts — but your phone learns a machine's Ed25519 identity key from your account's device inventory, served by app.antgrid.ai, so a compromised account service could hand a device that has not yet cached the real key one of its choosing. The relay is not trusted with identity. The account service is.
+
+
+
+
+
+
+
+ Remote access
+
Three things have to be true before a phone can drive a machine.
+
+ They are independent and they are checked together. Any one of them false and the command does not run.
+
+
+
+
+
01
+
The device is on your signed-in account.
+
+ A device is admitted from your account's own inventory — there is no pairing code or QR ceremony to intercept — and its Ed25519 identity must verify against the handshake transcript before anything proceeds.
+
+
+
+
02
+
That machine's remote-access switch is on.
+
+ Off on a fresh install. One boolean for the whole machine, read live at every check, so turning it off takes effect immediately without restarting anything.
+
+
+
+
03
+
The project is one that machine already knows.
+
+ A shape check plus a lookup in that machine's own catalog of projects bounds which project a remote device is allowed to name.
+
+
+
+
+
+ With the switch off, a remote device sees nothing: every inbound verb is dropped at a single chokepoint, the preview and HTTP tunnel is gated separately because it bypasses that path, and outbound streaming stops at the send.
+
+
+
+
+
Not per-project permissions
+
+ The switch is machine-wide. The project catalog is a bound on what a remote device may name, not a grant you issue per project.
+
+
+
+
A known device is identity, not permission
+
+ The machine's phone list holds labels, push routing and last-seen. Removing a phone from it revokes nothing. Revocation is deleting the device on your account — which closes its live relay sockets immediately — or turning the machine's switch off.
+
+
+
+
The desktop is exempt by design
+
+ The app driving its own machine goes over the loopback socket and never depends on the relay, so it keeps working with the switch off.
+
+
+
+
+
+
+
+ Your agents
+
Your agents talk to their providers, not to us.
+
+ Coding agents run on your machine as ordinary local processes, launched from your own environment, and reach their model providers directly under your own logins. antgrid operates no model endpoint and holds no model-provider credentials — there is no provider API key anywhere in the bridge, the relay or the web service.
+
+
+ That is not the same sentence as "your code never leaves your machine", and we are not going to write that one. Your agent sends your code to its provider; that is what it is for. The claim is that antgrid adds no hop of its own and no key of its own.
+
+
+
+
+
Handler runs your agent again
+
+ When you arm Handler on a session, it runs the agent you already picked for that session headlessly, with read-only tools over that working tree. So working-tree content and transcript excerpts reach the vendor you already chose, on your own account, without a fresh action from you for each call.
+
+
+ It never borrows a different vendor's agent to supervise a session, and those runs are kept out of your own session history.
+
+
+
+
antgrid does not sandbox your agent
+
+ An agent runs with your environment and your permissions, and an agent you have configured to skip approvals will skip them. Handler's destructive-path floor is an advisory floor, not a sandbox.
+
+
+ In an isolated session, the branch's own setup steps run as shell lines — the same trust class as the commands you run yourself. Escapes out of the checkout path are refused, but the commands themselves are branch content.
+
+
+
+
+
+
+
+ Honest inventory
+
What doesn't exist yet.
+
+ Every item here is something a reasonable reviewer will ask for and we do not have. None of it is written as a feature.
+
+
+ {gaps.map((g) => (
+
+
{g.title}
+
{g.body}
+
+ ))}
+
+
+ What we collect, and for how long, is set out on the privacy page.
+
+
+
+
+
+ Verify it
+
Check the crypto claims yourself.
+
+ The handshake is specified in public down to the byte layout of the signed transcript, with cross-language test vectors that both the TypeScript and the Dart implementation must pass. The wire protocol and the client-side encryption are Apache-2.0 and carry their own licence files, so you can read, reimplement and publish work built on them without asking us.
+
+
+ The rest of antgrid is source-available under the Elastic License 2.0. That is not OSI-approved open source and we do not call it that.
+
+ If you can read traffic the relay is not supposed to read, or run something on a machine without its owner's account and consent, we want to hear about it before anyone else does. Please don't open a public issue, discussion or pull request for it.
+
Preferred, and it keeps the thread on the repository.
+
+
+
+ We acknowledge reports within three business days, tell you what we found, keep you updated while we fix it, and credit you in the release notes if you want it. We ask for a reasonable window before public disclosure. Please test against your own account and your own machines.
+
+
+
+
diff --git a/site/tests/home.spec.ts b/site/tests/home.spec.ts
index 2e894c3a..bce47fc3 100644
--- a/site/tests/home.spec.ts
+++ b/site/tests/home.spec.ts
@@ -64,7 +64,7 @@ test("no horizontal overflow on mobile", async ({ page }) => {
});
test("internal links resolve (no dangling hrefs to missing pages)", async ({ page }) => {
- const removedPages = ["/docs", "/security"];
+ const removedPages = ["/docs"];
for (const startPath of ["/", "/pricing", "/get-started"]) {
await page.goto(startPath);
diff --git a/site/tests/security.spec.ts b/site/tests/security.spec.ts
new file mode 100644
index 00000000..b9aab4d0
--- /dev/null
+++ b/site/tests/security.spec.ts
@@ -0,0 +1,90 @@
+import { test, expect } from "@playwright/test";
+
+// The page only works if a reader can go and check it, so what is asserted here
+// is the structure that makes that possible: the sections a sceptical reader is
+// sent to, the outbound links that let them read the crypto themselves, and
+// security.txt actually being served. Prose inside the sections is deliberately
+// not asserted — see the note at the top of home.spec.ts.
+
+const REPO = "https://github.com/antgrid-ai/antgrid";
+
+test("security page renders with one h1 and the sections it promises", async ({ page }) => {
+ await page.goto("/security");
+ const h1 = page.getByRole("heading", { level: 1 });
+ await expect(h1).toHaveCount(1);
+ expect((await h1.innerText()).trim().length).toBeGreaterThan(0);
+
+ await expect(page.getByRole("heading", { name: /what the relay does see/i })).toBeVisible();
+ await expect(page.getByRole("heading", { name: /three things have to be true/i })).toBeVisible();
+ await expect(page.getByRole("heading", { name: /exist yet/i })).toBeVisible();
+ await expect(page.getByRole("heading", { name: /reporting a vulnerability/i })).toBeVisible();
+
+ // The relay section is the page's central claim: both halves of the ledger
+ // must render, not just the flattering one.
+ const relay = page.locator("#relay");
+ await expect(relay.getByText("in cleartext at the relay")).toBeVisible();
+ await expect(relay.getByText("never at the relay")).toBeVisible();
+});
+
+test("the verification links point at the public repository", async ({ page }) => {
+ await page.goto("/security");
+ // Asserted as targets rather than fetched: these are third-party URLs, and a
+ // GitHub outage must not be able to fail the site suite. `.first()` because
+ // each of these is offered twice — once above the fold, once in the verify
+ // list — and a second copy appearing is not a regression.
+ await expect(page.getByRole("link", { name: "Repository" }).first()).toHaveAttribute("href", REPO);
+ await expect(page.getByRole("link", { name: "SECURITY.md" }).first()).toHaveAttribute(
+ "href",
+ `${REPO}/blob/HEAD/SECURITY.md`
+ );
+ await expect(page.getByRole("link", { name: "packages/antgrid_relay_client" }).first()).toHaveAttribute(
+ "href",
+ `${REPO}/tree/HEAD/packages/antgrid_relay_client`
+ );
+ await expect(page.getByRole("link", { name: /report a vulnerability/i })).toHaveAttribute(
+ "href",
+ `${REPO}/security/advisories/new`
+ );
+ await expect(page.getByRole("link", { name: "contact@radhaai.com" })).toHaveAttribute(
+ "href",
+ /^mailto:contact@radhaai\.com/
+ );
+});
+
+test("every internal link on the page resolves", async ({ page }) => {
+ await page.goto("/security");
+ const hrefs = await page.locator("a[href^='/']").evaluateAll((els) =>
+ [...new Set(els.map((e) => (e as HTMLAnchorElement).getAttribute("href")!))].filter((h) => !h.startsWith("/#"))
+ );
+ expect(hrefs.length).toBeGreaterThan(0);
+ for (const href of hrefs) {
+ const res = await page.request.get(href);
+ expect(res.status(), `dead link on /security: ${href}`).toBeLessThan(400);
+ }
+});
+
+test("security.txt is served with the fields a scanner reads", async ({ page }) => {
+ const res = await page.request.get("/.well-known/security.txt");
+ expect(res.status()).toBe(200);
+ const body = await res.text();
+ expect(body).toContain("Contact: mailto:contact@radhaai.com");
+ expect(body).toContain("Canonical: https://antgrid.ai/.well-known/security.txt");
+ expect(body).toContain("Preferred-Languages:");
+ // RFC 9116 treats an expired file as stale, so the date has to stay ahead of
+ // the reader — bump it, never drop the field.
+ const expires = body.match(/^Expires: (.+)$/m);
+ expect(expires, "security.txt has no Expires field").toBeTruthy();
+ expect(new Date(expires![1]).getTime()).toBeGreaterThan(Date.now());
+});
+
+test("the footer routes readers to the security page", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.locator("footer").getByRole("link", { name: "Security" })).toHaveAttribute("href", "/security");
+});
+
+test("no horizontal overflow on mobile", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/security");
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
+ expect(overflow).toBe(false);
+});
From 3c4b642e6324eb1726e018848f22faebe1ad67ad Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:27:44 +0530
Subject: [PATCH 04/15] Replace the dead paid buttons with a founding-price
capture on all three surfaces
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every paid CTA rendered as a disabled "Available after beta" control, and the site had no email capture anywhere. That is the one instrument that produces pricing signal before launch, so the shutters become a form: POST /api/waitlist on the web service, backed by a Prisma model, Zod-validated, CORS'd for the marketing origin, and idempotent on a repeat address so it never leaks whether one is already on the list.
The capture names no figure. An address is not consent to a price, and we have no pricing data yet to set one with.
The card hosting it no longer strikes through $99. That price has never been charged, so rendering it as a crossed-out former price invents a reference price the product never had — the thing CCPA's dark-pattern rules and EU Omnibus Art. 6a both reach. Stated forwards as the list price at launch it is the same contrast and a true sentence. pricing.spec.ts now asserts the absence of any strike-through as well as the figures.
The app's worker-cap dialog stops offering an Upgrade button that could not be pressed and points at the same list; it is copy and a link, so it degrades to a dead link rather than a broken flow if web deploys later.
---
app/lib/screens/device_cap_dialog.dart | 39 +++--
site/src/components/pricing/PlanCard.astro | 20 ++-
site/src/components/pricing/WaitlistCta.astro | 143 +++++++++++++++++
site/src/data/pricing.ts | 26 +--
site/src/pages/pricing.astro | 6 +-
site/src/pages/support.md | 2 +-
site/tests/contracts.spec.ts | 21 ++-
site/tests/pricing.spec.ts | 126 ++++++++++++++-
.../migration.sql | 21 +++
web/prisma/schema.prisma | 15 ++
web/src/app.ts | 2 +
web/src/routes/ui.tsx | 10 +-
web/src/routes/waitlist.ts | 60 +++++++
web/src/ui/asset.ts | 1 +
web/src/ui/entries/waitlist.ts | 111 +++++++++++++
web/src/ui/pricing.tsx | 149 ++++++++----------
web/tests/helpers/pg.ts | 1 +
web/tests/routes/waitlist.test.ts | 127 +++++++++++++++
web/vite.config.ts | 1 +
19 files changed, 752 insertions(+), 129 deletions(-)
create mode 100644 site/src/components/pricing/WaitlistCta.astro
create mode 100644 web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql
create mode 100644 web/src/routes/waitlist.ts
create mode 100644 web/src/ui/entries/waitlist.ts
create mode 100644 web/tests/routes/waitlist.test.ts
diff --git a/app/lib/screens/device_cap_dialog.dart b/app/lib/screens/device_cap_dialog.dart
index df429078..64f13f61 100644
--- a/app/lib/screens/device_cap_dialog.dart
+++ b/app/lib/screens/device_cap_dialog.dart
@@ -7,13 +7,21 @@ import '../design/ab_icons.dart';
import '../design/ab_tokens.dart';
import '../design/widgets/ab_button.dart';
import '../design/widgets/ab_confirm_dialog.dart';
+import '../design/widgets/ab_icon.dart';
import '../design/widgets/ab_icon_button.dart';
import '../design/widgets/ab_list_row.dart';
-import '../design/widgets/ab_tooltip.dart';
import '../providers/auth.dart';
import '../providers/device_provisioning.dart';
import '../providers/post_signin_provisioning.dart';
import '../services/devices_api.dart';
+import '../util/detached.dart';
+import '../util/external_url.dart';
+
+/// Where the worker-cap variant sends someone who wants more machines. There is
+/// no checkout to send them to during the beta, so the ask is captured on the
+/// site instead; no price is named here or on the way out, because none is
+/// committed to yet.
+const _foundingPricingUrl = 'https://antgrid.ai/pricing';
/// Shows the device-cap remediation dialog and resolves when it is dismissed.
/// Always clears [deviceCapProvider] on close so a later provisioning attempt
@@ -34,8 +42,7 @@ Future showDeviceCapDialog(
/// same way: revoke one of the listed devices, then retry provisioning this
/// machine. Only the copy differs — `appDeviceLimit` is an abuse ceiling that
/// pricing never mentions, so its variant never offers upgrading, while the
-/// worker cap is the paid axis and shows an upgrade affordance (disabled until
-/// checkout ships).
+/// worker cap is the paid axis and points at the founding-price waitlist.
class DeviceCapDialog extends ConsumerStatefulWidget {
const DeviceCapDialog({super.key, required this.info});
@@ -206,20 +213,30 @@ class _DeviceCapDialogState extends ConsumerState {
runSpacing: AbTokens.space8,
children: [
if (_isWorker) ...[
- // Checkout is not wired yet, so the paid path is shown and
- // legibly shut rather than absent. The tooltip alone would
- // leave the button unexplained on mobile (no hover), hence
- // the inline label beside it.
+ // The machine slot cannot be bought during the beta, so the
+ // paid path leads somewhere that works instead of standing
+ // there disabled. The line says why the button is a
+ // waitlist and not a purchase; a tooltip could not, having
+ // no hover on mobile.
Text(
- 'Coming soon',
+ 'More machines aren\'t on sale yet.',
style: AbTokens.sansStyle(
fontSize: AbTokens.fontXs,
color: p.textMuted,
),
),
- const AbTooltip(
- message: 'Coming soon',
- child: AbButton(label: 'Upgrade'),
+ AbButton(
+ label: 'Join the waitlist',
+ leading: AbIcon(
+ AbIcons.openExternal,
+ size: AbTokens.iconButtonGlyph,
+ color: p.textSecondary,
+ ),
+ onTap: () => detached(
+ 'DeviceCapDialog',
+ 'open founding-pricing waitlist',
+ () => openExternalUrl(context, _foundingPricingUrl),
+ ),
),
],
AbButton(
diff --git a/site/src/components/pricing/PlanCard.astro b/site/src/components/pricing/PlanCard.astro
index 1802ab97..703ad240 100644
--- a/site/src/components/pricing/PlanCard.astro
+++ b/site/src/components/pricing/PlanCard.astro
@@ -1,11 +1,11 @@
---
import { Icon } from "astro-icon/components";
import Button from "../ui/Button.astro";
+import WaitlistCta from "./WaitlistCta.astro";
import { links } from "../../config";
-import { BETA_FREE, type PlanCardData } from "../../data/pricing";
-interface Props { plan: PlanCardData }
-const { plan } = Astro.props;
-const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd) * 100) : null;
+import { type PlanCardData } from "../../data/pricing";
+interface Props { plan: PlanCardData; waitlistSource?: string }
+const { plan, waitlistSource = "pricing" } = Astro.props;
---
@@ -17,9 +17,15 @@ const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd)
reach for the type class, which tied a revenue test to a font size. */}
${plan.priceUsd} {plan.unit}
- {plan.listUsd && ${plan.listUsd}}
- {discountPct && {discountPct}% off launch}
+ {/* A forward price, never a struck one. The higher figure has never been
+ charged, so rendering it as a crossed-out "was" invents a reference price
+ the product never had — the thing CCPA's dark-pattern rules and EU Omnibus
+ Art. 6a both reach. Stated as the list price at launch it is the same
+ contrast and a true sentence. */}
+ {plan.listUsd && (
+
{plan.comingSoon ? (
-
+
) : (
)}
diff --git a/site/src/components/pricing/WaitlistCta.astro b/site/src/components/pricing/WaitlistCta.astro
new file mode 100644
index 00000000..03881a92
--- /dev/null
+++ b/site/src/components/pricing/WaitlistCta.astro
@@ -0,0 +1,143 @@
+---
+import { links } from "../../config";
+
+interface Props {
+ /** Which surface the address came from — sent as `source` to the API. */
+ source: string;
+ /** Unique per instance: two captures can share a page (label/status ids). */
+ id: string;
+}
+const { source, id } = Astro.props;
+const inputId = `${id}-email`;
+const statusId = `${id}-status`;
+---
+{/* `action` is the real endpoint rather than a data-* attribute so the markup
+ states its own target, but the submit button ships DISABLED: without the
+ script a native cross-origin POST would land the reader on a raw JSON body,
+ and a disabled default button also blocks implicit submission from the
+ input. The script enables it — so scriptless readers get the noscript note
+ instead of a control that looks live and is not. */}
+
+
+
+
+
diff --git a/site/src/data/pricing.ts b/site/src/data/pricing.ts
index 601e42ad..3d1b3c8f 100644
--- a/site/src/data/pricing.ts
+++ b/site/src/data/pricing.ts
@@ -4,21 +4,23 @@
// every seat gets its own copy of (the cap is counted per user in
// checkCapAndUpsert, so a team never pools them) — the only place a machine count
// is a paywall is Free. If YEARLY_OFFER_ACTIVE is turned off in web, set
-// OFFER_ACTIVE=false here so the struck price/discount disappear.
+// OFFER_ACTIVE=false here so the founding-price line disappears and the card
+// shows list. YEARLY_LIST_USD is a price we have not charged yet, so it is only
+// ever rendered forwards ("$99 at launch"), never struck through as a former one.
//
// Checkout is deliberately unwired this release: every card that would charge carries
-// `comingSoon`, which swaps the checkout link for a disabled button. Clearing it here
-// re-points the CTA at web's live `/checkout` (`web/src/routes/ui.tsx`), but the same
-// shutter is duplicated on web's own pricing page (`ComingSoonCta` in
-// web/src/ui/pricing.tsx) and on the app's WORKER_CAP Upgrade button
+// `comingSoon`, which swaps the checkout link for the founding-pricing capture
+// (`WaitlistCta.astro`). Clearing it here re-points the CTA at web's live `/checkout`
+// (`web/src/routes/ui.tsx`), but the same shutter is duplicated on web's own pricing
+// page (web/src/ui/pricing.tsx) and on the app's WORKER_CAP Upgrade button
// (app/lib/screens/device_cap_dialog.dart) — flip all three together, or the funnel
// sells a plan two of its three entry points still refuse.
import { links } from "../config";
// Single switch for the beta-free period: banners the pricing page, hides the
-// trial card, relabels paid CTAs to "Available after beta", and swaps the hero
-// pill and closing-CTA copy. Flip to false when plans activate — and update
-// support.md's beta note by hand, it is static markdown.
+// trial card, and swaps the hero pill, closing-CTA and paid-card copy. Flip to
+// false when plans activate — and update support.md's beta note by hand, it is
+// static markdown.
export const BETA_FREE = true;
export const TRIAL_DAYS = 7;
@@ -97,10 +99,10 @@ export const proYearly: PlanCardData = {
priceUsd: seatPriceUsd,
listUsd: OFFER_ACTIVE ? YEARLY_LIST_USD : undefined,
unit: "/ seat / year",
- // Under BETA_FREE the card's button is disabled ("Available after beta"), so the
+ // Under BETA_FREE the card's CTA is an interest capture, not a checkout, so the
// copy must not promise a startable trial or a running subscription.
note: BETA_FREE
- ? "Free while the beta runs — this is the launch price"
+ ? "Free while the beta runs"
: `${TRIAL_DAYS}-day free trial, then $${seatPriceUsd} per seat / year`,
features: [
"Handler AI assistant — stack instructions, evidence-gated \"done\", one-tap undo",
@@ -111,8 +113,10 @@ export const proYearly: PlanCardData = {
"E2E zero-knowledge relay · priority support",
],
cta: "Get Pro",
+ // No figure on this line while the CTA is a capture: the reader is agreeing to
+ // hear from us, not to a price, and the headline above already carries the number.
ctaFooter: BETA_FREE
- ? `$${seatPriceUsd} per seat / year when plans activate`
+ ? "Founding pricing at launch · no card, nothing charged during the beta"
: `$${seatPriceUsd} per seat / year · renews automatically · cancel anytime`,
recommended: true,
};
diff --git a/site/src/pages/pricing.astro b/site/src/pages/pricing.astro
index 4161aa31..7c0dfc55 100644
--- a/site/src/pages/pricing.astro
+++ b/site/src/pages/pricing.astro
@@ -19,13 +19,13 @@ import { links } from "../config";
{BETA_FREE && (
- antgrid is free while in beta — everything below is included. Paid plans activate when the beta ends; prices shown are launch prices.
+ antgrid is free while in beta — everything below is included. Paid plans activate when the beta ends; prices shown are founding prices.
)}
- {!BETA_FREE && }
-
+ {!BETA_FREE && }
+
diff --git a/site/src/pages/support.md b/site/src/pages/support.md
index 78499188..d3a0777a 100644
--- a/site/src/pages/support.md
+++ b/site/src/pages/support.md
@@ -42,7 +42,7 @@ Antgrid supports three sign-in options:
## Plans and billing
-**Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are the launch prices.
+**Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are founding prices, below the list price at launch.
Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams are **Enterprise**, where SSO, audit logs and IP allowlisting are on the roadmap — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details.
diff --git a/site/tests/contracts.spec.ts b/site/tests/contracts.spec.ts
index 90eadc6b..a1df7df6 100644
--- a/site/tests/contracts.spec.ts
+++ b/site/tests/contracts.spec.ts
@@ -14,6 +14,9 @@ const DOWNLOADS = {
linux: "https://github.com/antgrid-ai/antgrid/releases/latest/download/antgrid-linux.AppImage",
};
+// The web service, which is a different origin from this static build.
+const WAITLIST_ORIGIN = "https://app.antgrid.ai";
+
test("desktop downloads point at the published release artifacts", async ({ page }) => {
await page.goto("/#download");
const band = page.locator("#download");
@@ -59,14 +62,22 @@ test("the paid path stays closed: no checkout links anywhere", async ({ page })
}
});
-test("charging plans render a disabled button, never a live CTA", async ({ page }) => {
+test("charging plans capture interest, never a live checkout CTA", async ({ page }) => {
await page.goto("/pricing");
- // Any card carrying `comingSoon` (pricing.ts) must swap its checkout link for a
- // disabled button. Asserted by state, not by label — the label is BETA_FREE-gated.
+ // Any card carrying `comingSoon` (pricing.ts) must swap its checkout link for the
+ // founding-price capture. Asserted by state, not by label — copy is BETA_FREE-gated.
const yearlyCard = page.locator("span.font-mono", { hasText: /^Pro$/ }).locator("..").locator("..");
- await expect(yearlyCard.locator("button[disabled]")).toHaveCount(1);
+ const capture = yearlyCard.locator("form[data-waitlist]");
+ await expect(capture).toHaveCount(1);
+ // The address goes to the web service, cross-origin from this static site.
+ await expect(capture).toHaveAttribute("action", `${WAITLIST_ORIGIN}/api/waitlist`);
+ await expect(capture).toHaveAttribute("data-waitlist", "pricing");
await expect(yearlyCard.locator("a[href]")).toHaveCount(0);
+ // The capture ships disabled so a scriptless reader is told to email instead; once
+ // the page's script has run nothing in the card may still be inert, or the dead
+ // paid CTA is back under a new name.
+ await expect(yearlyCard.locator("button[disabled]")).toHaveCount(0);
// The free card is the one plan whose CTA stays live.
const freeCard = page.locator("span.font-mono", { hasText: /^Free$/ }).locator("..").locator("..");
@@ -85,7 +96,7 @@ for (const path of ["/pricing", "/terms", "/refunds", "/support"]) {
// Indexed pages. og-card is excluded on purpose: it is the screenshot source for
// the social card, already noindex and filtered out of the sitemap.
-const INDEXED = ["/", "/pricing", "/get-started", "/support", "/privacy", "/terms", "/refunds"];
+const INDEXED = ["/", "/pricing", "/get-started", "/support", "/privacy", "/terms", "/refunds", "/security"];
test("every indexed page ships a description search engines will show whole", async ({ page }) => {
// 155 is where Google starts truncating. Social previews cut earlier — mobile
diff --git a/site/tests/pricing.spec.ts b/site/tests/pricing.spec.ts
index 94013ca0..4a28947c 100644
--- a/site/tests/pricing.spec.ts
+++ b/site/tests/pricing.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from "@playwright/test";
+import { test, expect, type Page } from "@playwright/test";
// Prices and the tier axis. KEEP IN LOCKSTEP with src/data/pricing.ts, which is itself
// pinned to the shipped catalog by web/tests/billing/site-pricing-lockstep.test.ts —
@@ -23,15 +23,133 @@ test("plan cards carry the real prices, machine allowances and seat ceiling", as
await expect(freeCard.locator("[data-price]", { hasText: "$0" })).toBeVisible();
await expect(freeCard.getByText("1 worker machine")).toBeVisible();
- // Yearly card: $49 offer price (the headline figure) + $99 struck list price, both
- // per seat — the unit is the claim, so it is asserted beside the number.
+ // Yearly card: $49 founding price (the headline figure) and $99 named as the
+ // price at launch. The unit is part of the claim, so it is asserted beside the
+ // number. $99 must never render as a struck-through former price — it has never
+ // been charged, so a crossed-out "was" would be a reference price we invented.
await expect(yearlyCard.locator("[data-price]", { hasText: "$49" })).toBeVisible();
- await expect(yearlyCard.locator("span.line-through", { hasText: "$99" })).toBeVisible();
+ await expect(yearlyCard.locator("[data-list]", { hasText: "$99" })).toBeVisible();
+ await expect(yearlyCard.getByText(/Founding price/)).toBeVisible();
+ await expect(yearlyCard.locator("s, del, .line-through")).toHaveCount(0);
await expect(yearlyCard.getByText("/ seat / year")).toBeVisible();
await expect(yearlyCard.getByText("Up to 10 worker machines per person")).toBeVisible();
await expect(yearlyCard.getByText(/Up to 25 seats/)).toBeVisible();
});
+// Founding-price capture. The paid card's CTA is an interest form, not a checkout —
+// contracts.spec.ts pins its target and the closed paid path; these cover what the
+// reader actually experiences at the control.
+
+const capture = (page: Page) =>
+ page.locator("span.font-mono", { hasText: /^Pro$/ }).locator("..").locator("..").locator("form[data-waitlist]");
+
+test("the capture asks for an address without naming a price", async ({ page }) => {
+ await page.goto("/pricing");
+ const form = capture(page);
+
+ // The waitlist trades on "founding pricing", never on a figure or a struck anchor —
+ // an address is not consent to a price.
+ await expect(form).not.toContainText("$");
+ await expect(form.locator("s, del, .line-through")).toHaveCount(0);
+
+ // Accessibility floor: a real label (visually hidden is fine), an email field, and a
+ // status line the reader's screen reader is told about.
+ const field = form.getByLabel(/email address/i);
+ await expect(field).toHaveAttribute("type", "email");
+ await expect(form.locator("[aria-live]")).toHaveCount(1);
+ await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled();
+});
+
+test("joining posts the address with the surface it came from", async ({ page }) => {
+ const posted: unknown[] = [];
+ await page.route("**/api/waitlist", async (route) => {
+ posted.push(route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json", "access-control-allow-origin": "*" },
+ body: JSON.stringify({ ok: true }),
+ });
+ });
+
+ await page.goto("/pricing");
+ const form = capture(page);
+ await form.getByLabel(/email address/i).fill("founder@example.com");
+ await form.getByRole("button", { name: /^Join the list$/ }).click();
+
+ // One verb throughout: the button says Join, so the confirmation says joined.
+ await expect(form.locator("[aria-live]")).toContainText(/joined the list/i);
+ await expect(form.getByRole("button", { name: /^Joined$/ })).toBeVisible();
+ expect(posted).toEqual([{ email: "founder@example.com", source: "pricing" }]);
+});
+
+test("the control says it is working while the address is in flight", async ({ page }) => {
+ let release = () => {};
+ const held = new Promise((resolve) => (release = resolve));
+ await page.route("**/api/waitlist", async (route) => {
+ await held;
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json", "access-control-allow-origin": "*" },
+ body: JSON.stringify({ ok: true }),
+ });
+ });
+
+ await page.goto("/pricing");
+ const form = capture(page);
+ await form.getByLabel(/email address/i).fill("founder@example.com");
+ await form.getByRole("button", { name: /^Join the list$/ }).click();
+
+ // Same verb in every state, so the reader never wonders whether a second thing
+ // happened: Join -> Joining -> Joined.
+ await expect(form.getByRole("button", { name: /^Joining/ })).toBeDisabled();
+ release();
+ await expect(form.getByRole("button", { name: /^Joined$/ })).toBeVisible();
+});
+
+test("a malformed address is refused at the field, before anything is sent", async ({ page }) => {
+ let requests = 0;
+ await page.route("**/api/waitlist", async (route) => {
+ requests += 1;
+ await route.fulfill({ status: 200, headers: { "access-control-allow-origin": "*" }, body: "{}" });
+ });
+
+ await page.goto("/pricing");
+ const form = capture(page);
+ await form.getByLabel(/email address/i).fill("founder@");
+ await form.getByRole("button", { name: /^Join the list$/ }).click();
+
+ await expect(form.locator("[aria-live]")).toContainText(/does not look like an email address/i);
+ // The error says what to do next and leaves the control usable, rather than
+ // dead-ending the way the button it replaced did.
+ await expect(form.locator("[aria-live]")).toContainText(/try again/i);
+ await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled();
+ expect(requests).toBe(0);
+});
+
+test("a rejected address explains itself and leaves the reader able to retry", async ({ page }) => {
+ // The API answers a rejection with a machine code, so the page owes the reader
+ // its own sentence — echoing "BAD_REQUEST" back at them is not an explanation.
+ await page.route("**/api/waitlist", async (route) => {
+ await route.fulfill({
+ status: 400,
+ headers: { "content-type": "application/json", "access-control-allow-origin": "*" },
+ body: JSON.stringify({ ok: false, error: "BAD_REQUEST" }),
+ });
+ });
+
+ await page.goto("/pricing");
+ const form = capture(page);
+ await form.getByLabel(/email address/i).fill("founder@example.com");
+ await form.getByRole("button", { name: /^Join the list$/ }).click();
+
+ const status = form.locator("[aria-live]");
+ await expect(status).toContainText(/not accepted/i);
+ await expect(status).toContainText(/try again/i);
+ await expect(status).not.toContainText("BAD_REQUEST");
+ await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled();
+ await expect(form.getByLabel(/email address/i)).toBeEditable();
+});
+
test("the FAQ answers the seat and machine questions in place", async ({ page }) => {
await page.goto("/pricing");
await expect(page.getByRole("heading", { name: /what counts as a seat\?/i })).toBeVisible();
diff --git a/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql b/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql
new file mode 100644
index 00000000..df5bb1ae
--- /dev/null
+++ b/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql
@@ -0,0 +1,21 @@
+-- Add waitlist_signup for the marketing site's launch-interest capture.
+-- Rows are written by the anonymous, cross-origin POST /api/waitlist route; no
+-- FK to user — a signup happens long before an account exists.
+--
+-- The unique index on "email" is load-bearing, not hygiene: the route inserts
+-- with ON CONFLICT DO NOTHING so a repeat submit is a silent no-op answered
+-- with the same 200 as a first submit. Without it a second submit would create
+-- a duplicate row, and any later de-dup would have to distinguish the two —
+-- which is exactly the membership fact the endpoint must not expose.
+
+CREATE TABLE "waitlist_signup" (
+ "id" UUID NOT NULL DEFAULT gen_random_uuid(),
+ "email" TEXT NOT NULL,
+ "source" TEXT NOT NULL,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(),
+
+ CONSTRAINT "waitlist_signup_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "waitlist_signup_email_key" ON "waitlist_signup" ("email");
+CREATE INDEX "waitlist_signup_created_at_idx" ON "waitlist_signup" ("created_at");
diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma
index 5a7f26ea..5fea2e22 100644
--- a/web/prisma/schema.prisma
+++ b/web/prisma/schema.prisma
@@ -367,6 +367,21 @@ model AnalyticEvent {
@@map("analytic_event")
}
+// ---------- Marketing waitlist ----------
+
+model WaitlistSignup {
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
+ // Unique so a re-submit is an ON CONFLICT DO NOTHING rather than a duplicate
+ // row — POST /api/waitlist answers 200 either way and must never reveal which
+ // of the two happened. Stored already lowercased/trimmed by the route.
+ email String @unique(map: "waitlist_signup_email_key")
+ source String
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+
+ @@index([createdAt], map: "waitlist_signup_created_at_idx")
+ @@map("waitlist_signup")
+}
+
// ---------- Better-Auth OAuth Provider plugin ----------
model OauthClient {
diff --git a/web/src/app.ts b/web/src/app.ts
index 03ba8d5b..38342d10 100644
--- a/web/src/app.ts
+++ b/web/src/app.ts
@@ -14,6 +14,7 @@ import { devBillingRoutes } from "./routes/dev-billing.js";
import { oauthHandoffRoutes } from "./routes/oauth-handoff.js";
import { oauthStartRoutes } from "./routes/oauth-start.js";
import { eventsRoutes } from "./routes/events.js";
+import { waitlistRoutes } from "./routes/waitlist.js";
import { uiRoutes } from "./routes/ui.js";
import { setPublicOrigin } from "./ui/origin.js";
import type { DB } from "./db/index.js";
@@ -110,6 +111,7 @@ export function buildApp(deps: AppDeps) {
});
app.route("/", health);
app.route("/", eventsRoutes({ db: deps.db, clientIp }));
+ app.route("/", waitlistRoutes({ db: deps.db, clientIp }));
app.route("/", deviceRoutes({ db: deps.db, auth: deps.auth, relay: deps.relay }));
app.route("/", agentRoutes({ db: deps.db, auth: deps.auth, env: deps.env }));
app.route("/", subscriptionRoutes({ db: deps.db, auth: deps.auth }));
diff --git a/web/src/routes/ui.tsx b/web/src/routes/ui.tsx
index 2f9a8122..848f00da 100644
--- a/web/src/routes/ui.tsx
+++ b/web/src/routes/ui.tsx
@@ -1535,10 +1535,10 @@ export function uiRoutes(deps: {
const userId = c.get("userId");
await provisionProductAccountForUser(deps.db, userId);
const plans = await listActivePlans(deps.db);
- // TEMP-PROMO: every plan renders as a disabled "Coming soon" card while
- // in-app purchases aren't live — grep "TEMP-PROMO" repo-wide for every
- // related spot (backend grant logic in web/src/models/subscription.ts
- // plus the matching disabled UI in web/src/ui/pricing.tsx).
+ // TEMP-PROMO: no plan can be bought while in-app purchases aren't live, so
+ // the Pro card takes waitlist signups instead of running a checkout — grep
+ // "TEMP-PROMO" repo-wide for every related spot (backend grant logic in
+ // web/src/models/subscription.ts plus the static UI in web/src/ui/pricing.tsx).
//
// TO RESTORE ONCE PAYMENT INTEGRATION SHIPS: delete the `const plans =`
// line above and the `c.html(...)` call below, then uncomment the two
@@ -1555,7 +1555,7 @@ export function uiRoutes(deps: {
// if (plan && isPlanId(plan.slug)) currentPlanSlug = plan.slug;
// }
return c.html(
-
+
// s.trim().toLowerCase())
+ .pipe(z.email()),
+ source: z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9_-]*$/),
+});
+
+// Anonymous public writer keyed on an attacker-chosen email: burst 5, refill
+// 1 per 10s per IP. Far tighter than the analytics ingest — a human submits
+// this form once, and the row it writes is not idempotent per-IP the way an
+// event batch is.
+const signupLimiter = tokenBucket(5, 0.1);
+
+export function waitlistRoutes(deps: { db: DB; clientIp: ClientIpResolver }) {
+ const r = new Hono();
+
+ r.post("/api/waitlist", async (c) => {
+ // Spoof-safe resolution (peer + trusted-proxy XFF walk); the IP is used
+ // only for this bucket and is deliberately never stored on the row.
+ const ip = deps.clientIp(c) ?? "unknown";
+ if (!signupLimiter(ip)) return c.json({ ok: false, error: "RATE_LIMITED" }, 429);
+
+ const parsed = Signup.safeParse(await c.req.json().catch(() => null));
+ if (!parsed.success) {
+ return c.json({ ok: false, error: "BAD_REQUEST", issues: parsed.error.issues }, 400);
+ }
+
+ // createMany + skipDuplicates emits INSERT ... ON CONFLICT DO NOTHING, so
+ // two concurrent submits of the same address cannot race into a unique
+ // violation — which would surface as app.onError's 500 and tell the caller
+ // the address was already on the list.
+ await deps.db.waitlistSignup.createMany({
+ data: [{ email: parsed.data.email, source: parsed.data.source }],
+ skipDuplicates: true,
+ });
+
+ // Identical response whether the row was inserted or already existed:
+ // membership in the list is not something a stranger may probe for.
+ return c.json({ ok: true }, 200);
+ });
+
+ return r;
+}
diff --git a/web/src/ui/asset.ts b/web/src/ui/asset.ts
index 0dc7d870..76d694e1 100644
--- a/web/src/ui/asset.ts
+++ b/web/src/ui/asset.ts
@@ -15,6 +15,7 @@ const ENTRIES = {
checkout: "src/ui/entries/checkout.ts",
dashboard: "src/ui/entries/dashboard.ts",
devices: "src/ui/entries/devices.ts",
+ waitlist: "src/ui/entries/waitlist.ts",
} as const;
// Fonts reach the manifest as dependencies of styles.css, not as inputs, so
diff --git a/web/src/ui/entries/waitlist.ts b/web/src/ui/entries/waitlist.ts
new file mode 100644
index 00000000..9d150f64
--- /dev/null
+++ b/web/src/ui/entries/waitlist.ts
@@ -0,0 +1,111 @@
+/**
+ * Founding-price waitlist capture on /pricing.
+ *
+ * Not htmx: the target is the public JSON endpoint the marketing site posts to
+ * as well (POST /api/waitlist), so there is no fragment to swap and no redirect
+ * to follow — the same reason entries/devices.ts issues its own request rather
+ * than going through the vendored htmx bundle.
+ */
+
+const IDLE_NOTE = "Founding pricing at launch.";
+
+/** Confirms in the button's own words. "Submitted" would leave the reader
+ * guessing whether the thing they joined is the thing that answered. */
+const SUCCESS_NOTE = "You're on the waitlist. Founding pricing at launch.";
+
+const STATUS_BASE = "text-xs text-center mt-3 min-h-10";
+const STATUS_TONE = {
+ idle: "text-faint",
+ ok: "text-ink2",
+ error: "text-error",
+} as const;
+
+type Tone = keyof typeof STATUS_TONE;
+
+/**
+ * The endpoint answers a bare code, and a code is not an instruction — each
+ * status has to say what the server did with the address and what the reader
+ * does next. No apology: nothing here is broken, and "sorry" would be the only
+ * word in the sentence that carries no information.
+ */
+function messageForStatus(status: number): string {
+ if (status === 400) {
+ return "That address isn't a valid email. Correct it and submit again.";
+ }
+ if (status === 429) {
+ return "Too many submissions from this network. Wait a minute, then submit again.";
+ }
+ return `The server rejected the request (HTTP ${status}). Submit again in a moment.`;
+}
+
+function setStatus(message: string, tone: Tone) {
+ const el = document.getElementById("waitlist-status");
+ if (!el) return;
+ el.textContent = message;
+ el.className = `${STATUS_BASE} ${STATUS_TONE[tone]}`;
+}
+
+let inFlight = false;
+
+async function submit(form: HTMLFormElement) {
+ const input = document.getElementById("waitlist-email");
+ const button = document.getElementById("waitlist-submit");
+ if (
+ inFlight ||
+ !(input instanceof HTMLInputElement) ||
+ !(button instanceof HTMLButtonElement)
+ ) {
+ return;
+ }
+
+ inFlight = true;
+ button.disabled = true;
+ const idleLabel = button.textContent;
+ button.textContent = "Joining…";
+ setStatus(IDLE_NOTE, "idle");
+
+ try {
+ const res = await fetch(form.action, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ email: input.value,
+ source: form.dataset.waitlistSource,
+ }),
+ });
+ if (!res.ok) {
+ setStatus(messageForStatus(res.status), "error");
+ return;
+ }
+ // The endpoint answers identically for an address already on the list, so
+ // there is nothing to tell apart here — and telling them apart is exactly
+ // what it refuses to leak.
+ form.hidden = true;
+ setStatus(SUCCESS_NOTE, "ok");
+ } catch {
+ setStatus(
+ "The request never reached the server. Check your connection and submit again.",
+ "error",
+ );
+ } finally {
+ inFlight = false;
+ button.disabled = false;
+ button.textContent = idleLabel;
+ }
+}
+
+function init() {
+ const form = document.getElementById("waitlist-form");
+ if (!(form instanceof HTMLFormElement)) return;
+ // The form's own `action`/`method` name the real target, but submission is
+ // always intercepted: the endpoint reads a JSON body and would answer a
+ // urlencoded post with the 400 this handler exists to avoid.
+ form.addEventListener("submit", (ev) => {
+ ev.preventDefault();
+ void submit(form);
+ });
+}
+
+init();
+
+export {};
diff --git a/web/src/ui/pricing.tsx b/web/src/ui/pricing.tsx
index 3a057a31..15217f38 100644
--- a/web/src/ui/pricing.tsx
+++ b/web/src/ui/pricing.tsx
@@ -1,25 +1,19 @@
import { Layout, PageHead } from "./layout.js";
-import {
- BETA,
- displayPriceCents,
- formatUsd,
- FREE_WORKER_LIMIT,
- TRIAL_DAYS,
- type BillingEnv,
-} from "../billing/plans.js";
+import { asset } from "./asset.js";
+import { FREE_WORKER_LIMIT } from "../billing/plans.js";
import type { PlanRow } from "../models/plan.js";
export type PricingPageProps = {
user: { email?: string | null };
plans: PlanRow[];
- env: BillingEnv;
};
-/** Why a plan can't be bought, in the CTA itself. "Coming soon" reads as
- * half-built to someone who arrived from a site that told them the beta is
- * free; naming the beta makes the disabled button an explanation. Keep the
- * beta wording identical to PlanCard.astro's on the marketing site. */
-const UNAVAILABLE_CTA_LABEL = BETA ? "Available after beta" : "Coming soon";
+/** The public waitlist endpoint (web/src/routes/waitlist.ts), which the
+ * marketing site posts to as well — same origin as this page, so a relative
+ * action reaches it. `source` is the bounded slug its schema expects, naming
+ * the surface that captured the signup. */
+const WAITLIST_ACTION = "/api/waitlist";
+const WAITLIST_SOURCE = "pricing";
/** Sales address for the contract-only plan. */
const ENTERPRISE_MAILTO = "mailto:contact@radhaai.com";
@@ -111,31 +105,60 @@ function FeatureList({
);
}
-function UnavailableCta({ footer }: { footer: string }) {
+/** The founding-price capture, in the slot a plan's buy button will take back.
+ *
+ * One per page: entries/waitlist.ts binds by id, and a second copy would
+ * collide on every one of them. */
+function WaitlistCta({ email }: { email?: string | null }) {
return (
-
-
- {footer}
+
+ {/* Idle note, error and confirmation all land here, on the `min-h-10` the
+ plan footers already reserve — so none of the three resizes the card. */}
+
+ Founding pricing at launch.
);
}
export function PricingPage(props: PricingPageProps) {
- const yearlyPrice = displayPriceCents("pro_yearly", props.env);
- const trialPlan = props.plans.find((p) => p.slug === "trial");
const yearlyPlan = props.plans.find((p) => p.slug === "pro_yearly");
const enterprisePlan = props.plans.find((p) => p.slug === "enterprise");
return (
- {/* Headline and lede match PricingHeader.astro on the marketing site,
- same as the beta CTA wording below — this is the same three plans for
- the same reader, and "Simple, honest pricing" said nothing that the
- site's line does not say better. Keep them in lockstep. */}
+ {/* Headline and lede match PricingHeader.astro on the marketing site —
+ this is the same three plans for the same reader, and "Simple, honest
+ pricing" said nothing that the site's line does not say better. Keep
+ them in lockstep. */}
Priced per person. Bring your own machines.
@@ -148,62 +171,19 @@ export function PricingPage(props: PricingPageProps) {
- {trialPlan && (
-
- )}
-
{/* The free plan row is excluded from listActivePlans, so its worker
count comes from the same constant that seeds it. */}
- {yearlyPlan && }
+ {yearlyPlan && }
- {TRIAL_DAYS}-day free trial, then {formatUsd(yearlyPrice)} per seat / year
-
-
- Add your card to start. Run agents on up to{" "}
- {machines(trialWorkers)} during the
- trial, on one seat — invite your team once it converts. Your card won't be charged
- until {firstCharge} — cancel anytime
- before then to avoid the {formatUsd(yearlyPrice)} per seat / year charge.
- Subscription renews automatically unless canceled.
-
-
-
-
- );
-}
-
function FreeCard({ workers }: { workers: number }) {
return (
+ {/* No figure in this slot. Every account already holds the promotional
+ Pro grant (ensureDefaultSubscription), so "free" is the true state
+ today — and a number printed before the price is set is one launch
+ would have to honour. */}
- {formatUsd(price)}
- / seat / year
-
Billed yearly, per seat.
+ Free
+ while in beta
+
No card and no charge while the beta runs.
-
+
);
@@ -290,13 +272,16 @@ function EnterpriseCard({ plan }: { plan: PlanRow }) {
// web/src/models/subscription.ts). So the real, working pricing/checkout UI
// below — current-plan detection, the active-plan banner, and the actual
// /checkout links — is kept here as comments instead of deleted, and the
-// static always-"Coming soon" cards above are shown instead.
+// static beta cards above are shown instead. Those cards name no price at
+// all: the Pro card takes waitlist signups for the launch price instead.
//
// TO RESTORE ONCE PAYMENT INTEGRATION SHIPS:
// 1. Delete the static replacement above this marker: PricingPageProps,
-// UNAVAILABLE_CTA_LABEL, FREE_FEATURES, UnavailableCta, PricingPage,
-// FreeTrialBanner, FreeCard, ProYearlyCard (everything from `export type
+// WAITLIST_ACTION, WAITLIST_SOURCE, FREE_FEATURES, WaitlistCta,
+// PricingPage, FreeCard, ProYearlyCard (everything from `export type
// PricingPageProps` down to just above this marker). Re-add a Free card.
+// Also drop the `waitlist` entry from vite.config.ts and ui/asset.ts, and
+// delete ui/entries/waitlist.ts, which nothing else loads.
// 2. Select everything below this marker and strip the leading "// " from
// each line (most editors: select + "toggle line comment").
// 3. In web/src/routes/ui.tsx's `/pricing` handler, do the matching restore
diff --git a/web/tests/helpers/pg.ts b/web/tests/helpers/pg.ts
index a968cf24..cea1f1a0 100644
--- a/web/tests/helpers/pg.ts
+++ b/web/tests/helpers/pg.ts
@@ -33,6 +33,7 @@ const APP_TABLES = [
"product_accounts",
"pending_sign_in",
"analytic_event",
+ "waitlist_signup",
"session",
"account",
"verification",
diff --git a/web/tests/routes/waitlist.test.ts b/web/tests/routes/waitlist.test.ts
new file mode 100644
index 00000000..a2e0db67
--- /dev/null
+++ b/web/tests/routes/waitlist.test.ts
@@ -0,0 +1,127 @@
+import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
+import { startTestPg, type PgHandle } from "../helpers/pg.js";
+import { buildTestApp } from "../helpers/app.js";
+import { parseTrustedProxies } from "antgrid-wire";
+
+let pg: PgHandle;
+beforeAll(async () => { pg = await startTestPg(); });
+afterAll(async () => { await pg.stop(); });
+beforeEach(async () => { await pg.truncate(); });
+
+// The limiter is module-scoped, so every buildTestApp in this file shares its
+// buckets. Give each test its own key by resolving through a trusted proxy and
+// varying the client hop — otherwise the second test inherits a drained bucket.
+const TRUSTED = { TRUSTED_PROXY_IPS: parseTrustedProxies("172.28.0.0/16") };
+const PROXY_PEER = { requestIP: () => ({ address: "172.28.0.9" }) };
+
+type TestApp = ReturnType["app"];
+
+function post(app: TestApp, clientIp: string, body: unknown) {
+ return app.request(
+ "/api/waitlist",
+ {
+ method: "POST",
+ headers: { "content-type": "application/json", "x-forwarded-for": clientIp },
+ body: JSON.stringify(body),
+ },
+ PROXY_PEER,
+ );
+}
+
+describe("POST /api/waitlist", () => {
+ test("records a signup, no auth required", async () => {
+ const { app } = buildTestApp(pg.db, pg.url, { envOverrides: TRUSTED });
+ const res = await post(app, "203.0.113.1", { email: "founder@example.com", source: "pricing" });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: true });
+
+ const rows = await pg.db.waitlistSignup.findMany();
+ expect(rows).toHaveLength(1);
+ expect(rows[0].email).toBe("founder@example.com");
+ expect(rows[0].source).toBe("pricing");
+ });
+
+ test("normalises the address so casing cannot split the unique index", async () => {
+ const { app } = buildTestApp(pg.db, pg.url, { envOverrides: TRUSTED });
+ const res = await post(app, "203.0.113.2", { email: " Mixed.Case@Example.COM", source: "hero" });
+
+ expect(res.status).toBe(200);
+ const rows = await pg.db.waitlistSignup.findMany();
+ expect(rows).toHaveLength(1);
+ expect(rows[0].email).toBe("mixed.case@example.com");
+ });
+
+ test("rejects a malformed email with 400 and writes nothing", async () => {
+ const { app } = buildTestApp(pg.db, pg.url, { envOverrides: TRUSTED });
+ const res = await post(app, "203.0.113.3", { email: "not-an-email", source: "security" });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { ok: boolean; error: string };
+ expect(body.ok).toBe(false);
+ expect(body.error).toBe("BAD_REQUEST");
+ expect(await pg.db.waitlistSignup.count()).toBe(0);
+ });
+
+ test("rejects a body missing source", async () => {
+ const { app } = buildTestApp(pg.db, pg.url, { envOverrides: TRUSTED });
+ const res = await post(app, "203.0.113.4", { email: "someone@example.com" });
+
+ expect(res.status).toBe(400);
+ expect(await pg.db.waitlistSignup.count()).toBe(0);
+ });
+
+ test("a duplicate email answers 200 and is indistinguishable from a first signup", async () => {
+ const { app } = buildTestApp(pg.db, pg.url, { envOverrides: TRUSTED });
+ const first = await post(app, "203.0.113.5", { email: "twice@example.com", source: "hero" });
+ const second = await post(app, "203.0.113.5", { email: "twice@example.com", source: "pricing" });
+
+ expect(first.status).toBe(200);
+ expect(second.status).toBe(200);
+ // Byte-identical bodies: the response must not leak list membership.
+ expect(await second.json()).toEqual(await first.json());
+
+ const rows = await pg.db.waitlistSignup.findMany();
+ expect(rows).toHaveLength(1);
+ // The original capture surface wins — a repeat submit is a no-op, not an update.
+ expect(rows[0].source).toBe("hero");
+ });
+
+ test("does not store the client IP anywhere on the row", async () => {
+ const { app } = buildTestApp(pg.db, pg.url, { envOverrides: TRUSTED });
+ await post(app, "203.0.113.6", { email: "private@example.com", source: "pricing" });
+
+ const row = (await pg.db.waitlistSignup.findFirst())!;
+ expect(JSON.stringify(row)).not.toContain("203.0.113.6");
+ });
+
+ test("rate-limits a single client, leaving other clients unaffected", async () => {
+ const { app } = buildTestApp(pg.db, pg.url, { envOverrides: TRUSTED });
+ let limited = false;
+ for (let i = 0; i < 12 && !limited; i++) {
+ const res = await post(app, "203.0.113.7", { email: `burst${i}@example.com`, source: "hero" });
+ limited = res.status === 429;
+ }
+ expect(limited).toBe(true);
+
+ const other = await post(app, "203.0.113.8", { email: "calm@example.com", source: "hero" });
+ expect(other.status).toBe(200);
+ });
+
+ test("answers the marketing origin's preflight with CORS headers", async () => {
+ const { app } = buildTestApp(pg.db, pg.url, {
+ envOverrides: { ...TRUSTED, CORS_ORIGINS: ["https://antgrid.ai"] },
+ });
+ const res = await app.request("/api/waitlist", {
+ method: "OPTIONS",
+ headers: {
+ origin: "https://antgrid.ai",
+ "access-control-request-method": "POST",
+ "access-control-request-headers": "content-type",
+ },
+ });
+
+ expect(res.headers.get("access-control-allow-origin")).toBe("https://antgrid.ai");
+ expect(res.headers.get("access-control-allow-methods")).toContain("POST");
+ });
+});
diff --git a/web/vite.config.ts b/web/vite.config.ts
index e3cf4f6f..504bfbe9 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -25,6 +25,7 @@ export default defineConfig({
checkout: resolve(import.meta.dirname, "src/ui/entries/checkout.ts"),
dashboard: resolve(import.meta.dirname, "src/ui/entries/dashboard.ts"),
devices: resolve(import.meta.dirname, "src/ui/entries/devices.ts"),
+ waitlist: resolve(import.meta.dirname, "src/ui/entries/waitlist.ts"),
},
output: {
entryFileNames: "[name].[hash].js",
From cd0f6627ba559e2250bffe8de60e13c7115756b4 Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Wed, 2 Sep 2026 10:30:47 +0530
Subject: [PATCH 05/15] Site: point Features at the paid feature, and stop the
title claiming the wrong category
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Nav and footer sent Features to #fleet. Phases.astro (#handler) sits above Fleet.astro, so the one link a reader clicks to find out what the product does opened one section PAST the only thing anyone pays for. It is now #handler; scrolling on from there still reaches the fleet view.
The home title said "remote dev for concurrent coding agents". "Remote dev" names the cloud-workspace category — Codespaces, Gitpod, devcontainers — which is the opposite of what this is: the agents run on hardware the reader already owns. It now says "remote control for Claude Code, Codex and Cursor", which is accurate, names the agents people actually search for, and fits in 58 characters. The description drops "sequences the work and proves it's done" for the hero's own sentence, so the result and the page it opens agree.
404.astro answers every unknown path, so a mistyped inbound link could be indexed under its own URL as a page saying nothing exists. Seo.astro grows a robots prop, set only there; every other page still emits no robots tag at all.
Three contracts, because none of this could fail loudly. Anchor hrefs are excluded from home.spec.ts's dead-link sweep (a fragment never reaches the server), so a renamed section id is the one link on the site that rots silently — all of them are now resolved against the DOM. Features is pinned by target. And the noindex is asserted on /404 together with its absence on /, since a stray default there delists the site.
---
site/src/components/Seo.astro | 7 ++++++
site/src/config.ts | 6 +++++-
site/src/layouts/Base.astro | 5 +++--
site/src/pages/404.astro | 13 ++++++++++--
site/src/pages/index.astro | 18 ++++++++++++++--
site/tests/contracts.spec.ts | 40 +++++++++++++++++++++++++++++++++++
6 files changed, 82 insertions(+), 7 deletions(-)
diff --git a/site/src/components/Seo.astro b/site/src/components/Seo.astro
index 16ad9b0d..7fad1ac1 100644
--- a/site/src/components/Seo.astro
+++ b/site/src/components/Seo.astro
@@ -6,6 +6,11 @@ interface Props {
ogImage?: string;
ogImageAlt?: string;
path?: string;
+ // Only ever set to keep a page OUT of the index (/404). Left undefined the tag
+ // is omitted entirely rather than emitted as "index, follow" — that is already
+ // the default, and a page that states it invites the question of which pages
+ // set it deliberately.
+ robots?: string;
}
// The two defaults describe the same file — a page overriding one must override
// the other, or the card ships someone else's alt text. The filename tracks the
@@ -19,6 +24,7 @@ const {
ogImage = "/og/one-screen.png",
ogImageAlt = "Every agent. Every machine. One screen. Below, antgrid checking an agent's work against test output while you slept.",
path = "/",
+ robots,
} = Astro.props;
const canonical = new URL(path, SITE_URL).href;
const ogUrl = new URL(ogImage, SITE_URL).href;
@@ -27,6 +33,7 @@ const ogUrl = new URL(ogImage, SITE_URL).href;
{title}
+{robots && }
{/* Keep in lockstep with --color-page in styles/global.css. */}
diff --git a/site/src/config.ts b/site/src/config.ts
index af14803e..ef92df90 100644
--- a/site/src/config.ts
+++ b/site/src/config.ts
@@ -18,7 +18,11 @@ export const links = {
// the product entry point; billing sign-in stays on links.signIn.
startFree: "/#download",
pricing: "/pricing",
- features: "/#fleet",
+ // #handler, not #fleet. Phases.astro sits ABOVE Fleet.astro on the home page,
+ // so a "Features" link aimed at the fleet view opened one section PAST the only
+ // feature anyone pays for. Anchor hrefs are excluded from the dead-link sweep in
+ // home.spec.ts, so the id this depends on is pinned in contracts.spec.ts instead.
+ features: "/#handler",
download: "/#download",
getStarted: "/get-started",
downloadMacos: `${RELEASES_URL}/releases/latest/download/antgrid-macos.dmg`,
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index a698f3b4..a65c0439 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -10,8 +10,9 @@ interface Props {
ogImage?: string;
ogImageAlt?: string;
path?: string;
+ robots?: string;
}
-const { title, description, ogImage, ogImageAlt, path } = Astro.props;
+const { title, description, ogImage, ogImageAlt, path, robots } = Astro.props;
---
@@ -25,7 +26,7 @@ const { title, description, ogImage, ogImageAlt, path } = Astro.props;
-
+
diff --git a/site/src/pages/404.astro b/site/src/pages/404.astro
index 642d8e15..30e548d0 100644
--- a/site/src/pages/404.astro
+++ b/site/src/pages/404.astro
@@ -1,10 +1,19 @@
---
import Base from "../layouts/Base.astro";
---
-
+{/* noindex because this file is served for EVERY unknown path, not just /404 —
+ without it a mistyped or expired inbound link puts a page reading "Page not
+ found" into the index under whatever URL was requested. `follow` so the links
+ out of it still pass the crawler back into the real pages. */}
+
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index a61458b1..281aedba 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -10,15 +10,29 @@ import Privacy from "../components/sections/Privacy.astro";
import CrossAgent from "../components/sections/CrossAgent.astro";
import ClosingCta from "../components/sections/ClosingCta.astro";
+// NOT "remote dev": that names the cloud-workspace category (Codespaces, Gitpod,
+// devcontainers) and claims the opposite of what this is — the agents run on
+// hardware you own. "Remote control" is accurate and is the phrase people search
+// when they want a machine of their own driven from somewhere else. Under 60
+// characters so a result page shows it whole rather than cutting the agents off.
+const TITLE = "antgrid — remote control for Claude Code, Codex and Cursor";
+
// Under ~125 characters so a mobile link preview shows it whole — Google's
// ~155 is the looser bound, and this is the page people actually share. It has
// to keep the end-to-end claim through any rewrite: that is the positioning the
// product is sold on, and home.spec.ts asserts it for exactly that reason.
+//
+// It names the agents, because "Claude Code" is what a person types into a
+// search box and "coding agent" is not, and it leads with WHERE they run: the
+// thing a reader is likeliest to get wrong here is assuming the work happens in
+// our cloud. The wedge is carried in the hero's own words rather than
+// paraphrased — a description promising a different sentence than the one the
+// page opens with is why people bounce off a result they chose.
const DESCRIPTION =
- "Claude Code, Codex and Cursor on your own machines. antgrid sequences the work and proves it's done. End-to-end encrypted.";
+ "Claude Code and Codex on your own machines, steered from your phone. Done means proven, not claimed. End-to-end encrypted.";
---
diff --git a/site/tests/contracts.spec.ts b/site/tests/contracts.spec.ts
index a1df7df6..65ec1b37 100644
--- a/site/tests/contracts.spec.ts
+++ b/site/tests/contracts.spec.ts
@@ -119,3 +119,43 @@ test("the social card declares its dimensions so previews reserve the box", asyn
const alt = await page.locator('meta[property="og:image:alt"]').getAttribute("content");
expect(alt, "the card carries no alt text").toBeTruthy();
});
+
+// Every in-page anchor the site links to must exist. home.spec.ts's dead-link
+// sweep skips "/#..." hrefs — it resolves them over HTTP, where the fragment is
+// never sent — so a renamed section id breaks navigation with nothing red. These
+// are the only links on the site that can rot silently.
+test("every in-page anchor the nav and footer offer has a section to land on", async ({ page }) => {
+ await page.goto("/");
+ const fragments = await page.locator("a[href^='/#'], a[href^='#']").evaluateAll((els) =>
+ [...new Set(els.map((e) => (e as HTMLAnchorElement).getAttribute("href")!.split("#")[1]))]
+ );
+ expect(fragments.length, "the home page offers no in-page anchors at all").toBeGreaterThan(0);
+ for (const id of fragments) {
+ await expect(page.locator(`#${id}`), `nothing on the page has id="${id}"`).toHaveCount(1);
+ }
+});
+
+// Features has to open on the paid feature. Handler is the only thing anyone pays
+// for and its section sits ABOVE the fleet view, so aiming this at #fleet scrolled
+// the reader straight past it — a revenue link that resolved fine and pointed at
+// the wrong thing, which is why it is pinned by target here rather than by wording.
+test("Features opens the section that sells Handler", async ({ page }) => {
+ await page.goto("/");
+ const features = page.getByRole("link", { name: /^Features$/ });
+ expect(await features.count(), "no Features link").toBeGreaterThan(0);
+ for (let i = 0; i < (await features.count()); i++) {
+ await expect(features.nth(i)).toHaveAttribute("href", "/#handler");
+ }
+ await expect(page.locator("#handler")).toContainText("Handler");
+});
+
+// The 404 template answers EVERY unknown path, so without this a mistyped inbound
+// link can be indexed under its own URL as a page that says nothing exists.
+test("the not-found page is kept out of the index", async ({ page }) => {
+ await page.goto("/404");
+ await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", /noindex/);
+
+ // Real pages must NOT inherit it — a stray default here delists the whole site.
+ await page.goto("/");
+ await expect(page.locator('meta[name="robots"]')).toHaveCount(0);
+});
From 7ba6541c30b9e375a8a70b31844eea9a11810b09 Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Wed, 2 Sep 2026 10:50:13 +0530
Subject: [PATCH 06/15] Site: reposition the home page from remote control to a
control plane
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remote control is 1:1 and commoditising — every agent vendor is shipping phone access, and no company buys "reach my laptop". A control plane is 1:many: N machines by M agents on one screen, which is the version of this a team recognises as its own problem. Remote development was the other candidate and is worse: it is the Codespaces/Gitpod shelf, judged on workspace provisioning and browser IDEs, and it centres a human at a keyboard, which is what we are positioning away from.
The headline takes the overview claim back from Fleet.astro. It was handed over when the h1 carried the evidence gate, and the gate was never entitled to it: Handler is opt-in and it is on Pro (CAPABILITIES in bridge/src/entitlement.ts), so "Make it prove it" was false on every free machine until someone armed it. The one promise a stranger is asked to believe now holds on a bare install. Fleet keeps the proof, and the ProofCard under the hero still shows the gate working.
The gate drops to the second beat rather than out: it is the only thing here anyone pays for and the only part that is hard to copy, so the hero points at it with a verb — you ARM Handler — and Phases.astro keeps the whole argument. Fleet's lede stops restating the hero and picks up the team scale instead; the footer follows the same order.
The og card needed no re-cut. It already says "Every agent. Every machine. One screen.", which disagreed with the old headline and agrees with this one.
---
site/src/components/Footer.astro | 2 +-
site/src/components/sections/Fleet.astro | 2 +-
site/src/components/sections/Hero.astro | 42 +++++++++++++++---------
site/src/pages/index.astro | 21 +++++++-----
4 files changed, 41 insertions(+), 26 deletions(-)
diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro
index 1a669ef0..dfc5ce15 100644
--- a/site/src/components/Footer.astro
+++ b/site/src/components/Footer.astro
@@ -11,7 +11,7 @@ import Wordmark from "./ui/Wordmark.astro";
- Run your coding agents on your machine, steer them from your phone — and nothing counts as done without evidence.
+ Every agent you run, on every machine you own, in one control plane — and a gate on every phase when you arm Handler.
- Your laptop, your workstation, a cloud box — every agent you're running, grouped by the machine it's on, sorted so the one that's blocked is never the one you have to go looking for.
+ Grouped by the machine it's on, sorted so the one that's blocked is never the one you have to go looking for. On a team, every seat's machines land in this same list.
diff --git a/site/src/components/sections/Hero.astro b/site/src/components/sections/Hero.astro
index f1bd51d5..fc7e47a9 100644
--- a/site/src/components/sections/Hero.astro
+++ b/site/src/components/sections/Hero.astro
@@ -74,15 +74,17 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100);
Claude Code on your laptop, Codex on your workstation, another on a cloud box.{" "}
One's been stuck since 2am.
- {/* The kicker carries scope — every agent, every machine — so the headline
- is free to carry the wedge, and the ProofCard below is its evidence. It
- used to claim the overview ("One screen."), which is a watching claim
- for a product that acts, and left the argument the card was making
- unstated in anything larger than body copy. Fleet.astro still owns the
- overview claim; it is the section that proves it. */}
+ {/* This deliberately takes the overview claim BACK from Fleet.astro, which
+ was handed it while the headline carried the evidence gate. The gate is
+ not entitled to a headline: Handler is opt-in and it is on Pro, so "make
+ it prove it" was false on every free machine until someone armed it —
+ the one promise a stranger is asked to believe has to be true on a bare
+ install. This one is. Fleet.astro keeps the PROOF; the hero takes back
+ the CLAIM, and the ProofCard below still shows the gate doing its job. */}
- Your agent says it's done.{" "}
- Make it prove it.
+ Your machines.{" "}
+ Your agents.{" "}
+ One control plane.
@@ -90,13 +92,23 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100);
rather than pinned to the shell's edges — pushed apart, the buttons read
as stranded rather than as a deliberate second column. */}
- {/* Carries the mechanism behind the headline's claim — the headline asserts
- the gate, this says how it closes. Names "your agents" rather than
- opening on "They": the nearest noun above is now the singular "your
- agent" in the headline, so the pronoun read as a reference to it. */}
-
- Your agents run on your own machines — your repos, your branches, your existing subscriptions. Hand antgrid the sequence in plain text: it reads every result, refuses a "done" that arrives with no test output, exit code or diff, and moves to the next phase.
-
+ {/* Two beats, and their order is the argument. The first is true on a free
+ install with nothing configured, which is what earns it the space next
+ to the headline. The second names the paid feature with a VERB — you arm
+ it — because a reader who takes the gate for a default will find it
+ missing and conclude we lied. It links rather than explains: Phases.astro
+ is two sections down and makes the whole case there. */}
+ {/* One grid child, not two paragraphs: the grid has exactly two columns and
+ a loose second
becomes a third item, which pushes the CTA column out
+ of row one and strands the buttons under the copy. */}
+
+
+ Every agent you're running, on every machine you own — your repos, your branches, your existing subscriptions. Nothing rented from us, nothing to provision.
+
+
+ Arm Handler when you want a phase held until the evidence is there.
+
+
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 281aedba..ee74ff0f 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -10,12 +10,15 @@ import Privacy from "../components/sections/Privacy.astro";
import CrossAgent from "../components/sections/CrossAgent.astro";
import ClosingCta from "../components/sections/ClosingCta.astro";
-// NOT "remote dev": that names the cloud-workspace category (Codespaces, Gitpod,
-// devcontainers) and claims the opposite of what this is — the agents run on
-// hardware you own. "Remote control" is accurate and is the phrase people search
-// when they want a machine of their own driven from somewhere else. Under 60
+// "Control plane", after two rejected shelves. NOT "remote dev" — that is the
+// cloud-workspace category (Codespaces, Gitpod, devcontainers), it is judged on
+// workspace provisioning and browser IDEs, and it centres a human at a keyboard.
+// NOT "remote control" either: that is 1:1, one phone to one laptop, it is a
+// feature every agent vendor is shipping, and nobody buys it for a team. A
+// control plane is 1:many — N machines by M agents on one screen — which is the
+// only one of the three a company recognises as its own problem. Under 60
// characters so a result page shows it whole rather than cutting the agents off.
-const TITLE = "antgrid — remote control for Claude Code, Codex and Cursor";
+const TITLE = "antgrid — control plane for Claude Code, Codex and Cursor";
// Under ~125 characters so a mobile link preview shows it whole — Google's
// ~155 is the looser bound, and this is the page people actually share. It has
@@ -25,11 +28,11 @@ const TITLE = "antgrid — remote control for Claude Code, Codex and Cursor";
// It names the agents, because "Claude Code" is what a person types into a
// search box and "coding agent" is not, and it leads with WHERE they run: the
// thing a reader is likeliest to get wrong here is assuming the work happens in
-// our cloud. The wedge is carried in the hero's own words rather than
-// paraphrased — a description promising a different sentence than the one the
-// page opens with is why people bounce off a result they chose.
+// our cloud. It promises only what an unarmed, unpaid install actually does —
+// the evidence gate is the reason to pay, but it is opt-in and it lives on Pro,
+// so a headline built on it would be false for every free machine on day one.
const DESCRIPTION =
- "Claude Code and Codex on your own machines, steered from your phone. Done means proven, not claimed. End-to-end encrypted.";
+ "Claude Code, Codex and Cursor on every machine you own, in one control plane. Nothing rented from us. End-to-end encrypted.";
---
Date: Wed, 2 Sep 2026 10:50:26 +0530
Subject: [PATCH 07/15] README: answer what a repo visitor asks before they
scroll
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three questions decide whether someone stays: what is this, may I use it, may I contribute. The first was answered in prose and the other two were 100 lines down, so a reader who cared about the licence found out about ELv2 and the closed PR queue only after investing in the page. All three are now above the fold in one NOTE callout.
The tagline follows the site to the control-plane position. The supervisor keeps its place as the differentiator — a README has room for both, unlike a hero — but it is now labelled as the paid tier and cites the gate (CAPABILITIES in bridge/src/entitlement.ts). Someone who builds from source and finds the supervisor inert should learn why from us, not from an issue thread.
The security section gains what it was missing: the limits. No external penetration test, no certification, and the trust boundary is emptied of the relay but not of the account service — app.antgrid.ai serves the device inventory your phone reads a machine's Ed25519 identity from, so it is trusted to hand you the right key even though the relay never is.
A CI badge, since the workflow is public and a green one is cheap evidence for a pre-release repo. antgrid.ai/security is deliberately NOT linked: it 404s until the site deploys, and the in-repo spec and sources it would point at are already linked here.
---
README.md | 58 ++++++++++++++++++++++++++++++++++++++-----------------
1 file changed, 40 insertions(+), 18 deletions(-)
diff --git a/README.md b/README.md
index a20df567..6bf3d643 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,48 @@
# Antgrid
-**Your agent says it's done. Make it prove it.**
+**Your machines. Your agents. One control plane.**
-Evidence-gated supervision for the CLI coding agents you already run — on your own
-hardware, end-to-end encrypted.
+Every CLI coding agent you run, on every machine you own, in one place — end-to-end
+encrypted, on hardware you control.
+[](https://github.com/antgrid-ai/antgrid/actions/workflows/ci.yml)
[](LICENSE.md)
[](https://github.com/antgrid-ai/antgrid/releases/latest)
[](https://github.com/antgrid-ai/antgrid/stargazers)
Antgrid runs the coding agents you already use — Claude Code, Codex, Cursor and others —
-in real terminals on your own hardware. Arm its supervisor on a session and it watches the
-agent's attention signals, answers what it can, escalates what it can't, and calls a task
-done only on concrete evidence — test output, exit codes, a diff — rather than the agent's
-own report.
-
-Around each agent it puts the context you need to check that work yourself: multi-session
-terminals, a file tree, git review with diffs, and a live browser preview. The same
-workspace opens on a phone, over a relay that is end-to-end encrypted and cannot read a
-byte of what passes through it.
+in real terminals on your own hardware, and puts one screen over all of them: every
+session on every machine you have signed in, grouped by the machine it is on. Around each
+agent it puts the context you need to check the work yourself — multi-session terminals, a
+file tree, git review with diffs, and a live browser preview. The same workspace opens on
+a phone, over a relay that is end-to-end encrypted and cannot read a byte of what passes
+through it.
+
+Arm its supervisor on a session and it goes further: it watches the agent's attention
+signals, answers what it can, escalates what it can't, and calls a task done only on
+concrete evidence — test output, exit codes, a diff — rather than the agent's own report.
+That part is opt-in and it is the paid tier — `CAPABILITIES` in
+[`bridge/src/entitlement.ts`](bridge/src/entitlement.ts) is the whole gate. Everything
+above it is free.
Antgrid does not replace your agent and ships no model of its own.
-> Status: pre-release, working towards v1.
+> [!NOTE]
+> **Pre-release, working towards v1.**
+>
+> **Licence** — source-available under [Elastic License 2.0](#licence): free to read,
+> fork, modify and self-host, including commercially. Not OSI open source.
+>
+> **Contributing** — bug reports are welcome; pull requests are not open yet
+> ([CONTRIBUTING.md](CONTRIBUTING.md)).
## Features
-- **Supervisor.** Arm it on a session and it watches the agent's attention signals,
- answers what it can, escalates what it can't, and calls a task done only on concrete
- evidence — test output, exit codes — rather than the agent's own report. You can also
- give it follow-up steps to carry out once the task is done; it works through them in
- order and stays armed until each one is satisfied.
+- **Supervisor** *(paid tier)*. Arm it on a session and it watches the agent's attention
+ signals, answers what it can, escalates what it can't, and calls a task done only on
+ concrete evidence — test output, exit codes — rather than the agent's own report. You
+ can also give it follow-up steps to carry out once the task is done; it works through
+ them in order and stays armed until each one is satisfied.
- **Bring your own agent.** Claude Code, Codex, opencode, Cursor, GitHub Copilot,
Antigravity, Kilo, Kimi and Mistral Vibe are wired for notifications and session naming
— the current set is `AGENTS` in [`bridge/src/agents/registry.ts`](bridge/src/agents/registry.ts).
@@ -83,6 +95,16 @@ feature flag.
Encryption protects the transport. It does not sandbox the agent, and it cannot make an
untrusted agent safe to run on your machine.
+And two things the list above is not. It is not an audit: there has been no external
+penetration test and no certification. And it does not empty the trust boundary — it moves
+the relay out of it, not our account service. Your phone learns a machine's Ed25519
+identity from your account's device inventory, which `app.antgrid.ai` serves, so that
+service is trusted to hand you the right key even though the relay never is.
+
+None of this needs taking on trust. The handshake specification, both implementations and
+the relay itself are linked above and in this repo; [SECURITY.md](SECURITY.md) is the
+reporting policy if you find something wrong with them.
+
## Architecture
| Component | Path | Stack | Role |
From 5fed198bf4d569cd8ddc93ede2f3416e5247e856 Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:06:47 +0530
Subject: [PATCH 08/15] Site: make the hero's light survive a narrow frame
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The glow was a fixed 980x620 box with a closest-side gradient, so its visible
circle was 434px across at every viewport. On a desktop that is roughly a third
of the frame with dark air either side, and the falloff is what makes it read as
a light source. On a phone it is wider than the screen: no falloff lands in
frame, so the light flattens into a brown tint over the kicker with one hard
horizontal terminus and no shape to it.
Below md it stops being an orb and becomes an edge — the gradient's centre sits
on the top edge, so only its lower half is ever visible and the falloff runs down
the one axis a phone has room for. Alpha drops with it, because 0.16 across the
whole of a small frame is a far larger event than 0.16 across a third of a large
one, and it sits behind the lowest-contrast text on the page. Above md the halo
is unchanged in look but sized min(980px, 68vw), which also fixes 768-1024 where
the fixed box overflowed the same way.
The live cells go with it. They are placed in raw px so they land on the 32px
background pitch, which means on a phone only the leftmost column is on screen
and all of it sits behind the copy rather than beside it; a warm block fading in
and out under muted body text reads as a rendering fault. Liveness is already
carried in that viewport by the beta pill and the ProofCard's loop. The one cell
at x=96 moves to the right gutter for the same reason a viewport wider: the
shell is 72rem, so its left margin shrinks with the frame and by 1280 that cell
was sitting on the kicker.
astro check 0/0/0, 86/86 Playwright. Verified at 320, 360, 390, 430, 768 and
1440; the closing CTA's glow is left alone, its card frame contains it.
---
site/src/components/sections/Hero.astro | 10 ++++--
site/src/styles/global.css | 43 ++++++++++++++++++++++++-
2 files changed, 50 insertions(+), 3 deletions(-)
diff --git a/site/src/components/sections/Hero.astro b/site/src/components/sections/Hero.astro
index fc7e47a9..a9881902 100644
--- a/site/src/components/sections/Hero.astro
+++ b/site/src/components/sections/Hero.astro
@@ -19,7 +19,11 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100);
stop around 1568px because past the shell the mask has faded the field out
anyway, and the life belongs where the content is. */}
-
+ {/* Geometry lives with the paint in .glow-hero, not in utilities here: the
+ two change together across the md breakpoint and are meaningless apart. */}
+
diff --git a/site/src/styles/global.css b/site/src/styles/global.css
index c74883be..7de39616 100644
--- a/site/src/styles/global.css
+++ b/site/src/styles/global.css
@@ -136,11 +136,41 @@ body {
/* ---- Atmosphere -------------------------------------------------------- */
-/* Signal radial glow — position with inset utilities on an absolute wrapper. */
+/* Signal radial glow — position with inset utilities on an absolute wrapper.
+ Only safe where a frame contains it (the closing CTA's bordered card); the hero
+ is full-bleed and needs .glow-hero below. */
.glow-signal {
background: radial-gradient(closest-side, rgba(210, 84, 42, 0.16), transparent 70%);
}
+/* The hero's light, which is two different devices because a phone has no room
+ for the desktop one. Wide: a compact halo about a third of the frame across,
+ with dark air either side — the falloff is visible on every axis, which is the
+ only reason it reads as a light source at all. Narrow: that same fixed 980px
+ halo is wider than the viewport, so no falloff lands inside the frame and the
+ light flattens into a brown tint over the headline, ending in one hard
+ horizontal terminus under the kicker.
+ So below md it stops being an orb and becomes an edge: the gradient's centre
+ sits ON the top edge, so there is never a circle to resolve, and the falloff
+ runs down the one axis a phone has room for. Alpha drops with it — 0.16 across
+ the whole of a small frame is a far larger event than 0.16 across a third of a
+ large one, and it is sitting behind the lowest-contrast text on the page. */
+.glow-hero {
+ position: absolute;
+ inset: 0 0 auto 0;
+ height: 340px;
+ background: radial-gradient(ellipse 150% 100% at 50% 0%, rgba(210, 84, 42, 0.11), transparent 72%);
+}
+@media (min-width: 48rem) {
+ .glow-hero {
+ inset: -14rem auto auto 50%;
+ height: 620px;
+ width: min(980px, 68vw);
+ transform: translateX(-50%);
+ background: radial-gradient(closest-side, rgba(210, 84, 42, 0.16), transparent 70%);
+ }
+}
+
/* The namesake, made literal. Ruled lines at one pitch (square cells at any
zoom) plus a scattered handful of filled cells: a rack where most machines are
idle and a few are working, which is the picture the page is selling before a
@@ -167,6 +197,17 @@ body {
.live-cells {
mask-image: radial-gradient(130% 85% at 50% 8%, black 8%, transparent 72%);
}
+/* Not below md. The cells are placed in raw px against a wide canvas, so on a
+ phone only the leftmost column is on screen and every one of those sits behind
+ the copy rather than beside it — a warm block fading in and out under muted
+ body text reads as a rendering fault, not as a machine waking up. The static
+ field stays; liveness is carried in the same viewport by the beta pill and by
+ the ProofCard's own loop, which says it far better than a background can. */
+@media (max-width: 47.999rem) {
+ .live-cells {
+ display: none;
+ }
+}
.live-cells rect {
fill: rgba(210, 84, 42, 0.14);
opacity: 0;
From 368c15711194a5c178cca07ec23970665e00c83b Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:59:20 +0530
Subject: [PATCH 09/15] Waitlist capture: check the answer, scope the bindings,
keep the focus
Success was decided on res.ok alone. Any 2xx from something that is not this endpoint -- a maintenance interstitial, an SSO landing page, a CDN error page served 200 -- hid the form and told the reader they were on the list. No row was written and no retry was possible, because the form was gone. Both clients now require the body's `ok` as well as the status.
web's submit button shipped enabled while the form's action names a JSON-only endpoint, so a page whose script failed to load fired a native urlencoded POST and navigated the reader off /pricing onto a raw error body. It ships disabled now and the script enabling it is what says the handler is attached; a noscript note gives the scriptless reader the email route. The site's card already worked this way.
web's card bound its controls by fixed id -- and its own comment claimed a second copy would bind its own. It would not: duplicate ids, and the second card's label focusing the first card's input. Bindings are per-form data attributes now, ids are per instance, and the status paragraph carries one so aria-describedby can reach it.
Disabling the control a reader just activated blurs it, and focus fell to : their next Tab restarted at the top of the document, permanently on the success path since the button stays disabled there. Focus is reclaimed only when it did in fact land on , so someone who tabbed on keeps their place.
WAITLIST_SOURCE was "pricing" on both surfaces. email is UNIQUE and the insert is ON CONFLICT DO NOTHING, so the app's logged-in card and the public marketing page were indistinguishable in the one column that exists to tell them apart -- and unrecoverable after the fact, on the release whose whole point is measuring founding-price demand. web sends "app_pricing".
The 400 echoed Zod's issues from an anonymous, cross-origin-allowed writer that neither client reads them from -- both pick their wording from the status code. Dropped. The 254 bound now applies to the trimmed value; the outer bound only stops an unbounded string reaching toLowerCase.
Site: WaitlistSource is a closed union, so a surface added with a space or a capital fails astro check instead of 400ing at every reader with copy that blames their email address. The status line reserves two lines -- every message there wraps at the card's mobile width. And cors() gets maxAge, since the fetch default caches a preflight for 5s and every retry paid a second round trip.
---
site/src/components/pricing/PlanCard.astro | 4 +-
site/src/components/pricing/WaitlistCta.astro | 56 +++++--
site/src/data/pricing.ts | 14 +-
web/src/app.ts | 4 +
web/src/routes/waitlist.ts | 16 +-
web/src/ui/entries/waitlist.ts | 156 +++++++++++-------
web/src/ui/pricing.tsx | 55 ++++--
7 files changed, 201 insertions(+), 104 deletions(-)
diff --git a/site/src/components/pricing/PlanCard.astro b/site/src/components/pricing/PlanCard.astro
index 703ad240..acca7422 100644
--- a/site/src/components/pricing/PlanCard.astro
+++ b/site/src/components/pricing/PlanCard.astro
@@ -3,8 +3,8 @@ import { Icon } from "astro-icon/components";
import Button from "../ui/Button.astro";
import WaitlistCta from "./WaitlistCta.astro";
import { links } from "../../config";
-import { type PlanCardData } from "../../data/pricing";
-interface Props { plan: PlanCardData; waitlistSource?: string }
+import { type PlanCardData, type WaitlistSource } from "../../data/pricing";
+interface Props { plan: PlanCardData; waitlistSource?: WaitlistSource }
const { plan, waitlistSource = "pricing" } = Astro.props;
---
diff --git a/site/src/components/pricing/WaitlistCta.astro b/site/src/components/pricing/WaitlistCta.astro
index 03881a92..706dd3d7 100644
--- a/site/src/components/pricing/WaitlistCta.astro
+++ b/site/src/components/pricing/WaitlistCta.astro
@@ -1,9 +1,11 @@
---
import { links } from "../../config";
+import type { WaitlistSource } from "../../data/pricing";
+
interface Props {
/** Which surface the address came from — sent as `source` to the API. */
- source: string;
+ source: WaitlistSource;
/** Unique per instance: two captures can share a page (label/status ids). */
id: string;
}
@@ -44,9 +46,10 @@ const statusId = `${id}-status`;
class="inline-flex shrink-0 items-center justify-center rounded-md bg-signalbtn px-5 py-3 text-note font-medium text-page transition-colors duration-200 hover:bg-signal disabled:cursor-not-allowed disabled:opacity-60 motion-reduce:transition-none"
>Join the list
- {/* Reserves its line so a status message does not shift the card under the
- pointer that just clicked. */}
-
+ {/* Reserves TWO lines, not one: every message here is 60+ characters and wraps
+ at the card's mobile width, so a one-line reservation still grows the card
+ under the thumb that just tapped it. */}
+
);
From 7ea15358237b248f57f51b597001dd3cb897ba97 Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:59:39 +0530
Subject: [PATCH 10/15] Say only what the code does: the claims this release
got wrong
Fleet's "On a team, every seat's machines land in this same list" has no code path. mayRoute is the only routing authorization the relay has and it is same-uid; devices are listed per userId, and each seat signs in as itself. The security page shipped in the same release says the opposite in as many words.
The security page called Handler's headless runs "read-only tools over that working tree". That is the readonly tier. opencode is the transcript tier, and registry.ts says why in as many words -- config-level rather than flag-proven -- with judge.ts compensating by withholding the transcript path. opencode is one of the three agents this same release advertises as Handler-supervised, on a page whose entire argument is that it states every limit. "It never borrows a different vendor's agent" also ignored a user-set judgeTool.
support.md offered three sign-in options. better-auth enables emailAndPassword, the app has a real password step, and the security page added in this same release correctly names four.
The README said CAPABILITIES is the whole gate and everything above it is free. The machine count is a second paywall (FREE_WORKER_LIMIT, the only one enforced on a server), and this same branch replaced that dialog's Upgrade button with a waitlist -- so a second machine is not merely refused, it is currently unpurchasable.
privacy.md never disclosed the waitlist row this release starts writing: no collection entry, no retention period, and a deletion path that requires a registered account a waitlist signer does not have. All three added.
The hero pill quoted a percentage off $99, a price that has never been charged -- the exact invented reference price PlanCard's own comment refuses to print, and for the same stated reason. It quotes both figures forwards now. The FAQ's founding/list framing is gated on OFFER_ACTIVE like every other offer string, so flipping the documented switch no longer leaves the card and the FAQ contradicting each other on one screen.
Compat's prose is derived from the array the chips are built from, so it cannot go on naming three while a fourth chip lights up -- the accent has to stay in lockstep with handlerObservable. home.spec's cross-agent test was asserting the roster at page scope after it moved to Compat.astro, so it stayed green while checking a different section than its name; it is scoped now, and a new test pins the supervised three and the catch-all in #agents.
Docs: relay-requirements' archived banner now covers the offline queue that never shipped and that /privacy denies exists, and DEVELOPMENT records that production CORS_ORIGINS must list the marketing origin -- nothing can catch that mismatch but a reader.
---
DEVELOPMENT.md | 6 ++
README.md | 6 +-
app/lib/screens/upgrade_screen.dart | 9 +--
relay/relay-requirements.md | 5 +-
site/src/components/sections/Compat.astro | 55 ++++++++++++-------
site/src/components/sections/Fleet.astro | 2 +-
site/src/components/sections/Hero.astro | 9 +--
site/src/pages/privacy.md | 4 ++
site/src/pages/security.astro | 4 +-
site/src/pages/support.md | 3 +-
site/tests/home.spec.ts | 26 +++++++--
.../billing/site-pricing-lockstep.test.ts | 5 +-
12 files changed, 91 insertions(+), 43 deletions(-)
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index aaf28b1b..896b2b10 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -506,6 +506,12 @@ The `.env.example` files are documentation and drift.
`GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`,
`CORS_ORIGINS`.
+`CORS_ORIGINS` must list the **marketing site's** origin in staging and
+production, not just the app's: `site/` is a static build on another host and its
+founding-price capture POSTs to `/api/waitlist` here (`WEB_URL` in
+`site/src/config.ts`). Omit it and every submit fails in the browser as a network
+error, with nothing in the web service's logs to say why.
+
**`web/.env` — defaulted, safe to omit:** `NODE_ENV` (`development`),
`EMAIL_FROM`, `PORT` (8787). `BETTER_AUTH_URL` auto-derives to
`http://localhost:${PORT}` in development and test; it is required only in
diff --git a/README.md b/README.md
index 6bf3d643..fc760b2b 100644
--- a/README.md
+++ b/README.md
@@ -22,8 +22,10 @@ Arm its supervisor on a session and it goes further: it watches the agent's atte
signals, answers what it can, escalates what it can't, and calls a task done only on
concrete evidence — test output, exit codes, a diff — rather than the agent's own report.
That part is opt-in and it is the paid tier — `CAPABILITIES` in
-[`bridge/src/entitlement.ts`](bridge/src/entitlement.ts) is the whole gate. Everything
-above it is free.
+[`bridge/src/entitlement.ts`](bridge/src/entitlement.ts) is the whole capability gate. The
+only other paid line is how many machines one account may run agents on
+(`FREE_WORKER_LIMIT` in [`web/src/billing/plans.ts`](web/src/billing/plans.ts)); everything
+else is free.
Antgrid does not replace your agent and ships no model of its own.
diff --git a/app/lib/screens/upgrade_screen.dart b/app/lib/screens/upgrade_screen.dart
index 8cd74e61..e84c1c26 100644
--- a/app/lib/screens/upgrade_screen.dart
+++ b/app/lib/screens/upgrade_screen.dart
@@ -30,10 +30,11 @@ const _proYearlyFeatures = [
/// TEMP-PROMO: why the plan can't be bought, said in the CTA itself. "Coming
/// soon" reads as half-built to someone who arrived from a site that told them
-/// the beta is free. Same wording as web's `UNAVAILABLE_CTA_LABEL` and the
-/// marketing site's `PlanCard.astro`. Carries no beta flag of its own: the
-/// whole static block this belongs to is deleted when checkout opens — see the
-/// TEMP-PROMO marker at the foot of this file.
+/// the beta is free. This is now the only shut CTA of the four: web's pricing
+/// page, the marketing site's plan cards and `device_cap_dialog.dart` all take
+/// a founding-price waitlist signup here instead. Carries no beta flag of its
+/// own: the whole static block this belongs to is deleted when checkout opens —
+/// see the TEMP-PROMO marker at the foot of this file.
const _unavailableCtaLabel = 'Available after beta';
/// The machine count sits mid-sentence, so it has to agree with its noun —
diff --git a/relay/relay-requirements.md b/relay/relay-requirements.md
index c0d4a6fa..f0ee0689 100644
--- a/relay/relay-requirements.md
+++ b/relay/relay-requirements.md
@@ -9,7 +9,10 @@
> `pair-connected` pairing ceremony below. Both are gone: the relay now
> authenticates a single signed `hello` and routing is account-derived
> (`mayRoute`), with no pairing step at all. See `relay/CLAUDE.md` for the
-> current protocol.
+> current protocol. The offline message queue in section 4 never shipped either:
+> a frame for a disconnected peer is answered `PEER_OFFLINE` and dropped, and
+> nothing is buffered or written to disk — which is what `/privacy` on the site
+> states, so do not implement section 4 without changing that page first.
---
diff --git a/site/src/components/sections/Compat.astro b/site/src/components/sections/Compat.astro
index 90d5ae79..e51f2e7f 100644
--- a/site/src/components/sections/Compat.astro
+++ b/site/src/components/sections/Compat.astro
@@ -26,36 +26,49 @@ const agents = [
{ name: "Mistral Vibe", icon: "simple-icons:mistralai" },
];
-// Grouped into two rows rather than left to wrap: the ten chips are ~100px wider
-// than the shell at every desktop width, so a single flex row always broke 9 + 1
-// and stranded the dashed chip alone on the second line. Splitting on the
-// distinction the row already encodes costs no words and fixes the orphan.
-const groups = [agents.filter((a) => a.handler), agents.filter((a) => !a.handler)];
+// Two rows rather than one that wraps: the chips are ~100px wider than the shell
+// at every desktop width, so a single flex row breaks 9 + 1 and strands the
+// dashed chip alone on the second line. Splitting on the distinction the row
+// already encodes costs no words and fixes the orphan.
+const supervised = agents.filter((a) => a.handler);
+const unsupervised = agents.filter((a) => !a.handler);
+
+// Built from the same array the chips are, so the sentence cannot go on naming
+// three while a fourth chip lights up.
+const supervisedNames = supervised.map((a) => a.name);
---
- Handler supervises Claude Code, Codex and opencode today. Every other agent runs as a named session — terminal, files, git and alerts.
+ Handler supervises {supervisedNames.map((name, i) => (
+ <>{i === 0 ? "" : i === supervisedNames.length - 1 ? " and " : ", "}{name}>
+ ))} today. Every other agent runs as a named session — terminal, files, git and alerts.
diff --git a/site/src/components/sections/Fleet.astro b/site/src/components/sections/Fleet.astro
index 9d0ba25f..120afe2a 100644
--- a/site/src/components/sections/Fleet.astro
+++ b/site/src/components/sections/Fleet.astro
@@ -39,7 +39,7 @@ const doneCount = allRows.filter((r) => r.state === "done").length;
that proves it. */}
The one that needs you is never below the fold.
- Grouped by the machine it's on, sorted so the one that's blocked is never the one you have to go looking for. On a team, every seat's machines land in this same list.
+ Grouped by the machine it's on, sorted so the one that's blocked is never the one you have to go looking for.
diff --git a/site/src/components/sections/Hero.astro b/site/src/components/sections/Hero.astro
index a9881902..c521845c 100644
--- a/site/src/components/sections/Hero.astro
+++ b/site/src/components/sections/Hero.astro
@@ -5,8 +5,6 @@ import Button from "../ui/Button.astro";
import ProofCard from "./ProofCard.astro";
import { links } from "../../config";
import { BETA_FREE, OFFER_ACTIVE, YEARLY_OFFER_USD, YEARLY_LIST_USD } from "../../data/pricing";
-
-const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100);
---
{/* The proof loop is the argument, so it gets the stage rather than a column:
full shell width under the headline, which also puts it just below the fold
@@ -63,8 +61,11 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100);
href={links.pricing}
class="inline-flex items-center gap-2 rounded-full border border-signaldeep2 bg-signaldeep/40 px-3 py-1 font-mono text-marker text-signal2 transition-colors hover:border-signalbtn hover:text-signal3"
>
- {offerPct}% off
- Launch offer — Pro ${YEARLY_OFFER_USD} per seat / year
+ {/* Forwards, never as a discount off a struck price — same rule
+ PlanCard.astro states and for the same reason: $99 has never been
+ charged, so "% off" would invent a reference price. */}
+ Founding
+ Pro ${YEARLY_OFFER_USD} per seat / year — ${YEARLY_LIST_USD} at launch
)}
diff --git a/site/src/pages/privacy.md b/site/src/pages/privacy.md
index c04ad71c..87521c43 100644
--- a/site/src/pages/privacy.md
+++ b/site/src/pages/privacy.md
@@ -32,6 +32,7 @@ Radha AI Products is the legal entity behind Antgrid. **Questions or concerns?**
- **Account information** — name and email address (provided directly or via a third-party sign-in provider).
- **Billing status** — your plan, subscription state, trial status, and transaction identifiers returned by our payment processors or app stores. **We do not collect or store full payment card numbers; these are handled entirely by our payment processors and the app stores.**
+- **Waitlist email address** — if you join the founding-pricing waitlist from our website or pricing page, we store the email address you submit and the page you submitted it from, so we can tell you when pricing opens. This does not create an account, and the submitting IP address is used only for rate limiting and is never stored on the record.
### Information collected automatically
@@ -107,6 +108,7 @@ We keep personal information only for as long as necessary for the purposes set
- **Session IP address and user-agent** — for the lifetime of the session; expired and deleted sessions are removed.
- **Cross-device sign-in records** — automatically expire within approximately 10 minutes.
- **Operational logs** (which may contain IP addresses) — retained for up to 30 days, then deleted.
+- **Waitlist email address** — until founding pricing opens and we have contacted you, or until you ask us to remove it, whichever comes first.
When we no longer have a legitimate need to process your information, we delete or anonymize it, or securely isolate it where deletion is not immediately possible (for example, in backups).
@@ -122,6 +124,8 @@ Depending on your location, you may have the right to access, correct, update, o
You can request deletion of your account and associated personal data at any time by emailing [contact@radhaai.com](mailto:contact@radhaai.com) with the subject "Account Deletion Request" from your registered email address. Upon verification, we will delete or anonymize your account data, except where retention is required by law (for example, tax and transaction records). We will action verified deletion requests within 30 days.
+To be removed from the founding-pricing waitlist, email us from the address you submitted with the subject "Waitlist Removal" — no account is needed, and we delete the record on verification.
+
If you purchased through the Apple App Store or Google Play, manage or cancel any active subscription through your store account before requesting deletion, as those subscriptions are managed by the store (see our [Cancellation & Refund Policy](/refunds)).
## 11. India (DPDP Act, 2023) — Grievance Redressal
diff --git a/site/src/pages/security.astro b/site/src/pages/security.astro
index 5e3848d0..37d23c48 100644
--- a/site/src/pages/security.astro
+++ b/site/src/pages/security.astro
@@ -303,10 +303,10 @@ const sources = [
Handler runs your agent again
- When you arm Handler on a session, it runs the agent you already picked for that session headlessly, with read-only tools over that working tree. So working-tree content and transcript excerpts reach the vendor you already chose, on your own account, without a fresh action from you for each call.
+ When you arm Handler on a session, it runs the agent you already picked for that session headlessly and restricted over that working tree — with a proven read-only tool set where the agent offers one, and with the agent's own restricted mode, and no transcript handed to it, where that restriction is configuration rather than a flag we can verify. So working-tree content and transcript excerpts reach the vendor you already chose, on your own account, without a fresh action from you for each call.
- It never borrows a different vendor's agent to supervise a session, and those runs are kept out of your own session history.
+ It never borrows a different vendor's agent to supervise a session unless you pick one yourself, and those runs are kept out of your own session history.