Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
33b263d
Expose the shared local GEO workflow through MCP
erickkkyt Oct 22, 2026
c493fa9
Keep long browser collections connected through durable progress polling
erickkkyt Oct 22, 2026
1b58829
Record verified BeatAPI browser and shared workspace acceptance
erickkkyt Oct 22, 2026
90b818b
Show the recorded BeatAPI workflow and prepare reusable video material
erickkkyt Oct 22, 2026
8f9d930
Stop trend lines from inventing days before the first collection
erickkkyt Oct 22, 2026
1b159aa
Show small samples as an early baseline instead of a red score
erickkkyt Oct 22, 2026
adf77ce
Group Actions & retests into collection, monitoring and saved actions
erickkkyt Oct 22, 2026
b43ce06
Add a 41-second README demo built from the recorded BeatAPI case
erickkkyt Oct 22, 2026
86443b5
Move the dashboard to a near-black palette with BeatAPI's code texture
erickkkyt Oct 22, 2026
828304e
Show common date ranges as tabs and give Overview a page title
erickkkyt Oct 22, 2026
b68fc55
Pair each overview trend with a ranking or tracking panel
erickkkyt Oct 22, 2026
72f578c
Show citation categories as one bar and top domains as a ranked table
erickkkyt Oct 22, 2026
f4c65ea
Replace the README demo with a narrated 44-second cut
erickkkyt Oct 22, 2026
dc319c5
Hide citation trends until citations exist on two days
erickkkyt Oct 22, 2026
9ceb65a
Translate the remaining dashboard labels and share one page-title style
erickkkyt Oct 22, 2026
77c8c67
Merge remote-tracking branch 'origin/main' into codex/agent-mcp-workflow
erickkkyt Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/calm-agents-monitor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workspace/web": patch
"@workspace/lib": patch
---

Operate local website onboarding, one-shot browser collection, shared drafts and monitoring through MCP with durable operation history.
5 changes: 5 additions & 0 deletions .changeset/honest-early-dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workspace/web": patch
---

Show early results as a neutral baseline, stop trends from drawing days before the first collection, and reorganise Actions & retests.
5 changes: 5 additions & 0 deletions .changeset/near-black-workspace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workspace/web": patch
---

Restyle the dashboard with a near-black analytics palette, inline date-range tabs, larger page titles and BeatAPI's faint code texture.
5 changes: 5 additions & 0 deletions .changeset/real-workflow-case.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workspace/web": patch
---

