Skip to content

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:

{ "ok": true, "message": "…text the sender receives…", "files": ["/path/to/attachment.pptx"] }

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 under stages/.

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:

cd u2v-dispatch && PYTHONPATH=src .venv/bin/python -m pytest

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.