The Orchestrator Got an HTTP Front Door
The investment memo orchestrator has been a 30-agent Python CLI for months — you type a company name, wait 15 to 45 minutes, and a fully cited 50-file deal memo lands in `output/`. It just works. Now it also speaks HTTP.
Changelog - 2026-04-30_01
The Orchestrator Got an HTTP Front Door
The investment memo orchestrator has been a 30-agent Python CLI for months — you type a company name, wait 15 to 45 minutes, and a fully cited 50-file deal memo lands in output/. It just works. Now it also speaks HTTP. We added a FastAPI sidecar at src/server/ that wraps the existing generate_memo() entry point, streams every log line to subscribers as Server-Sent Events, and serves the resulting artifacts back. Zero changes to any agent. The CLI still works exactly as it did yesterday.
This is the seam memopop-native — the Tauri desktop app that's been growing in lossless-monorepo/ai-labs/memopop-ai/ — needs to drive memo generation visually instead of through python -m src.main.
Why
A Tauri shell can't import generate_memo from a Python module. Something has to translate "user clicked Generate" into "Python function runs." Three options were on the table:
Subprocess the existing CLI. Tauri spawns
python -m src.main, parses stdout for progress, watchesoutput/for new files. Lowest effort. Fragile to argument-shape changes; no structured progress events.Hosted FastAPI server. Same shim, but in a container on Fly.io or Railway. Tauri talks to a public URL. Requires auth, queue, multi-tenancy, BYO API keys, and a way to bill memo runs that cost $5–$20 of Anthropic + Tavily each.
Local FastAPI sidecar. Run a FastAPI server alongside the desktop app on
localhost:8765, callinggenerate_memo()directly as a Python function. No network. No auth. No deploy. The user already has Python and a.venvset up — they had to, to run the CLI in the first place.
We picked option 3. Reasoning written up in detail in memopop-ai/context-v/explorations/Moving-an-Agent-Orchestrator-to-an-API.md, but the short version: the orchestrator is on the user's machine. They cloned it. Asking them to also reach over the network to a remote server to run code that's sitting on their own SSD would be deeply silly. The sidecar lives where the code lives. When hosted is needed later — for sharing, demos, or users who don't want to run Python — the same FastAPI moves to a container basically unchanged. The work between local and hosted is auth + storage + queue, none of which are needed yet.
There's also a quieter benefit: the orchestrator now has an HTTP surface for anyone — a notebook, a curl one-liner, a remote, a future iOS app, the next thing. It's not a memopop-specific feature. It's the orchestrator gaining a second skin.
What Landed
src/server/ — a thin shim around generate_memo()
src/server/
├── __init__.py # Module docstring with the route table
├── __main__.py # `python -m src.server` entry
├── app.py # FastAPI app, CORS, lifespan, uvicorn runner
├── jobs.py # JobRegistry, single-worker ThreadPoolExecutor
├── events.py # JobEventBus (per-job pub/sub) + LogSink (stdout → events)
└── models.py # Pydantic shapes that mirror generate_memo() kwargsSix files. ~400 lines total. No imports into src/agents/, no edits to src/workflow.py, no changes to state schemas. The shim respects the orchestrator's gravity well — generate_memo() is the API, the FastAPI just speaks HTTP at it.
The route surface
| Method | Path | Purpose |
| GET | /healthz | Liveness probe. Returns cwd for sanity. |
| POST | /memos | Submit a generation job. Returns {job_id}. |
| GET | /memos | List jobs in this process. |
| GET | /memos/{id} | Status snapshot. |
| GET | /memos/{id}/events | SSE stream: log lines + lifecycle events. |
| GET | /memos/{id}/artifacts | List output files (relative paths + sizes). |
| GET | /memos/{id}/artifacts/{path:path} | Download a single artifact, with traversal guard. |
POST /memos accepts JSON that mirrors generate_memo() kwargs — same field names, same types:
{
"company_name": "Stripe",
"investment_type": "direct",
"memo_mode": "consider",
"firm": "alpha-partners",
"company_url": "https://stripe.com",
"deck_path": "/abs/path/to/deck.pdf",
"outline_name": "alpha-partners-7-Cs-memo-template",
"fresh": false
}Anything you can pass to the CLI you can post to the API.
Streaming logs without changing a line of orchestrator code
The trick: LogSink is a TextIOBase subclass that splits writes into lines and publishes each one to a thread-safe per-job event bus. While a job runs, contextlib.redirect_stdout(LogSink(bus)) (and the same for stderr) swaps it in transparently. Every print() from every agent — research, writer, citation enrichment, validator — becomes a {"type": "log", "line": "..."} event on the bus. Subscribers get the last 2000 events on connect, then live-tail until the job closes. Original stdout/stderr are mirrored so dev runs in a terminal still show the orchestrator's output the way they always did.
The agent code never knew anything happened. That's the discipline we wanted: memo generation is the orchestrator's job; HTTP plumbing is the server's. Neither side leaks into the other.
One worker, on purpose
ThreadPoolExecutor(max_workers=1). A local sidecar runs one memo at a time. Anthropic rate limits assume serial calls. Datarooms are usually one deal deep. The user's expectation is "submit one, watch it run, see the result." Multi-job concurrency is a sketch on the back of an envelope, not a feature anyone is asking for. If hosted ever needs it, swap the executor for Celery; everything else stays.
Free API docs
FastAPI introspects the Pydantic models and generates OpenAPI for free. While the server is running:
http://127.0.0.1:8765/docs— Swagger UI with try-it-out forms.http://127.0.0.1:8765/redoc— ReDoc, more reading-friendly.http://127.0.0.1:8765/openapi.json— raw spec, paste into Postman.
Field-level descriptions on the models become tooltips. We didn't write any of this. We wrote some Python and got a documented HTTP API.
Design Decisions
Sidecar lives in the orchestrator repo, not memopop-ai. The FastAPI is a thin import wrapper around generate_memo(). Putting it in a separate submodule under memopop-ai would mean either vendoring the orchestrator (two copies to maintain) or importing it across submodule boundaries (path gymnastics, fragile). The shim belongs where the code it imports lives. The orchestrator is already a submodule of ai-labs; adding src/server/ is a normal change inside that submodule.
Threads, not subprocesses. LangGraph state is not designed to cross process boundaries cleanly. Threads share memory with the FastAPI loop, which means SSE handlers can subscribe to the same JobEventBus instance the worker is publishing into — no IPC, no serialization. The cost is single-process: a server crash takes running jobs with it. Fine for v1. Artifacts are written to disk continuously by the orchestrator, so a crashed run leaves recoverable state.
Job registry in memory, artifacts on disk. Job records and event buses live in process memory. Restarting the server loses in-flight history. The artifact tree (output/... or io/{firm}/deals/{deal}/outputs/...) is the durable store. This is the correct tradeoff for a sidecar that's expected to be restarted whenever the desktop app restarts. SQLite-persistence is a 2-hour follow-up if it ever matters.
CORS locked to localhost and Tauri webview origins. Not because of security yet — local sidecar, no secrets to protect — but because declaring intent matters. The allow_origin_regex makes it obvious that this server expects to be talking to a desktop app or dev frontend on the same machine. A future hosted variant will need to tighten further; today's regex is already tighter than allow_origins=["*"], which is what most local servers ship with.
force_version is a string, not a SemVer object. Mirrors the CLI. Whatever the CLI accepts, the API accepts. No translation layer.
Verification
The shell works. The end-to-end memo generation through HTTP has not been verified yet — that takes 15 to 45 minutes and burns real Anthropic + Tavily credits, and we wanted the next session to drive that test rather than spending the budget today.
What we did verify:
$ python -m src.server --port 8765 &
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8765
$ curl -s http://127.0.0.1:8765/healthz | jq
{
"ok": true,
"service": "investment-memo-orchestrator",
"cwd": "/Users/.../investment-memo-orchestrator"
}
$ curl -s http://127.0.0.1:8765/memos
[]
$ curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8765/memos/nope
{"detail":"job not found"}
HTTP 404
$ curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8765/memos \
-H 'Content-Type: application/json' -d '{}'
{"detail":[{"type":"missing","loc":["body","company_name"], ...}]}
HTTP 422Routes register cleanly. Errors return correct codes with proper detail bodies. Pydantic validation produces useful messages. Imports don't blow up under uvicorn's reloader.
The unverified suspicions, in order:
Rich Console output capture. If any agent instantiated
rich.Console()at module-import time, it captured the realsys.stdoutbefore ourredirect_stdoutswap. Those writes will go to the host terminal (via the mirror) but not into the SSE event stream. Most agents seem to instantiate Rich at call time, but we haven't grep-confirmed.final_state["output_dir"]populated correctly. The CLI reads it from state and we mirror that. If the workflow ever returns without populating it, the artifact endpoints can't find files. The CLI'smain.py:436suggests it's reliable, but a real run is the proof.uvicorn under
--reloadand the lifespan handler. Reload mode replaces the worker process; the singletonJobRegistryand any in-flight jobs die when it does. Don't run jobs against--reloadin dev.
How to Run
cd investment-memo-orchestrator
source .venv/bin/activate
python -m src.server # default: 127.0.0.1:8765
python -m src.server --port 9000 # different port
python -m src.server --reload # dev: auto-reload on code changesOr, after uv pip install -e ., the console script:
memopop-serverBind config also via env: MEMOPOP_HOST, MEMOPOP_PORT.
Open http://127.0.0.1:8765/docs in a browser and click around.
Dependencies Added
To pyproject.toml:
| Package | Purpose |
fastapi>=0.110.0 | HTTP framework, OpenAPI generation |
uvicorn[standard]>=0.27.0 | ASGI server (with uvloop, httptools, ws) |
sse-starlette>=2.0.0 | Clean Server-Sent Event responses |
Installed via uv pip install fastapi 'uvicorn[standard]' sse-starlette — targeted, not a full uv pip install -e ., to sidestep the dependency-disappearing issue documented in CLAUDE.md.
Files Changed
| File | Change |
src/server/__init__.py | Created — Module docstring, route table, public exports. |
src/server/__main__.py | Created — python -m src.server entry. |
src/server/app.py | Created — FastAPI app, lifespan, routes, CORS, uvicorn runner. |
src/server/jobs.py | Created — JobRegistry, Job dataclass, single-worker ThreadPoolExecutor. |
src/server/events.py | Created — JobEventBus, LogSink for stdout→event capture. |
src/server/models.py | Created — Pydantic request/response shapes. |
pyproject.toml | Added fastapi, uvicorn[standard], sse-starlette. Added memopop-server console script. |
What's Next
The other half of this work lives in memopop-ai/apps/memopop-native/. To close the loop:
Spawn the sidecar from Tauri's Rust side on app start, with
cwdset to the user's anchored orchestrator repo path. Use the existingrepoPathsetting.Forward
/memos*from the existing Rust dispatcher tohttp://127.0.0.1:8765. The Transport seam is already abstracted; this is onematch (method, path)arm inrouter.rs.Replace the
DealCreationModal's "Ready to generate" panel with a live job view that subscribes to the SSE event stream, shows lifecycle transitions, and renders the log tail.First end-to-end test: type "Stripe" into MemoPop, click Generate, watch the agents run in a desktop window, see the artifact list when it's done.
That's the moment the journey from session 02 of memopop-native through this commit closes — the moment a VC could, in principle, sit down with the desktop app and not type a single shell command. Soon.