Skip to content

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 &amp; 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:

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

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:

  1. Allowlist gate β€” resolve the sender against the team roster. Unknown sender β†’ label ignored and stop. The classifier never even runs for a stranger.
  2. Classify β€” one AI call returns an Intent (which workflow, plus the extracted parameters).
  3. Routing gate β€” if the intent is unclear or missing something required, reply asking for it and stop (needs-info). Never guess.
  4. Roster shortcut β€” if it's a roster PDF or a long list, park it in the queue and ack (below).
  5. Run the intent β€” start the workflow subprocess(es) and collect their JSON results.
  6. 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.

    # gmail.py β€” "unprocessed" = in the inbox, not yet routed. No has:attachment filter:
    # an LP-list request has no attachment β€” the classifier decides intent, not the query.
    "in:inbox " + " ".join(f"-label:{name}" for name in 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-auth is 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}/src is injected because the projects are src-layout and can't use editable installs (the repo path has spaces).
  • The venv Python is used as-is, never resolved. A venv's python is 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=False with 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.vc account.
  • PDF attachments only. Only PDF attachments are extracted from an email; an .xlsx roster 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_one from the allowlist gate to the reply."
  • "Show me how router.py builds the classifier prompt and schema from workflows.yaml."
  • "Walk me through run_workflow in adapters.py and how it parses the JSON envelope."
  • "How does drain_roster_queue decide how many startups to score this pass?"