ResearchOS is structured around two planes that share a single source of truth:
Knowledge plane — the system of record for all domain objects: Papers, Websites, GitHub Repos, Collections, Libraries, Projects, Experiments, Tasks, Notes, Authors. Backed by Supabase (PostgreSQL for structured data, Storage for PDFs).
Execution plane — the agent and workflow runtime. Agents execute multi-step, stateful research workflows using tools that read from and write back to the knowledge plane with full provenance (run records, proposals, activity feed).
The closed loop is the core value proposition: every workflow run improves the library (curated collections, tags, notes), and the improved library provides better context for future workflows.
| Layer | Technology | Port | Notes |
|---|---|---|---|
| Frontend | React 18 + Vite | 5173 | SPA; all routing client-side via React Router v6 |
| Backend | FastAPI + uvicorn | 8000 | REST API; agent tasks run server-side |
| Proxy | Vite dev proxy | — | /api/* forwarded to localhost:8000 in dev |
| Database | Supabase (PostgreSQL) | — | Accessed via supabase-py client |
| Storage | Supabase Storage | — | pdfs bucket for uploaded/fetched PDFs |
backend/
├── app.py # FastAPI entry, CORS, router registration, startup seeding
├── agents/ # pydantic-ai workflow agents + shared infra
│ ├── base.py # RunLogger, search_arxiv, emit_activity
│ ├── llm.py # Model role config, get_pydantic_ai_model, OpenAI clients
│ ├── prompts.py # All system prompt constants
│ ├── literature_reviewer.py
│ ├── model_researcher.py
│ ├── experiment_designer.py
│ └── gap_analyzer.py
├── models/ # Pydantic domain models
├── services/ # All database access and business logic
├── routers/ # FastAPI route handlers (thin; delegate to services)
├── migrations/ # SQL migration files (run in Supabase SQL editor)
└── tests/ # pytest test suite
All domain models inherit from backend/models/base.py::CamelModel:
class CamelModel(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)This applies to_camel aliasing so that:
- Python fields are
snake_case:arxiv_id,library_id,created_at - JSON serialization via
model.model_dump(by_alias=True)producescamelCase:arxivId,libraryId,createdAt - Supabase row validation via
Model.model_validate(row)works withsnake_casecolumn names - Clients can POST either snake_case or camelCase due to
populate_by_name=True
- Primary keys:
TEXT, generated as<entity>_<uuid4().hex[:10]>(e.g.,paper_a1b2c3d4e5) - Timestamps:
TIMESTAMPTZ, ISO 8601 format with milliseconds - JSONB columns:
authors,tags,collections,config,metrics,logs,trace,cost,suggestions - All RLS disabled (single-user app, no auth)
Route handlers always use by_alias=True:
return thing.model_dump(by_alias=True) # single object
return [t.model_dump(by_alias=True) for t in things] # listAll Supabase access is in backend/services/. No direct database calls outside this directory.
result = get_client().table("papers").select("*").execute()
rows = [Paper.model_validate(r) for r in result.data]paper = Paper(id=f"paper_{uuid.uuid4().hex[:10]}", **data.model_dump())
get_client().table("papers").insert(paper.model_dump(by_alias=False)).execute()Note: use by_alias=False for inserts so column names remain snake_case.
updates = data.model_dump(exclude_unset=True) # use exclude_unset, not exclude_none
if get_paper(paper_id) is None:
return None
get_client().table("papers").update(updates).eq("id", paper_id).execute()
return get_paper(paper_id)Critical: .update(...).eq(...).select().execute() raises AttributeError — .eq() on an update returns SyncFilterRequestBuilder which has no .select(). Always re-fetch after update.
get_client().table("papers").delete().eq("id", paper_id).execute()# backend/services/db.py
from functools import lru_cache
from supabase import create_client
@lru_cache(maxsize=1)
def get_client():
return create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"])- Upload (user or auto-download): PDF is uploaded to Supabase Storage bucket
pdfsat path{paper_id}.pdfviapdf_service.upload_pdf(). - URL storage: the Supabase public URL is written back to
papers.pdf_url. - Frontend display: PDFs are fetched as blobs and displayed via
URL.createObjectURL():Blob URLs bypassconst res = await fetch(paper.pdfUrl) const blob = await res.blob() setPdfBlobUrl(URL.createObjectURL(blob))
Content-Disposition: attachmentandX-Frame-Optionsheaders. They are revoked on cleanup viaURL.revokeObjectURL(). - Detection: a
pdf_urlcontaining/storage/v1/object/public/pdfs/is a Supabase-hosted PDF. Any other URL is an external source not yet uploaded.
- Trigger: frontend calls
POST /api/runswithworkflow_id,prompt, and optionaltarget_collection_id. - Run record creation:
run_service.create_run()inserts a run withstatus="running". - Background task:
BackgroundTasks.add_task(run_<workflow>, run.id, prompt)queues the workflow. - Response: the route returns immediately with the run record (status
"running"). - Live logging: the workflow calls
RunLogger.info/agent/tool/error()which callsrun_service.append_log()for real-time log streaming. - Progress updates:
RunLogger.set_progress(pct, step)updates the progress bar. - Completion:
run_service.complete_run(run_id, trace, cost)sets status to"completed". - Failure:
run_service.fail_run(run_id, error)sets status to"failed". - Activity feed:
emit_activity()appends a human-readable entry todata/activity.json. - Proposals: agent-created papers have associated
Proposalrecords. Users review and approve/reject proposals on the Proposals page.
- Route handlers use
HTTPException(status_code, detail). - All 404s return
{"error": "not_found", "detail": "..."}via the global exception handler inapp.py. - Services return
Nonewhen a record is not found; routers convertNonetoHTTPException(404). - External API errors are caught at the integration boundary (
import_service.py,agents/base.py) and re-raised asValueErrorwith user-facing messages. - Agent workflow errors are caught in the top-level
try/exceptof the workflow runner;run_service.fail_run()is always called, never leaving a run in"running"state.
The frontend is a single-page React application with no global state manager.
- LibraryContext (
context/LibraryContext.jsx): the only cross-cutting store; holds active library, libraries list, and collections. Persists active library tolocalStorage. - api.js (
services/api.js): thin fetch wrapper with named API objects per resource (papersApi,websitesApi, etc.). All calls go to/api/*which Vite proxies to the backend. - Pages: each page manages its own state with
useState/useEffect. No shared page cache. - Custom events:
window.dispatchEvent(new CustomEvent(...))for cross-component coordination without prop drilling (e.g.,researchos:items-changed,researchos:projects-changed). - Layouts:
Layout(with Header + Sidebar) andLayoutBare(Sidebar only) defined incomponents/layout/Layout.jsx.