Design principles¶
If you read only one technical page, read this one. Eight ideas shape every automation in the repo. Once you can see them, the rest of the codebase stops looking like seven separate projects and starts looking like one system built the same way seven times.
Each principle below says what it is, why it's there, and where you'll see it in the code.
1. A monorepo of independent projects¶
Every top-level u2v-* folder is a completely self-contained project: its own src/, tests/,
.venv, config.yaml, .env, CLAUDE.md, and README.md. They live in one git repository but are
strictly separated — they share no code and no dependencies, and no automation can import,
call, or reach into another. The only thing that crosses a project boundary is the dispatcher, and even
it stays at arm's length (principle 2).
Why — this modularity is the single biggest reason the codebase stays maintainable. Each automation has different, heavy dependencies (PDF parsing, PowerPoint generation, Excel, web research) and moves at its own pace. Sharing code would couple them: one change could silently break three automations at once. Strict separation buys the opposite — a change to one automation physically cannot break another, each one can be read, tested, and reasoned about entirely on its own, and a new automation drops in without touching a line of the others. You never have to hold the whole system in your head to work safely on one piece of it.
Where you'll see it
The seven sibling folders at the repo root. Notice each has the same skeleton — once you've learned one, you can navigate all of them.
2. The dispatcher is a thin router¶
One project — u2v-dispatch — owns the shared inbox and decides which
automation an email is for. But it never imports the others. It runs each one as a subprocess,
in that project's own folder and virtual environment.
Why. This is the trick that lets the projects have clashing dependencies and still cooperate. The dispatcher doesn't need to know how an automation works — only how to start it and read its result. Swapping "run as a subprocess" for "call over HTTP" one day would be a change in a single file.
Where you'll see it
u2v-dispatch/adapters.py (starts the subprocess) and u2v-dispatch/workflows.yaml (the registry
of what can be run). No import u2v_dealflow anywhere in the dispatcher.
3. Every automation speaks the same contract¶
When run in --json mode, an automation prints exactly one JSON object to standard output:
Everything else — logs, progress, warnings — goes to standard error. The dispatcher reads that one
object, emails message back to the sender, and attaches any files.
Why. A fixed, tiny contract is what makes the automations interchangeable. The dispatcher parses no automation-specific fields, so adding a new automation never means changing how results are read.
Where you'll see it
Each project's cli.py builds and prints this envelope. The shape is defined in
u2v-dispatch/models.py.
4. Data vs. logic — the U2V-team-editable seam¶
In every project there's a deliberate split:
- Logic (engineers only) lives in
src/— the Python spine. - Judgment, wording, and the high-level "plug" decisions (U2V-team-editable) live in
config.yaml,reference/,templates/, and the rubric files understages/.
Why. The parts most likely to change — a scoring threshold, how much each dimension of the score is weighted, the wording of a rubric, a sector list, a document template, which model runs each step — are the parts that should be tunable without an engineer digging through the spine. Pulling them out of the code means you can adjust what the system decides without touching how it runs, and the important dials sit in one obvious place instead of being scattered through the Python.
The models are a dial, not code¶
The clearest example is model selection. Each project's config.yaml lists, in one place, which
Claude model runs each step of that automation's workflow — a fast, cheap model for simple extraction,
a stronger one for hard scoring or ranking. Swapping a model (or one day pointing a step at a
self-hosted open-weight model) is a one-line edit in config.yaml, never a code change. So that
file doubles as the honest map of where the intelligence is spent: read it and you know exactly what
runs where.
Change dials through Claude Code
Even though these are "just settings", the safest way to change one — a weight, a threshold, a model — is still to open the repo in Claude Code and ask for the change in plain English. It finds the right file, keeps the format valid, and won't stray into the spine.
Where you'll see it
The model settings in each project's config.yaml — e.g. u2v-lp-match
routes research → Sonnet, mapping → Sonnet, ranking → Opus in three lines under models:. And
compare u2v-dealflow/src/ (mechanics) with u2v-dealflow/stages/ (the scoring rubrics in plain
language): change a rubric, and the behaviour changes with no code edit.
5. Secrets stay out of the code, and stay minimal¶
No secret is ever written in code. Each project keeps its own credentials in a gitignored .env and
ships a committed .env.example showing which keys it needs. Some automations use their own,
narrowly-scoped API keys rather than a shared one.
Why. Two reasons: a leaked repo never leaks a secret, and least privilege limits the blast radius
— u2v-lp-intake and u2v-lp-tasks can only
touch the slice of Attio they need, because they authenticate with keys scoped to just that.
Where you'll see it
.env.example in each project; the per-project secret injection in
.github/workflows/dispatch.yml.
6. Idempotent, with state kept in git¶
The automations run on a schedule and may see the same input twice. They're built so that running again doesn't double-act: processed emails get labelled, and progress is saved to small JSON files that are committed back to the repo.
Why. There's no database. Committing state (a queue of pending work, a record of what was last featured) to git makes the system's memory visible, versioned, and free — you can see in the git history exactly what it did and when.
Where you'll see it
u2v-dispatch/state/roster_queue.json (work still to drain) and
u2v-dealflow-sample/state/last_featured.json (so it doesn't repeat picks). Both are updated by
the GitHub Actions after each run.
7. The model does judgment, not plumbing¶
Claude is used for the parts that genuinely need reasoning — classifying an email, scoring a startup, extracting fields from a messy website, ranking a shortlist. Everything deterministic — date math, parsing a spreadsheet, building a document, deciding whether an email was already handled — is plain Python.
Why. Reliability and cost. A language model is the wrong tool for arithmetic and file I/O; wrapping its judgment in deterministic code makes each automation predictable and cheap, and keeps the model focused on the one thing only it can do.
Where you'll see it
u2v-lp-tasks: one small model call extracts what the task is; the
due-date math and the Attio write are ordinary code. u2v-lp-intake
parses .xlsx rosters with no model call at all.
8. Deterministic by default, non-deterministic only where it counts¶
Principle 7 is about what the model is used for. This one is about the flip side: the reliability boundary it creates. Every automation is a mix of two kinds of step, and it helps to see them as having genuinely different properties:
| Deterministic (plain Python) | Non-deterministic (a model call) |
|---|---|
| Same input → same output, every time | Same input → similar, not identical, output |
| Testable offline, no API key, free | Needs the API, costs money, varies run to run |
| Predictable, but can't judge or read prose | Can reason and read messy inputs — but can be wrong, and can only perceive what it's actually given |
Why it matters. The non-deterministic parts are where the value is and where the limits are. A model call can be brilliant at reading a deck and still (a) return a slightly different score on a re-run, (b) only "see" inputs it can actually fetch — it can read a normal web page, but not a file sitting behind a Google Drive / Dropbox / DocuSign share link — and (c) occasionally reach for a value that doesn't exist.
The architecture contains those limits rather than pretending they're absent:
- Strict enums mean the model can only emit real, in-vocabulary values (a made-up sector or LP is literally unemittable).
- Validation rejects a malformed result before it's ever written.
- Deterministic wrappers do the arithmetic and the I/O around the judgment.
- A human always decides — the output is a proposal, never an irreversible act.
Where you'll see it
u2v-dealflow has a whole Limits of the model
section built on this idea — most concretely, that a pitch deck hidden behind a file-share link
can't be fetched, so the real PDF must be sent instead.
A note on running the code¶
One quirk you'll hit immediately: the repo path contains spaces, which breaks Python's editable
installs. So everything runs with PYTHONPATH=src instead:
And because the live dispatcher runs from origin/main on a schedule, a merged change is live on
the next pass — commit deliberately.
Go deeper
Every principle here is enforced somewhere concrete. Open the repo in
Claude Code and ask, e.g., "show me how the dispatcher runs a
workflow as a subprocess" or "where is the --json envelope built in u2v-dealflow?" — the
per-project CLAUDE.md files give it the context to answer precisely.