Self-hosted research agent built on ActiveGraph (event-sourced agent runtime). One agent, one mission: grow activegraph.ai's evidence base. One seed branch: read_the_website. The agent crawls the site, infers project claims, finds gaps, proposes branches, dispatches work, drafts artifacts, and requests approval before anything publishes or self-modifies. The UI is a notebook feed; each branch is a thread you can talk inside.
Built as a layered pack on activegraph-packs (consumed as a pinned git dependency — ADR-005). Governing docs: start at CLAUDE.md and docs/CONTRACT.md.
lab_pack/ the lab as one pack: mission/branch/decision + eight behaviors
bundle.py build_lab() — composes upstream packs + lab_pack, seeds branch zero
fixtures/ deterministic scenarios, no API key, no network
server/ thin HTTP server + the public blog (server/blog.py, SSR)
ui/ the notebook feed (vanilla JS, served by the server at /lab)
docs/ CONTRACT, ARCHITECTURE, INTERFACE, DECISIONS, ROADMAP
data/ SQLite event log + memory store (created at first run)
| Route | What |
|---|---|
/ |
the public blog — published posts, server-rendered from the graph, newest first |
/posts/<slug> |
one post + "Show the work": branch, evidence, chat, the publish decision, prior drafts |
/feed.xml |
RSS 2.0, published posts only |
/lab |
the open workshop: every branch (incl. proposed/decided/archived), every chat, every decision, filter row, deep links via #branch=<id> |
/healthz |
backend, event count, pending decisions, paused, LLM calls/cost vs caps |
POST /mcp |
MCP server, streamable HTTP (ADR-016/021/026) — read tools, send_chat, reversible controls (budget, pause/resume), decision annotations; never approve/reject or seam promotion |
/.well-known/oauth-*, /register, /authorize, /token |
OAuth 2.1 + DCR for the MCP surface (ADR-017), stateless |
/mcp accepts three equivalent credentials, all rooted in LAB_MCP_TOKEN (a separate secret from the operator token, revocable independently): an OAuth 2.1 bearer token, the legacy Authorization: Bearer $LAB_MCP_TOKEN header, or the legacy /mcp/<LAB_MCP_TOKEN> path token. Identical authority in all three — read tools, send_chat, the reversible operator controls (set_budget, pause_lab/resume_lab — ADR-021), and annotate_decision (public pre-review commentary on a pending decision — ADR-026); approving/rejecting decisions and seam promotion are excluded by design (the inbox is the one place only the human operator exists).
The OAuth server is stateless: client ids, codes, and access/refresh tokens are HMAC-signed payloads keyed from LAB_MCP_TOKEN, verified by recomputation — nothing is stored, so rotating LAB_MCP_TOKEN revokes everything at once.
Connect claude.ai (web/desktop — Settings → Connectors → Add custom connector):
- Enter the plain MCP URL:
https://<your-host>/mcp— no token in it. - Click Connect. claude.ai discovers the OAuth metadata, registers itself, and opens the lab's
/authorizepage. - Paste
LAB_MCP_TOKENinto the password field once. You're redirected back and the connector is live (24h access tokens, refreshed automatically for 30 days).
Verification (zsh, single lines — replace $URL):
- Metadata:
curl -s $URL/.well-known/oauth-authorization-server | python3 -m json.tool - Resource metadata:
curl -s $URL/.well-known/oauth-protected-resource/mcp | python3 -m json.tool - Discovery challenge:
curl -s -i -X POST $URL/mcp -d '{}' | grep -i www-authenticate→Bearer resource_metadata=... - DCR (deterministic client_id):
curl -s -X POST $URL/register -H 'Content-Type: application/json' -d '{"redirect_uris":["https://claude.ai/api/mcp/auth_callback"]}' - Bad grant is a fixed body:
curl -s -X POST $URL/token -d 'grant_type=authorization_code&code=x&code_verifier=y&client_id=z&redirect_uri=w'→{"error": "invalid_grant"}
All mutations need Authorization: Bearer $LAB_OPERATOR_TOKEN.
POST /lab/pause/POST /lab/resume— global pause, persisted aslab.paused/lab.resumedevents and rebuilt from the log at boot. While paused every LLM behavior exceptansweridles (one behavior-skipped observation per behavior per episode); the operator can always talk to the lab.- Daily cost ceiling:
setting.daily_cost_cap_usd(default $5.00, seam-eligible) over activegraph's nativecost_usdaccounting onllm.respondedevents — restart-proof, like the daily call cap. - Editorial policy thresholds (
digest_min_findings,research_min_evidence,max_drafts_pending) are seam settings: tuning them is self-modification through the gate (ADR-014). - The UI shows
live|paused · $today/$capin the/labheader and the blog footer; the pause toggle appears in operator mode only.
Install:
pip install -e .Run the fixture suite (no API key, no network):
python lab_pack/fixtures/run_fixtures.pyStart the lab (mock LLM unless OPENAI_API_KEY / ANTHROPIC_API_KEY is set):
python server/lab_server.pyOpen the blog (front door) and the notebook (open workshop):
open http://localhost:7799/ http://localhost:7799/labLocal-dev override for the packs dependency (instead of the pinned SHA):
pip install -e ../activegraph-packsThe upstream Inspector remains the debugging view — point it at the lab runtime (the lab serves the Inspector's /graph, /trace, /summary, /packs endpoints):
cd ../activegraph-packs && ACTIVEGRAPH_PORT=7799 pnpm dev| Env var | Default | Meaning |
|---|---|---|
LAB_PORT |
7799 |
server port |
LAB_DATABASE_URL |
unset | Postgres event store; wins over DATABASE_URL (ADR-009 note). In production this is the Replit-managed Neon cluster's writable primary endpoint |
DATABASE_URL |
unset | fallback Postgres URL (Replit reserves this name — see replit.md) |
ACTIVEGRAPH_DB |
data/lab.sqlite |
SQLite event-log store when no Postgres URL is set (resumed on restart) |
ACTIVEGRAPH_MEMORY_DB |
data/lab_memory.sqlite |
memory_gateway backend |
LAB_LLM_PROVIDER |
auto | openai / anthropic / mock |
LAB_LLM_MODEL |
provider default | model override |
- Push this repo to GitHub:
git push origin main - Import to Replit: create a Repl from the GitHub repo (the
.replitrun command is checked in) - Add the Postgres integration (
postgresql-16module — it owns the Replit-managed Neon cluster that is the production database; do NOT remove it) - Supply the cluster's writable primary endpoint URL as the
LAB_DATABASE_URLsecret — Replit reserves the nameDATABASE_URLat publish time, so the lab readsLAB_DATABASE_URLfirst and falls back toDATABASE_URL(ADR-009 note) - Generate the operator token:
python -c "import secrets; print(secrets.token_urlsafe(32))" - Set Secrets:
ANTHROPIC_API_KEY,LAB_OPERATOR_TOKEN(from above),LAB_ENV=prod— leaveLAB_ALLOW_GRAPH_CODEunset (ADR-012) - Run. First boot seeds the mission (
mode=fresh); redeploys resume (mode=resumed).
Verification (replace $URL and $TOK):
- Public read OK:
curl -s $URL/lab/feed | head -c 200 - Backend is Postgres:
curl -s $URL/healthz | grep -o '"backend": "[a-z]*"' - Tokenless mutation refused:
curl -s -o /dev/null -w "%{http_code}\n" -X POST $URL/chat -d '{}'→401 - Tokened mutation OK:
curl -s -X POST $URL/chat -H "Authorization: Bearer $TOK" -d '{"branch_id":"branch#2","content":"hello"}' | head -c 200 - Restart the Repl, then:
curl -s $URL/healthz→event_count≥ before, boot log showsmode=resumed - Postgres round-trip (scratch DB):
LAB_DATABASE_URL=... python scripts/test_postgres.py
- Fixtures:
python lab_pack/fixtures/run_fixtures.py - Smoke (regression bar):
python scripts/smoke.py - Auth:
python scripts/test_auth.py - MCP surface:
python scripts/test_mcp.py - OAuth 2.1 + DCR (ADR-017):
python scripts/test_oauth.py - Public-safety sentinel audit:
python scripts/test_public_safety.py - Chat-path robustness (ADR-023; add
LAB_TEST_PG_URL=...scratch...for the real leaf):python scripts/test_chat_robustness.py - Boot readiness (ADR-024):
python scripts/test_readiness.py - UI render (jsdom, static fallback):
python scripts/check_ui.py