u2v-dispatch¶
πͺ Front door Β· the router every email passes through
This is the piece that lets the U2V team remember exactly one thing β email Klaus β and have the right automation run. It's also, deliberately, the smallest thinking part of the system: it decides what you want and hands the work to someone else. This page goes deep on how that routing works and where its edges are. Use the table of contents on the right to jump around.
At a glance¶
u2v-dispatch owns the shared inbox klaus@u2v.vc. On every pass it reads new mail, checks the sender
is on the team, asks Claude what the sender wants, runs the matching workflow as a subprocess,
replies with the result, and labels the thread so it's never handled twice.
flowchart TD
A["Every 30 min: read unprocessed mail"] --> B{On the allowlist?}
B -->|no| Bx["label 'ignored' Β· stop"]
B -->|yes| C["classify (1 forced Sonnet call):<br/>which workflow? what parameters?"]
C --> D{confident & all<br/>required info present?}
D -->|no| Dx["reply asking for what's missing<br/>(never guess) Β· label 'needs-info'"]
D -->|yes| E{a roster / long list?}
E -->|yes| Q["park in the queue,<br/>drain a chunk per pass"]
E -->|no| F["run the workflow as a subprocess<br/>in its own folder + venv"]
F --> G["read its {ok, message, files} envelope"]
Q --> H
G --> H["label 'processed' Β· reply Β· attach files"]
It runs as a GitHub Action every 30 minutes and is the production heartbeat of the whole system. 105 offline tests (Gmail, the classifier and the subprocess are all faked β the suite never makes a live call).
The mental model: a router that never does the work¶
The one idea to hold onto: the dispatcher decides, it doesn't do. It never scores a startup or builds a document itself. It figures out which automation an email needs, starts that automation, and relays the result.
Crucially, it starts each automation as a subprocess β a separate program in that project's own folder and virtual environment β and reads back a single line of JSON. It never imports a workflow's code. That's the keystone of the whole repo (see Design principles β the thin router): because each automation runs in its own environment, they can have wildly different, conflicting dependencies and still cooperate. The only thing they share is a tiny contract β one JSON object on standard output:
The dispatcher parses no automation-specific fields β it relays message and attaches files. That's
why "add a new automation" almost never means "change the dispatcher," and why swapping subprocesses
for HTTP calls one day would be a change in a single file.
One inbox pass, step by step¶
The pass lives in dispatch.py.
process_inbox is a plain loop over each unprocessed email; _process_one handles a single message in
this order:
- Allowlist gate β resolve the sender against the team roster. Unknown sender β label
ignoredand stop. The classifier never even runs for a stranger. - Classify β one AI call returns an
Intent(which workflow, plus the extracted parameters). - Routing gate β if the intent is unclear or missing something required, reply asking for it and
stop (
needs-info). Never guess. - Roster shortcut β if it's a roster PDF or a long list, park it in the queue and ack (below).
- Run the intent β start the workflow subprocess(es) and collect their JSON results.
- Label
processed, then reply β attach any files the workflow produced; CC the team per policy.
# dispatch.py β the allowlist gate comes first, before any AI runs
owner = owners.resolve(msg.sender)
if owner is None: # only known team senders are processed
gmail.mark(msg, LABEL_IGNORED)
return DispatchResult(msg.id, msg.sender, LABEL_IGNORED, note="sender not on allowlist")
One bad email can never sink the pass. Each message is processed inside a try/except: any failure
is caught, the thread is labelled dispatch-error, and the loop moves on. So a malformed email or a
crashing workflow is isolated to its own result.
Two things worth knowing about the ordering
The thread is labelled before the reply is sent (idempotency is prioritised over the reply β a failed send won't cause a re-run), and processing within a pass is sequential β messages are handled one at a time.
The Gmail connection¶
Everything to and from the inbox goes through
gmail.py,
which talks to the Gmail REST API directly over httpx β no Google client libraries.
- Auth. It holds a long-lived refresh token and exchanges it for a short-lived access token on
each run, so it can act as the inbox with no human logging in. (The three
GMAIL_*secrets and how to re-authorise are covered in Structure & orchestration β Google Cloud.) -
What counts as "unprocessed." A single query: everything in the inbox that isn't already carrying one of the four dispatch labels.
-
Reading a message. It walks the MIME tree to pull the sender, subject, the plain-text body, and any PDF attachments (PDF is the only attachment type extracted today β see Limitations).
- Replying. A proper multipart/alternative email (plain-text + HTML), with attachments, optional CC, and threading headers so the reply lands on the original thread.
- The four labels are the memory that makes the pass idempotent:
dispatch-processed,dispatch-ignored,dispatch-needs-info,dispatch-error. gmail-authis a one-time CLI verb that runs the OAuth consent flow and prints the refresh token to paste into the secrets.
Classification: the menu is built from data¶
The router (router.py)
makes one forced tool-call to claude-sonnet-5: the model must answer by calling a classify tool
whose schema is fixed, so the routing decision is always well-formed data.
The clever part is that the list of things it can route to is generated at runtime from
workflows.yaml β
each workflow's description and params. The prompt lists the workflows, and the schema's workflow
field is an enum of their names:
# router.py β the classifier's options ARE the registry; internal helpers are hidden
def build_schema(specs):
names = [s.name for s in _catalog(specs)] + ["none"]
return {... "workflow": {"type": "string", "enum": names} ...}
So adding a new automation is a data edit: register it in workflows.yaml with a good description,
and the classifier can route to it β no change to the router's code.
The full email body is always sent β never truncated. An earlier version capped the body to save tokens and once silently dropped 18 of 22 startups from a forwarded newsletter (tracking URLs had inflated the byte count). The lesson stuck: send the whole thing.
The result is an Intent (models.py):
the chosen workflow, plus everything the model could extract β startups[] (each with name/url and
optional funding round + raise), roster_pdf, deal_source, funnel_stage, lp_flags, lp_company
/ sector_query, also_lp_task, a confidence, and a reason shown to the sender when it can't
route. These extracted fields are exactly the sender-stated overrides
that flow into dealflow.
The subprocess contract¶
How a workflow actually runs lives in one file,
adapters.py
β the architectural keystone. A WorkflowSpec (loaded from workflows.yaml) knows the workflow's
folder, its venv Python, and its argv template. run_workflow fills the template, launches the child,
and parses the envelope:
# adapters.py β run the workflow in its own dir + venv, read one JSON line back
argv = [spec.python] + [_fill(a, params) for a in spec.args]
sub_env = {**os.environ, **(env or {}), "PYTHONPATH": str(Path(spec.cwd) / "src")}
proc = run(argv, spec.cwd, sub_env) # subprocess.run(..., timeout=1800)
parsed = _extract_json(proc.stdout) # the LAST stdout line that is a JSON object
Details that matter:
PYTHONPATH={cwd}/srcis injected because the projects aresrc-layout and can't use editable installs (the repo path has spaces).- The venv Python is used as-is, never resolved. A venv's
pythonis a symlink to the system interpreter; following it would launch Python without the venv's packages. So the path is used verbatim. - The envelope is the last JSON line on stdout β everything else (logs, progress) is expected on
stderr. A non-zero exit with no JSON becomes
ok=Falsewith a tail of stderr; the dispatcher never crashes because a workflow did. - File paths are made absolute against the workflow's folder, since the dispatcher runs from a different directory and needs to find them to attach.
workflows.yaml also marks some entries internal: true (e.g. the roster splitter and the batch
scorer) β real workflows the dispatcher calls itself, hidden from the classifier's menu.
Routing, fan-out & "ask, don't guess"¶
Before running anything, _routing_problem decides whether the dispatcher is allowed to act:
# dispatch.py β if any gate returns a reason, we reply asking for it instead of guessing
if spec is None: return intent.reason or "no matching workflow"
if intent.confidence != "high": return intent.reason or "low confidenceβ¦"
if spec.needs_pdf and not msg.pdf_attachments and not any(s.input_value() for s in intent.startups):
return "looks like a deal intake but no deck was attached and no startup link/name was given"
if spec.name == "lp_match" and not (intent.lp_company or intent.sector_query):
return "tell me which LP company or which sector to look in"
for p in spec.params: # any generically-declared required param (e.g. lp_name)
if not (getattr(intent, p, None) or "").strip():
return f"missing required detail: {p}"
If any gate fires, the sender gets a friendly note listing what Klaus can do, and nothing runs. This is the "ask, don't guess" rule in code.
When it can run, the fan-out depends on the intent:
- dealflow β one run per attached PDF (the deck is the source of funding truth, so no email
overrides are applied), or, with no deck, one run per
startups[]entry (URL preferred over name). The per-email context (owner, deal source, funnel stage, LP flags) is threaded to each. - lp_match β one run, passing whichever of company / sector was given.
- lp_intake β one run (it fans out internally over the LPs it finds); if the same email
also_lp_task, a task run is chained only if the intake succeeded (chaining after a failure would just cascade). - lp_share β one run with the
lp_name.
CC policy: the team is CC'd on a dealflow reply only when a new record was created, always on lp-intake / lp-task (the team should know about LP activity either way), and never on lp-share / lp-match / needs-info.
Long lists & rosters: the queue¶
A single email can imply a lot of work β a roster PDF listing 100 startups, or a forwarded newsletter with 20 of them. Running all of those inline would blow the pass's time budget, and because the thread is only labelled after everything finishes, a crash midway would reprocess the whole batch next pass.
So those are parked in a git-tracked queue (state/roster_queue.json)
and drained a few at a time. The drain is a shared budget across all queued jobs:
# dispatch.py β score up to `chunk` entries total this pass, oldest first
budget = chunk # roster_chunk_size, default 5
for job in jobs:
while job["pending"] and budget > 0:
entry = job["pending"].pop(0)
job["done"].append(_score_roster_entry(batch_spec, job["owner"], entry, run, common))
budget -= 1
When a job's list is fully drained, the sender gets a summary email with a CSV of the results. The
GitHub Action commits the updated queue back to the repo (with a [skip ci] message so the commit
doesn't trigger another run), so the next pass resumes exactly where this one left off, and a
concurrency guard stops two passes from racing on the file.
Idempotency & state¶
The dispatcher has no database β its memory is Gmail labels + git. The four labels mean a thread is
handled exactly once; a labelled thread is excluded from the next pass's query. Two consequences worth
being explicit about: the label is applied before the reply is sent (so a failed reply won't cause
a re-run), and an errored email is not automatically retried β it carries dispatch-error and
waits for a human. Anything that should be safe to see twice (a re-forwarded deck) is caught by each
workflow's own dedup, not by the dispatcher.
Limitations of the front door¶
Being honest about the edges β most of these are deliberate trade-offs, a couple are just "not built yet."
- Allowlist-only. Only known team senders are processed; everyone else is silently ignored. A founder emailing Klaus directly gets nothing β a teammate has to forward it. (This is a security feature first, but it is a limit.)
- Not real-time. The pass runs on a ~30-minute cadence, so there's up to a ~30-minute delay before an email is handled.
- One inbox. The design is hard-bound to the single
klaus@u2v.vcaccount. - PDF attachments only. Only PDF attachments are extracted from an email; an
.xlsxroster works when a workflow CLI is run directly but isn't reachable through the inbox yet. An HTML-only email with no plain-text part loses its body, leaving the classifier with just the subject and attachment names. - The classifier can be confidently wrong. Routing is an AI judgment. The guard is the confidence gate (low confidence β ask), but a high-confidence misroute β right shape, wrong workflow β still runs. This is the non-deterministic boundary in practice: the router is stable enough to trust, not infallible.
- Sequential passes. Messages are handled one at a time, so one slow AI call or a hung workflow serialises the pass.
- Hard 30-minute timeout per workflow, no retry. A workflow that exceeds it fails that email (or that roster entry); nothing retries automatically.
- Big rosters drain slowly. At 5 entries per pass every 30 minutes, a ~100-startup roster takes hours to fully process. This is intentional (steady, safe, cheap) β but it isn't instant.
- The envelope is the last JSON line. A workflow that prints a JSON object to stdout after its real result would confuse the parser β a real contract every workflow has to honour.
Where things live¶
| Path | Role |
|---|---|
src/u2v_dispatch/dispatch.py |
The pass: allowlist β classify β route β run β reply; the roster drain |
src/u2v_dispatch/router.py |
The classifier β builds its prompt + schema from workflows.yaml |
src/u2v_dispatch/adapters.py |
Runs a workflow as a subprocess and parses the {ok,message,files} envelope |
src/u2v_dispatch/gmail.py |
Gmail access (read, label, reply) over plain REST; the gmail-auth flow |
src/u2v_dispatch/roster_queue.py |
Pure helpers for the git-tracked work queue |
src/u2v_dispatch/reply.py / render.py |
Compose the reply markdown β multipart email |
src/u2v_dispatch/owners.py |
The team allowlist (sender β team email; doubles as dealflow owner) |
workflows.yaml |
The registry β the menu of routable + internal workflows |
reference/owners.yaml |
The team roster / allowlist |
state/roster_queue.json |
The git-tracked queue of pending list work |
config.yaml |
Knobs: classifier_model, roster_chunk_size, team_cc |
Knobs a non-engineer can change in config.yaml: the classifier model, roster_chunk_size (how
many queued entries to drain per pass, and the inline-vs-queue threshold), and team_cc (blank to
disable CC entirely). Who may email Klaus lives in reference/owners.yaml.
Status & how to run it¶
The production heartbeat, running every 30 minutes. 105 offline tests.
cd u2v-dispatch
PYTHONPATH=src .venv/bin/python -m u2v_dispatch.cli run # one inbox pass
PYTHONPATH=src .venv/bin/python -m u2v_dispatch.cli route --body-file email.txt # classify only
PYTHONPATH=src .venv/bin/python -m u2v_dispatch.cli gmail-auth # mint a Gmail refresh token
Go deeper
Open the repo in Claude Code and ask, for example:
- "Trace one email through
_process_onefrom the allowlist gate to the reply." - "Show me how
router.pybuilds the classifier prompt and schema fromworkflows.yaml." - "Walk me through
run_workflowinadapters.pyand how it parses the JSON envelope." - "How does
drain_roster_queuedecide how many startups to score this pass?"