DEV Community

Taylor Lin
Taylor Lin

Posted on

Routing Agent Jobs Across Free Tiers: A Glossary, a Decision Tree, and Four Worked Leaves

Two of this week's most-read posts on DEV were about AI fatigue and about tooling that lags behind model capability. Read together, they describe the same operational gap: models got cheaper and better, while the plumbing that decides where a job runs stayed informal, undocumented, and unreproducible.

The failure shape is familiar. A script that used to run on a laptop gets promoted to "the free tier" because the free tier is free. Nobody notices that the job is a webhook receiver — something has to connect to it. Nothing does. The retry loop runs for six hours, duplicate units pile up, and the only record of the routing decision is one sentence in a chat thread.

A week later, three questions have no answer: which lane did this job belong in, what evidence justified that lane, and what would move it out again.

So here is the artifact this post builds: a glossary, a decision tree with four leaves, a dependency-free router you can run (route_job.py), a test file that pins every branch, and a log query that turns routing records into a table you can argue with.

Glossary: the eight terms the tree depends on

Precision here saves arguments later. If two people mean different things by "free tier", the decision tree resolves nothing.

  1. Job — one unit of work with a name, an input, an output, and an owner. Not a pipeline.
  2. Lane — the placement class a job routes to. This post uses four: local_only, free_model, free_server, controlled.
  3. Leaf — a terminal outcome of the tree, carrying one example, one expected routing record, and one exit condition.
  4. Data class — what the input contains: public, internal, or sensitive. Assigned by the data owner, not by the engineer who is in a hurry.
  5. Idempotent unit — work that replays from the start with no extra side effect. A duplicate is a wasted call, not a corrupted record.
  6. Addressable — something must initiate a connection to the job: a webhook, a schedule, an SSH session. Addressability is a hosting requirement, not a model requirement.
  7. Pinned model — a job whose output is reproducible only against a specific model revision. Reproducibility outranks cost.
  8. Routing record — one JSON line written at dispatch time: job, lane, reason, data class, and the quota observed on that call.

The decision tree: five branches, in order

Branch order is the policy. Reorder it and you have a different system, so write the order down and test it.

Branch 1 — Can the answer be computed without a model? If yes, local_only.

Branch 2 — Is the input sensitive, or is there a contractual review? controlled. Boundaries beat convenience.

Branch 3 — Does the job need a pinned model? controlled. Free tiers move underneath you by design.

Branch 4 — Must something connect to the job, or does it run longer than a short request? free_server.

Branch 5 — Is a re-run cheap and safe? Not idempotent goes to controlled. Idempotent goes to free_model.

The two interesting leaves are free_model and free_server, because both assume you have a model endpoint and a host. Many free options exist for each; this post treats them as properties, not brands. MonkeyCode, for example, is an open-source project whose free model access and free server option map onto those two leaves.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Its current outreach states a free tier of 10,000,000 tokens plus a free server option. Treat both as configuration you read at run time, never as constants you hard-code: quotas and terms are set by the project and can change between the day you write the number down and the day you rely on it.

Four worked leaves

Leaf A — local_only

Example: a nightly disk-usage digest that reads /proc and prints a table. Why it stops here: the answer is computable without a model.

{"name":"disk-report","lane":"local_only","reason":"decidable without a model; keep it off the network"}
Enter fullscreen mode Exit fullscreen mode

Exit condition: the day someone adds an LLM summarization step, this becomes two jobs. Keep the deterministic half exactly where it is.

Leaf B — free_model

Example: drafting a changelog entry from a public PR diff. Why it stops here: public input, single-shot, idempotent, no inbound connection.

{"name":"changelog-draft","lane":"free_model","reason":"disposable, non-sensitive, single-shot"}
Enter fullscreen mode Exit fullscreen mode

Exit condition: the diff stops being public. Then Branch 2 fires and the job moves to controlled, no matter how cheap leaf B is.

Leaf C — free_server

Example: a watcher that receives a repo webhook, queues the event, and replies later. Why it stops here: it is addressable and long-running; the model call is incidental.

