AI general counsel and litigation platform. Two agent personas handle a law firm's inbound work end to end — read the email, do the legal work, produce the document, and hand it to an attorney for review.
- FRANK — outsourced General Counsel. Reviews, drafts, and answers questions about commercial agreements; runs KYC/AML onboarding.
- ELARA — civil litigation. Dockets statutory deadlines for California Superior Court cases and drives the workflow as case events occur.
Everything lands in a human-in-the-loop review queue. Attorney-client privilege and malpractice exposure are the reason — no work product reaches a client without an attorney approving it, with one narrow exception (see Auto-delivery).
They are deliberate opposites, and the split is the core design idea of the platform.
| FRANK | ELARA | |
|---|---|---|
| Domain | Commercial / corporate GC work | California civil litigation (> $25k) |
| Composition | ~80% LLM, thin pipeline | ~80% deterministic engine, LLM at the edges |
| Core loop | Plan → Execute → Reflect | Event → step activation → deadline arithmetic |
| What decides the work | An LLM plan, per email | Versioned workflow data, never a model |
| Human gates | Review queue (1 auto-deliver exception) | 3 gates, no exceptions |
| Code | backend/agents/frank_agent/ |
backend/agents/elara_agent/ |
ELARA's inversion is intentional: the product core is malpractice-grade docketing, and a stochastic model has no place in computing a statutory deadline. Every deadline carries its CCP citation and anchor event.
Three categories, all reached from the same inbox:
| Category | Input | Output |
|---|---|---|
| Review | Counterparty document + tenant playbook | Track-changes .docx + per-clause accept/reject cards |
| Draft | Email facts + template + jurisdiction profile | Generated .docx + statutory / consistency / gap flags |
| Query | A question about prior agreements | Synthesised answer + citations + confidence |
Workflow types: nda_review, document_review, investment_review,
employment_draft, term_sheet_draft, transactional, board_resolution,
agreement_query, kyc_incoming, kyc_outgoing, and a general catch-all.
Inbound email
├─ mailbox poll (Gmail / Microsoft Graph via OAuth) beat, every 2 min
└─ provider webhook
│
▼
email_logs row → LLM classify → workflow_type + facts + urgency
│
▼
Attorney clicks "Create Matter" routers/email_intake.py
│
▼
Celery dispatch tasks/frank_core.py | tasks/elara_core.py
│
▼
Agent orchestrator agents/frank_agent/orchestrator.py
Phase 1 UNDERSTAND load email, attachments, playbook, prior history
Phase 2 PLAN decompose into TaskPlans with depends_on
Phase 3 EXECUTE run experts in dependency-ordered parallel waves
Phase 4 REFLECT score quality, find gaps, retry up to 2x
Phase 5 FINALIZE persist matter, archive, conversation memory
│
▼
Review queue → attorney approves → outbound_emails → sent beat, 1 min
Every phase is checkpointed. A crashed matter resumes from its last saved phase rather than re-running completed work.
The orchestrator LLM never sees raw attachment text or full analysis output. Experts return a compact ~150-token summary (Tier 2) for planning and reflection; the full result (Tier 1) goes straight to Postgres and S3. This is what keeps compound requests — "review these 3 NDAs and draft an offer letter" — inside a workable context window.
One exception to attorney review: a single-task NDA review where all 7 playbook
dimensions return PASS and confidence is at least 0.85 may be delivered
without approval. Any non-PASS outcome, any compound request, and every other
workflow stays on the reviewed path. ELARA has no auto-deliver path at all.
Every agent writes its raw LLM response to agent_output['llm_response']
before validating it. If validation fails, the task is marked failed, the
matter does not advance, and the record stays for audit. A matter never
auto-completes with no work product.
.
├── backend/ FastAPI + SQLAlchemy 2 (async) + Celery/Redis + Postgres
│ ├── agents/
│ │ ├── frank_agent/ GC orchestrator, experts, playbooks, tools
│ │ ├── elara_agent/ Litigation engine, docketing, workflow defs
│ │ ├── agent_common/ Shared LLM client, storage, memory, checkpoints
│ │ └── tests_box/ Black-box end-to-end harness
│ ├── routers/ API surface, mounted under /api/v1
│ ├── tasks/ Celery tasks (frank_core, elara_core, …) + beat/
│ ├── services/ Email ingest/parse, playbook resolution, progression
│ ├── models/ SQLAlchemy models — the contract enums live here
│ ├── alembic/ Migration chain
│ └── scripts/ Playbook seeders, template builders, smoke tests
├── frontend/ Next.js 14 App Router + Tailwind
│ └── app/(app)/ matters · documents · cases · deadlines · kyc · search
├── mail_sender/ Static dev UI to hand-craft mock inbound emails
├── start.sh Railway entrypoint (PROCESS_TYPE=web|worker|beat)
└── start.cmd Windows dev launcher (opens all services)
Prerequisites: Python 3.11+, Node 18+, PostgreSQL 15+ (with pgvector),
Redis 7+. Tesseract OCR is optional — without it, scanned PDFs ingest as empty
text but the pipeline still runs.
# 1. Services
docker run -d --name la-pg -e POSTGRES_USER=c3 -e POSTGRES_PASSWORD=c3 \
-e POSTGRES_DB=c3_db -p 5432:5432 pgvector/pgvector:pg16
docker run -d --name la-redis -p 6379:6379 redis:7
# 2. Backend
cd backend
python -m venv .venv && source .venv/bin/activate # or: conda create -n c3 python=3.11
pip install -e ".[dev]"
cp .env.example .env # fill in the API keys
alembic upgrade head
# 3. Run (three terminals)
uvicorn main:app --reload --port 8000
celery -A core.celery_app.celery_app worker --loglevel=info
celery -A core.celery_app.celery_app beat --loglevel=info
# 4. Frontend
cd ../frontend && npm install && npm run dev| Check | URL |
|---|---|
| Liveness | GET localhost:8000/health/live |
| Readiness (verifies DB) | GET localhost:8000/health/ready |
| Swagger | localhost:8000/api/docs |
| App | localhost:3000 |
start.cmdlaunches everything at once on Windows, but itsPROJECT_WSLpath is hardcoded — edit it to match your checkout before first use.
Frontend-only work needs no backend: npm run dev:offline runs against
fixtures in frontend/mock/.
Run from backend/ with the environment activated.
| Task | make |
Raw |
|---|---|---|
| Install | make install |
pip install -e ".[dev]" |
| Migrate | make migrate |
alembic upgrade head |
| New migration | make revision |
alembic revision --autogenerate -m "..." |
| API | make api |
uvicorn main:app --reload --port 8000 |
| Worker | make worker |
celery -A core.celery_app.celery_app worker -l info |
| Beat | make beat |
celery -A core.celery_app.celery_app beat -l info |
| Tests | make test |
pytest -q |
| Lint | make lint |
ruff check . |
| Format | make fmt |
ruff format . |
Frontend, from frontend/: npm run dev · build · lint · typecheck.
All settings are pydantic-settings in backend/core/config.py.
Required fields have no defaults — a missing env var fails at startup rather
than silently misbehaving.
| Variable | Default | Notes |
|---|---|---|
LLM_PRIMARY |
kimi |
kimi or claude |
LLM_FALLBACK |
claude |
Must differ from primary |
MOONSHOT_MODEL |
kimi-k3 |
Primary model |
CLAUDE_MODEL |
claude-fable-5 |
Fallback model |
EMBEDDING_MODEL |
text-embedding-3-large |
3072 dims |
MOONSHOT_API_KEY |
— | Required |
ANTHROPIC_API_KEY |
— | Required |
OPENAI_API_KEY |
— | Required (embeddings) |
AGENTS_MODE |
— | development (SQLite + disk + keyword) or production (Postgres + S3 + pgvector) |
The primary provider is tried first; on a retryable failure the fallback runs
automatically. Per-service pins (NDA_ANALYZER_PROVIDER,
EMAIL_PARSER_PROVIDER, …) default to auto and let you force one provider
when the other misbehaves on a structured-JSON workload.
EMBEDDING_DIMENSIONSmust match the pgvector column definition. Changing one without the other corrupts the ANN index silently.
Beat tasks live in backend/tasks/beat/: mailbox polling
(2 min), outbound drain (1 min), SLA escalation (5 min), ELARA docket sweep
(hourly), KYC chase/refresh/retention, and NDA expiry (nightly). Every task
module must be named in core/celery_app.py::include or the worker raises
"Received unregistered task" at runtime.
The schema is Tenant → Matter → Document → Task, and a tenant is the
client — one tenant per fund or company, no separate clients table. Each
tenant has its own FRANK and ELARA mailbox addresses; the receiving address
binds an inbound email to a tenant.
The platform is cross-tenant for every signed-in lawyer. There is no
X-Tenant-Id header. List endpoints default to all tenants and accept an
optional ?tenant_id=; resource-scoped endpoints derive the tenant from the
row itself; /tenants/{tenant_id}/... paths are their own source of truth.
pytest -q # full suite
pytest tests/test_smoke.py -q # import + wiring sanity
pytest agents/frank_agent/tests/test_orchestrator.py -qTests run without infrastructure — tests/conftest.py stubs the required
env vars and no-ops the DB ping in the FastAPI lifespan, so wiring tests need
neither Postgres nor Redis. A test that genuinely needs a database opts in via
its own fixture. asyncio_mode = "auto", so no @pytest.mark.asyncio.
End-to-end runs make real LLM calls and cost money:
python -m agents.frank_agent.tests.real_e2e_runner --mode orchestrator --all --parallel 549 cases across 8 categories — standard workflows, compound requests, edge cases, boundaries, general knowledge, multi-turn conversation, adversarial stress (including prompt injection), and KYC.
Railway, three services from this repo differing only by PROCESS_TYPE:
PROCESS_TYPE |
Runs |
|---|---|
web |
alembic upgrade head, seeds court holidays, then uvicorn |
worker |
Celery worker |
beat |
Celery beat |
Set the start command to bash start.sh on all three and share the env vars
through one Railway variable group. Only web migrates — it is reliably first
to boot.
| Doc | Covers |
|---|---|
backend/agents/frank_agent/README.md |
Orchestration loop, experts, checkpointing, two-tier outputs, test matrix |
backend/agents/elara_agent/README.md |
Deterministic docketing engine, human gates, workflow definitions |
/api/docs |
Live OpenAPI reference |
- Add the value to
WorkflowTypeinbackend/models/models.py. - Teach
services/email_parser.pyto classify it. - Add a playbook under
agents/frank_agent/playbooks/and a resolver branch inservices/playbook_resolver.py. - Route it to an expert in
agents/frank_agent/experts/— reuseReviewExpert/DraftExpert/QueryExpertwhere the shape matches. - Add the SLA branch in
services/workflow_progression.py. - Add a seed script in
backend/scripts/. - (Optional) Add an FE panel in
frontend/app/(app)/documents/[id]/page.tsx. If the output mirrors the review/draft shape, the default panel handles it.
Skip the expert routing and the workflow falls through to GeneralExpert,
which will produce something — but not the specialised work product you
intended. Check the dispatch table before assuming a new type is wired.