Show a bilingual BeatAPI workflow case with verified results, a downloadable summary and recording material.
4 changes: 4 additions & 0 deletions README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ Ein quelloffener GEO-/AEO-Arbeitsbereich von [BeatAPI](https://beatapi.io). Verf

[Website](https://beatapi.io/geo) · [Schnellstart](#schnellstart) · [Ablauf](#ablauf) · [Funktionsstand](#funktionsstand) · [Roadmap](docs/roadmap.md) · [MIT-Lizenz](LICENSE.md)

[![44-Sekunden-Demo von BeatGEO ansehen](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4)

Echte Bildschirme aus einem aufgezeichneten BeatAPI-Test vom 2026-10-22, als Rückblick und nicht als Live-Lauf. [Fallnotizen auf Chinesisch](docs/beat-geo/cases/beatapi-agent/README.md)

> **Lokales MVP / Quellcode-Vorschau.** Die Kette ChatGPT-Erfassung → Datenbank → Worker wurde in Live-Smoke-Tests geprüft. Andere Engines, kostenpflichtige Recherche und unbeaufsichtigter Dauerbetrieb haben eigene Prüfgrenzen. [Prüfprotokoll auf Englisch](docs/beat-geo/validation.md)

## Von der Website zu wiederholbaren Fragen
Expand Down
4 changes: 4 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

[公式サイト](https://beatapi.io/geo) · [クイックスタート](#クイックスタート) · [ワークフロー](#ワークフロー) · [対応状況](#対応状況) · [ロードマップ](docs/roadmap.md) · [MIT ライセンス](LICENSE.md)

[![44秒の BeatGEO デモを見る](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4)

2026-10-22 に記録した BeatAPI 実測の実画面をまとめた振り返りで、ライブ実行ではありません。[ケース記録(中国語)](docs/beat-geo/cases/beatapi-agent/README.md)

> **ローカル MVP / ソースプレビュー版。** ChatGPT の収集 → データベース → worker は実環境のスモークテストを通過しています。他のエンジン、有料リサーチ、長期間の無人運用は別途検証が必要です。[検証記録(英語)](docs/beat-geo/validation.md)

## Web サイトから、継続して測定できる質問へ
Expand Down
4 changes: 4 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

[공식 웹사이트](https://beatapi.io/geo) · [빠른 시작](#빠른-시작) · [제품 흐름](#제품-흐름) · [지원 현황](#지원-현황) · [로드맵](docs/roadmap.md) · [MIT 라이선스](LICENSE.md)

[![44초 BeatGEO 데모 보기](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4)

2026-10-22에 기록한 BeatAPI 실측의 실제 화면을 정리한 회고이며, 실시간 실행이 아닙니다. [사례 기록 — 중국어](docs/beat-geo/cases/beatapi-agent/README.md)

> **로컬 MVP / 소스 미리보기 버전.** ChatGPT 수집 → 데이터베이스 → worker 경로는 실제 스모크 테스트를 통과했습니다. 다른 엔진, 유료 리서치, 장기 무인 운영은 별도로 검증해야 합니다. [검증 기록 — 영어](docs/beat-geo/validation.md)

## 웹사이트 하나에서 반복 가능한 질문 세트로
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ An open-source GEO / AEO workspace by [BeatAPI](https://beatapi.io). Track brand

[Website](https://beatapi.io/geo) · [Quick start](#quick-start) · [Product workflow](#the-product-workflow) · [Current support](#current-support) · [Roadmap](docs/roadmap.md) · [MIT license](LICENSE.md)

[![Watch the 44-second BeatGEO demo](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4)

Real screens from a recorded BeatAPI test on 2026-10-22, replayed as a summary rather than a live run. [Case notes](docs/beat-geo/cases/beatapi-agent/README.md)

> **Local MVP / source preview.** The ChatGPT collection → database → worker path has passed live smoke tests. Other engines, paid research and unattended operation have separate acceptance boundaries. [Validation log](docs/beat-geo/validation.md)

## From a website to a repeatable question set
Expand Down Expand Up @@ -45,6 +49,10 @@ The installer prepares a dedicated local PostgreSQL database and the included ex

[Detailed onboarding — Chinese](docs/beat-geo/getting-started.md) · [Manual PostgreSQL setup — Chinese](docs/self-hosting.md)

## Work from your Agent

Give your MCP-capable Agent a website. The local MCP can create a paused brand, save reviewed questions, queue bounded BeatBrowser collection, inspect evidence, edit shared content drafts and control monitoring. The manual dashboard shows the same project and operation history. [Agent workflow and connection guide](docs/beat-geo/agent-workflow.md).

## The product workflow

![The product workflow](assets/readme/workflow.en.svg)
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

[项目官网](https://beatapi.io/geo) · [快速开始](#快速开始) · [产品闭环](#产品闭环) · [支持范围](#支持范围) · [路线图](docs/roadmap.md) · [MIT 许可](LICENSE.md)

[![观看 44 秒 BeatGEO 演示](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4)

画面来自 2026-10-22 的 BeatAPI 实测记录,是结果回顾,不是实时运行。[案例说明](docs/beat-geo/cases/beatapi-agent/README.md)

> **本地 MVP / 源码预览版。** ChatGPT 采集 → 数据库 → worker 已通过真实冒烟测试。其他平台、付费研究与长期无人值守分别验收。[验收记录](docs/beat-geo/validation.md)

## 从一个网站,开始持续观察
Expand Down
82 changes: 82 additions & 0 deletions apps/browser-lab/async-collection.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { setTimeout as delay } from "node:timers/promises";

test("async collection acknowledges durable evidence and can be polled without another submission", {
timeout: 20000,
}, async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "beatgeo-async-"));
const fake = path.join(directory, "fake.mjs");
await writeFile(
fake,
`
import {existsSync,writeFileSync} from 'node:fs';
const tool=process.argv[3], p=JSON.parse(process.argv[4]), sent=${JSON.stringify(path.join(directory, "sent"))};
if(tool==='spaces') console.log(JSON.stringify({spaceId:'fixture-space',tabId:42,state:'running'}));
else if(tool==='snapshot') console.log('# ChatGPT');
else if(tool==='type') {if(existsSync(sent))throw Error('Duplicate submission');writeFileSync(sent,'1');console.log('Typed');}
else if(p.selector.includes('contenteditable')) console.log('[{"label":"Ask"}]');
else if(p.selector.includes('aria-busy')||p.selector.includes('a[href]'))console.log('[]');
else console.log(existsSync(sent)?'[{"text":"A completed fixture answer."}]':'[]');
`,
);
const probe = net.createServer().listen(0, "127.0.0.1");
await once(probe, "listening");
const port = probe.address().port;
await new Promise((resolve) => probe.close(resolve));
const child = spawn(process.execPath, ["apps/browser-lab/server.mjs"], {
env: {
...process.env,
BROWSER_LAB_PORT: String(port),
BROWSER_RUNNER_TOKEN: "fixture-token",
BROWSER_RUNS_DIR: path.join(directory, "runs"),
BEAT_BROWSER_COMMAND: process.execPath,
BEAT_BROWSER_ARGS_JSON: JSON.stringify([fake]),
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
await Promise.race([
once(child.stdout, "data"),
once(child, "exit").then(() => {
throw Error("Runner exited");
}),
]);
const base = `http://127.0.0.1:${port}/api/collect`;
const submit = () =>
fetch(base, {
method: "POST",
headers: { Authorization: "Bearer fixture-token", Prefer: "respond-async", "Content-Type": "application/json" },
body: JSON.stringify({ engine: "chatgpt", prompt: "Which providers?" }),
});
const response = await submit();
assert.equal(response.status, 202);
const { runId } = await response.json();
assert.equal(JSON.parse(await readFile(path.join(directory, "runs", `${runId}.json`), "utf8")).id, runId);
assert.equal((await fetch(`${base}/${runId}`)).status, 403);
assert.equal((await submit()).status, 409);
let run;
do {
await delay(500);
const progress = await fetch(`${base}/${runId}`, { headers: { Authorization: "Bearer fixture-token" } });
assert.equal(progress.status, 200);
run = await progress.json();
} while (["opening", "submitting", "waiting"].includes(run.status));
assert.equal(run.status, "completed");
assert.equal(run.answer, "A completed fixture answer.");
assert.equal(await readFile(path.join(directory, "sent"), "utf8"), "1");
} finally {
if (child.exitCode === null) {
const stopped = once(child, "exit");
child.kill("SIGTERM");
await stopped;
}
await rm(directory, { recursive: true, force: true });
}
});
24 changes: 23 additions & 1 deletion apps/browser-lab/core.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -47,6 +48,12 @@ export function createBrowser(env = process.env) {
throw new Error("BEAT_BROWSER_ARGS_JSON must be a JSON array of command arguments.");
}
return async (tool, params) => {
if (useBundle)
assertCompatibleBridge(
JSON.parse(
readFileSync(new URL("../../node_modules/@beatapi/beat-browser/package.json", import.meta.url), "utf8"),
).version,
);
const { stdout } = await execute(command, [...prefix, "call", tool, JSON.stringify(params)], {
timeout: 45_000,
maxBuffer: 4 * 1024 * 1024,
Expand All @@ -56,6 +63,19 @@ export function createBrowser(env = process.env) {
};
}

export function assertCompatibleBridge(expected, metadataPath = path.join(os.homedir(), ".beat-browser/bridge.json")) {
let info;
try {
info = JSON.parse(readFileSync(metadataPath, "utf8"));
} catch {
return;
}
if (info.version && info.version !== expected)
throw new Error(
`[BROWSER_VERSION_MISMATCH] Existing BeatBrowser bridge ${info.version} differs from bundled ${expected}. Reuse the matching installed CLI with BEAT_BROWSER_COMMAND and BEAT_BROWSER_ARGS_JSON, or update the connected extension and Agent clients together. The shared bridge was not restarted.`,
);
}

export function validateInput(input) {
if (!input || typeof input !== "object") throw new Error("Collection input is required.");
if (!Object.hasOwn(ENGINES, input.engine)) throw new Error("Choose chatgpt or gemini.");
Expand Down Expand Up @@ -192,6 +212,8 @@ async function openRunPage(config, run, transport, useSpaces) {
action: "open",
url: ENGINES[config.engine].url,
label: `BeatGEO · ${ENGINES[config.engine].name}`,
// Keep control through composer attachment, answer waiting and evidence reads.
leaseMs: Math.min(3_600_000, run.timeoutMs + 90_000),
profileId: config.profileId || process.env.BEAT_BROWSER_PROFILE_ID || undefined,
}),
);
Expand Down
19 changes: 18 additions & 1 deletion apps/browser-lab/core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,13 @@ test("an interrupted TaskSpace never falls back to an ordinary browser tab", asy
assert(calls.every((call) => call.tool === "spaces"));
});

test("default collection uses the chosen profile and preserves evidence pages", async () => {
test("collection retains the chosen profile through long answer waits and preserves evidence pages", async () => {
const calls = [];
let submitted = false;
const run = await collect(
{ engine: "chatgpt", prompt: "best providers", brand: "Example", profileId: "chosen-profile" },
{
timeoutMs: 1_800_000,
delay: async () => {},
browser: async (tool, params) => {
calls.push({ tool, params });
Expand All @@ -188,6 +189,7 @@ test("default collection uses the chosen profile and preserves evidence pages",
assert.equal(run.status, "completed");
assert.equal(run.brandMentioned, true);
assert.equal(calls[0].params.profileId, "chosen-profile");
assert(calls[0].params.leaseMs > run.timeoutMs + 60_000);
assert.equal(
calls.some((call) => call.tool === "tabs"),
false,
Expand Down Expand Up @@ -250,3 +252,18 @@ for (const readiness of ["transient", "persistent", "human-pause"]) {
if (readiness === "human-pause") assert.equal(run.errorCode, "needs_attention");
});
}

test("a different shared bridge is left untouched and returns an actionable compatibility error", async () => {
const { writeFile } = await import("node:fs/promises");
const { assertCompatibleBridge } = await import("./core.mjs");
const root = await mkdtemp(path.join(os.tmpdir(), "beatgeo-bridge-"));
const file = path.join(root, "bridge.json");
try {
await writeFile(file, JSON.stringify({ version: "1.3.2", pid: 123, token: "private-fixture" }));
assert.throws(() => assertCompatibleBridge("1.4.1", file), /shared bridge was not restarted/);
assert.doesNotThrow(() => assertCompatibleBridge("1.3.2", file));
assert.doesNotThrow(() => assertCompatibleBridge("1.4.1", path.join(root, "absent.json")));
} finally {
await rm(root, { recursive: true, force: true });
}
});
28 changes: 24 additions & 4 deletions apps/browser-lab/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ function allowedRequest(request) {
return true;
}

async function executeRun(input) {
async function executeRun(input, started) {
active = { status: "opening" };
try {
const result = await collect(input, {
publish: async (run) => {
active = { ...run };
await saveRun(directory, run);
started?.(run.id);
},
});
await saveRun(directory, result);
Expand Down Expand Up @@ -131,15 +132,25 @@ async function selectProfile(request, response) {
async function handle(request, response) {
if (!allowedRequest(request)) return json(response, 403, { error: "Loopback same-origin requests only." });
const url = new URL(request.url, `http://127.0.0.1:${port}`);
if (request.method === "GET") return handleRead(url, response);
if (request.method === "GET") return handleRead(url, request, response);
if (request.method === "POST" && url.pathname === "/api/browser/profile") return selectProfile(request, response);
if (request.method !== "POST" || !["/api/run", "/api/collect"].includes(url.pathname))
return json(response, 404, { error: "Not found" });
return handleCollection(request, response, url);
}

async function handleRead(url, response) {
async function handleRead(url, request, response) {
if (await staticAsset(url, response)) return;
const collection = url.pathname.match(/^\/api\/collect\/([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})$/i);
if (collection) {
if (!authorized(request)) return json(response, 403, { error: "Invalid local runner token" });
try {
return json(response, 200, JSON.parse(await readFile(path.join(directory, `${collection[1]}.json`), "utf8")));
} catch (error) {
if (error.code !== "ENOENT") throw error;
return json(response, 404, { error: "Collection not found" });
}
}
if (url.pathname === "/") {
response.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
Expand All @@ -166,7 +177,16 @@ async function handleCollection(request, response, url) {
if (active) return json(response, 409, { error: "A browser run is already active. Wait before submitting another." });
const input = validateInput(await bodyOf(request));
if (active) return json(response, 409, { error: "A browser run started while this request was being read." });
const operation = executeRun(input);
const started = Promise.withResolvers();
const operation = executeRun(input, started.resolve);
if (url.pathname === "/api/collect" && request.headers.prefer === "respond-async") {
operation.catch((error) => {
started.reject(error);
console.error("Run failed:", error.message);
});
// A receipt is returned only after its initial private run record is durable.
return json(response, 202, { accepted: true, runId: await started.promise });
}
if (url.pathname === "/api/collect") return json(response, 200, await operation);
operation.catch((error) => console.error("Run failed:", error.message));
return json(response, 202, { accepted: true });
Expand Down
34 changes: 0 additions & 34 deletions apps/web/public/code-texture.svg

This file was deleted.

2 changes: 2 additions & 0 deletions apps/web/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SidebarInset, SidebarProvider } from "@workspace/ui/components/sidebar";
import type { CSSProperties, ReactNode } from "react";
import { CodeTexture } from "@/components/code-texture";

export function AppShell({
sidebar,
Expand All @@ -15,6 +16,7 @@ export function AppShell({
{sidebar}
{/* Clip horizontal overflow without changing the sticky header's scroll parent. */}
<SidebarInset className="beat-workspace overflow-clip">
<CodeTexture />
{header}
{children}
</SidebarInset>
Expand Down
Loading
Loading