{"name":"repo-webhook","lane":"free_server","reason":"long-lived or addressable workload"}
Enter fullscreen mode Exit fullscreen mode

Exit condition: if the watcher becomes part of an availability promise, a free server is the wrong instrument. Move it to infrastructure you own.

Leaf D — controlled

Example: redacting customer identifiers from support tickets before anything else touches them. Why it stops here: sensitive input plus contractual review.

{"name":"ticket-redaction","lane":"controlled","reason":"sensitive or contractual: do not route to a shared free tier"}
Enter fullscreen mode Exit fullscreen mode

Exit condition: there is none that a cheaper endpoint can satisfy. This leaf exists to say no.

The artifact: a router you can run in one command

route_job.py has no dependencies and makes no network calls. It encodes the five branches as five early returns, in the order written above.

#!/usr/bin/env python3
"""route_job.py - classify an agent job into a placement lane. Python 3.9+, stdlib only."""
from __future__ import annotations
from dataclasses import dataclass, asdict
from enum import Enum
import json, sys


class Lane(str, Enum):
    LOCAL_ONLY = "local_only"
    FREE_MODEL = "free_model"
    FREE_SERVER = "free_server"
    CONTROLLED = "controlled"


class DataClass(str, Enum):
    PUBLIC = "public"
    INTERNAL = "internal"
    SENSITIVE = "sensitive"


@dataclass(frozen=True)
class JobSpec:
    name: str
    data_class: DataClass
    deterministic: bool
    idempotent: bool
    needs_inbound_endpoint: bool
    wall_clock_minutes: int
    requires_pinned_model: bool
    contractual_review: bool


def route(job: JobSpec):
    if job.deterministic:                                                  # Branch 1
        return Lane.LOCAL_ONLY, "decidable without a model; keep it off the network"
    if job.data_class is DataClass.SENSITIVE or job.contractual_review:    # Branch 2
        return Lane.CONTROLLED, "sensitive or contractual: do not route to a shared free tier"
    if job.requires_pinned_model:                                          # Branch 3
        return Lane.CONTROLLED, "pinned model required; free tiers move underneath you"
    if job.needs_inbound_endpoint or job.wall_clock_minutes > 15:           # Branch 4
        return Lane.FREE_SERVER, "long-lived or addressable workload"
    if not job.idempotent:                                                 # Branch 5
        return Lane.CONTROLLED, "non-idempotent: the cost of a duplicate is unbounded"
    return Lane.FREE_MODEL, "disposable, non-sensitive, single-shot"


def routing_record(job: JobSpec) -> dict:
    lane, reason = route(job)
    record = asdict(job)
    record["data_class"] = job.data_class.value
    record["lane"] = lane.value
    record["reason"] = reason
    return record


if __name__ == "__main__":
    with open(sys.argv[1], encoding="utf-8") as handle:
        jobs = [JobSpec(**{**raw, "data_class": DataClass(raw["data_class"])})
                for raw in json.load(handle)]
    for job in jobs:
        print(json.dumps(routing_record(job), sort_keys=True))
Enter fullscreen mode Exit fullscreen mode

Steps to reproduce:

  1. Save the file, and write a fixture jobs.json containing the four examples from the leaves above.
  2. Run python route_job.py jobs.json | jq -c '{name, lane, reason}'.
  3. Compare the four output lines against the expected records printed in each leaf. A mismatch is a branch-order disagreement, not a bug — decide which order you actually meant.

Expected output:

{"name":"disk-report","lane":"local_only","reason":"decidable without a model; keep it off the network"}
{"name":"changelog-draft","lane":"free_model","reason":"disposable, non-sensitive, single-shot"}
{"name":"repo-webhook","lane":"free_server","reason":"long-lived or addressable workload"}
{"name":"ticket-redaction","lane":"controlled","reason":"sensitive or contractual: do not route to a shared free tier"}
Enter fullscreen mode Exit fullscreen mode

Pin every branch with a test, not a review

A decision tree that lives only in prose drifts. The file below pins the branch order. Run it with python test_route_job.py; the names also work under pytest.

# test_route_job.py
from route_job import JobSpec, DataClass, Lane, route

BASE = dict(deterministic=False, idempotent=True, needs_inbound_endpoint=False,
            wall_clock_minutes=3, requires_pinned_model=False, contractual_review=False)


def make(**overrides):
    return JobSpec(**{**BASE, **overrides})


def test_deterministic_never_leaves_the_machine():
    lane, _ = route(make(name="a", data_class=DataClass.PUBLIC, deterministic=True))
    assert lane is Lane.LOCAL_ONLY


def test_sensitive_beats_convenience():
    lane, reason = route(make(name="b", data_class=DataClass.SENSITIVE))
    assert lane is Lane.CONTROLLED and "sensitive" in reason


def test_pinned_model_beats_free_tier():
    lane, _ = route(make(name="c", data_class=DataClass.PUBLIC, requires_pinned_model=True))
    assert lane is Lane.CONTROLLED


def test_addressable_job_gets_a_server():
    lane, _ = route(make(name="d", data_class=DataClass.PUBLIC, needs_inbound_endpoint=True))
    assert lane is Lane.FREE_SERVER


def test_long_job_gets_a_server():
    lane, _ = route(make(name="e", data_class=DataClass.PUBLIC, wall_clock_minutes=45))
    assert lane is Lane.FREE_SERVER


def test_non_idempotent_never_lands_on_a_shared_model():
    lane, _ = route(make(name="f", data_class=DataClass.PUBLIC, idempotent=False))
    assert lane is Lane.CONTROLLED


def test_disposable_public_single_shot():
    lane, _ = route(make(name="g", data_class=DataClass.PUBLIC))
    assert lane is Lane.FREE_MODEL


if __name__ == "__main__":
    for name, fn in sorted(globals().items()):
        if name.startswith("test_") and callable(fn):
            fn()
            print("ok", name)
Enter fullscreen mode Exit fullscreen mode

Two properties matter more than coverage. First, the assertions use is, so renaming a lane fails loudly instead of quietly changing meaning. Second, the precedence tests — test_sensitive_beats_convenience and test_pinned_model_beats_free_tier — fail the moment somebody reorders the branches. That is the point.

Turn routing records into a table you can argue with

Write one routing record per dispatch and append them to a JSONL file. Then:

jq -r '[.lane, .name] | @tsv' routing.jsonl | sort | uniq -c | sort -rn
Enter fullscreen mode Exit fullscreen mode

Keep the counts next to a small ledger. Fill the cells from your own logs; do not paste numbers you have not observed.

Column Why it is there
job joins the routing record to the unit of work
lane makes drift visible when a job silently changes placement
reason the branch that fired — the only field that survives a rewrite
retries idempotency failures show up here first
quota_observed what the endpoint actually reported, not what the docs said
review_flag who has to look before this job moves again

Limitations, and who should not use this

The tree is a policy encoding, not evidence about any provider. It has not been validated against your workload, and both thresholds — wall_clock_minutes > 15 and the public/internal split — are placeholders to replace with numbers from your own logs.

Do not adopt this approach if:

  • The workload is regulated, or the data class cannot be assigned by a named owner.
  • You need a contractual availability or residency guarantee. A free tier is not that, whatever today's terms say.
  • Your jobs need pinned model revisions. Branch 3 will keep sending them elsewhere, correctly.
  • You cannot write one routing record per dispatch. Without the ledger, the tree is just an opinion.

Also note that a free server is a placement, not an SLA. If the watcher in Leaf C sits on the critical path of a customer promise, label a free host as a prototype and plan the move before the promise is made.

What to do next

Before you move any job to a cheaper lane, run the fixture, pin the branches with the tests, and record the lane you chose. Then revisit the ledger monthly. The interesting column is not retries; it is reason, because that is where a changed assumption becomes visible — and a changed assumption is the only thing that should move a job between leaves.

If you want somewhere to run the free_model and free_server leaves while you build the ledger, MonkeyCode is an open-source project with free model access and a free server option. Start with the repository, run the fixture above against four of your own jobs, and compare your lanes to the expected records before you move anything real.

Top comments (0)