// Offline // Montreal, QC //

tl;dr: We released Milliseconds.ai , a multimodal API for AI decisions, classification and extraction from text and images. It grew out of models we’d built for another product. Here’s how that happened.


Watching TypeSafe AI launch Jev this week, we saw a totally new use case for models we already had.

Jev returns typed decisions: labels, probabilities and source spans that software can use directly. A question like “is this refund request legitimate?” gets a boolean and a score rather than a written explanation.

CloudRaker’s Paperwork API has been using small models for similar work for months: classifying pages, checking statements against contracts, and extracting fields from forms. Those calls take tens of milliseconds. We’d treated the models as part of our document processing service and hadn’t thought much about offering them separately.

The response to TypeSafe’s launch made us reconsider that stragy: developers seemed interested in an API for these small decisions, even without the rest of a document workflow attached.

Paperwork already handled API keys, login, per-organization rate limits and billing. Our first attempt was to expose the decision routes through its existing gateway.

Where the time went

The Paperwork gateway handles two-hundred-page PDFs, signature envelopes that stay open for weeks, and redaction jobs spread across three workers and different GPUs. Each request goes through authentication, fine-grained authorization, tenant context, logging, tracing and metering. That overhead barely matters for a forty-second job. For a model call that takes a few milliseconds, it can dominate the response time.

We benchmarked the decision routes and checked the traces in Dash0 . In the worst cases, the authorization calls, context propagation, logging and network hops took twelve times as long as inference.

sequenceDiagram
    participant C as client
    participant G as gateway
    participant A as auth worker
    participant Z as authz worker
    participant T as tenant / metering
    participant M as model
    C->>G: POST /classify
    G->>A: verify key
    A-->>G: ok
    G->>Z: can this org call this route?
    Z-->>G: ok
    G->>T: load tenant context, open trace
    T-->>G: ok
    G->>M: inference
    M-->>G: label, probability
    G->>T: log, meter
    G-->>C: answer
    Note over G,T: hops, context propagation, logging
    Note over M: ~10 ms

We gave the decision API its own service so we could shorten that path. Building Milliseconds.ai, the API for decision-machine-1, took two days. The models and supporting services already existed; the work was in changing how a request reached them.

The request path

Each request needs authentication, an organization-level usage check, and an available inference slot. Afterwards, we need to record the usage for billing. We put a single Cloudflare Worker in front of the models, with Durable Objects for organization state and inference scheduling.

flowchart LR
    C[client] --> W["api.milliseconds.ai<br/>Worker · Hono"]
    W -. "verdict cache<br/>60 s per colo" .-> W
    W --> NS["namespace DO<br/>one per organization<br/>keys · buckets · ledger"]
    W --> SCH["scheduler DO<br/>one per region<br/>leases · queue · breaker · spot broker"]
    SCH --> VPC[Workers VPC] --> T[Cloudflare Tunnel] --> R["inference runner<br/>spot GPU"]
    W --> D1["D1<br/>hashed API keys"]
    W --> AE["Analytics Engine<br/>one point per request"]
    NS --> M["metering Worker<br/>service binding"] --> SM[Schematic<br/>credits]

The Worker runs Hono . Bindings connect it to D1 for hashed keys, Analytics Engine for request metrics, and our metering service.

Two Durable Objects

The scheduler

A GPU runner has a limited number of concurrent inference slots. We track them in a scheduler Durable Object, one per GPU region, using a location hint to place it near the runners.

The scheduler keeps the slots in memory. It prefers GPU slots and falls back to CPU slots when the GPUs are full. A request takes a lease, POSTs through the tunnel and releases the lease when the answer comes back. Requests wait in a queue when all slots are busy; if none becomes available in time, the client gets a 529 and a retry hint.

If a call fails, we skip its slot for thirty seconds and send the retry to a different GPU host.

The scheduler also manages the spot fleet. It requests more GPUs as slots fill up and releases them when demand drops. That keeps us from paying for a fixed fleet sized for peak traffic.

Its state is temporary: an in-flight lease keeps the object alive, and a cold start rebuilds the pool. We don’t persist the scheduler’s state.

flowchart TD
    REQ[request arrives] --> LEASE{free slot?}
    LEASE -- GPU --> G[lease GPU slot]
    LEASE -- GPUs full --> CPU[lease CPU slot]
    LEASE -- none --> Q[queue]
    Q -- slot frees --> LEASE
    Q -- wait window over --> E["529 + retry hint"]
    G --> CALL[POST through tunnel]
    CPU --> CALL
    CALL -- ok --> REL[release lease · return answer]
    CALL -- fails --> SKIP["skip that slot 30 s<br/>retry on another GPU"] --> LEASE
    LEASE -. "slot pressure" .-> BROKER["spot broker<br/>add GPUs before queue backs up<br/>release when demand drops"]
    BROKER -. "new / dropped slots" .-> LEASE

The namespace

A Milliseconds.ai key looks like sk-ms-{namespace}-{entropy}, where the namespace is the organization id. Including it in the key lets the Worker find the right Durable Object without a database lookup.

Each organization’s namespace object mirrors its keys from the database into local storage and memory. It maintains two token buckets, for requests per minute and input tokens per minute, plus a usage ledger in fifteen-minute buckets.

For an admission check, the Worker sends the token count calculated from the request body. The object returns an admission, rate-limit or billing-block verdict, along with OpenAI-style x-ratelimit-* headers. The Worker caches that verdict, which I’ll get to below.

Usage goes to our metering Worker over a service binding . That Worker deducts credits in Schematic and returns the billing verdict. The namespace object stores it for subsequent admission checks.

flowchart TD
    K["sk-ms-{namespace}-{entropy}"] -- "namespace = org id" --> NS["namespace DO · one per organization<br/>keys · two token buckets · 15 min ledger<br/>all in memory"]
    D1[("D1 · hashed keys")] -. "mirror" .-> NS
    NS -- "yes · rate-limited · billing-blocked<br/>+ x-ratelimit-* headers" --> W[Worker]
    NS -- "ledger · service binding" --> M[metering Worker]
    M -- "burn credits" --> SM[Schematic]
    SM -- "billing verdict" --> M
    M -- "flip one boolean" --> NS

We reused Paperwork’s Schematic setup for plans, credits, entitlements and the customer-facing usage page. Adding Milliseconds.ai meant configuring a credit type and burn rate, then calling it from the metering Worker. Billing took an afternoon, and four hours of that afternoon went into arguing about what a token should cost.

Caching admission checks

A Durable Object call adds a round trip. To avoid one admission check per request, the Worker caches the verdict per key for sixty seconds in the Cache API , at each location that sees the key. The cache holds the key id, any block reason, and the rate-limit headers.

We record usage after sending the response. Combined with the cached verdict, this means a burst can exceed the limit before a block takes effect. We accepted that delay to keep admission checks off most requests’ critical path.

sequenceDiagram
    participant C as client
    participant W as Worker
    participant CA as Cache API (per colo)
    participant NS as namespace DO
    participant M as model
    C->>W: request with key
    W->>CA: verdict for key?
    alt cached (< 60 s)
        CA-->>W: key id · block reason · headers
    else miss
        W->>NS: admit? (token count)
        NS-->>W: verdict + headers
        W->>CA: store 60 s
    end
    W->>M: inference
    M-->>W: answer
    W-->>C: answer + x-ratelimit-*
    W--)NS: account usage (after response)

The tunnel

The GPUs aren’t on Cloudflare. They’re spot instances spread across managed instance groups in several regions.

Each VM runs two containers: the inference runner and cloudflared . We configure the tunnel on Cloudflare’s side, and each VM joins it with a token.

The scheduler reaches the runners with fetch() through a Workers VPC binding. We don’t have to expose a public endpoint for each runner. When a VM gets preempted, its connector drops and the tunnel continues serving through the others.

flowchart LR
    SCH["scheduler DO"] -- "fetch() on binding" --> VPC[Workers VPC] --> TUN[one Cloudflare Tunnel]
    subgraph R1["region A · managed instance group"]
        V1["VM · runner + cloudflared"]
        V2["VM · runner + cloudflared"]
    end
    subgraph R2["region B · managed instance group"]
        V3["VM · runner + cloudflared"]
        V4["VM · runner + cloudflared<br/>(preempted · connector dropped)"]
    end
    TUN --- V1
    TUN --- V2
    TUN --- V3
    TUN -.- V4

Images

Paperwork already used these models to classify scanned pages and extract fields from forms. Milliseconds.ai reuses that image support : calls such as yes-no, classify, rate and extract accept images alongside text and return typed results.

Images go in the request body as base64. We don’t accept image URLs because fetching from another server would add latency outside our control. Three detail tiers resize the longest edge to 512, 768 or 1024 pixels. Each tier has a fixed token cost, so the namespace object can check usage before inference. Image decisions take roughly 45 to 120 ms depending on the tier.

Images pass through the Worker and tunnel to the runner, where they’re processed in memory. We don’t write customer images to disk or include them in logs. That also avoided adding an image store to manage and include in our compliance scope.

What we reused

Milliseconds.ai uses our existing Worker template, configuration conventions, three environments and CI. It publishes an API spec that generates a typed client, gets secrets from our shared vault, and deploys through our release pipeline.

Traces go to Dash0, where we could compare a Milliseconds.ai request with a Paperwork request from the first day. We also reused our access policies for admin surfaces.

Those choices mattered for our SOC 2 Type II controls as well as for development time. Milliseconds.ai uses the deployment accounts, secret store, access policies and logging infrastructure we already operate at CloudRaker. We could build within that setup instead of establishing a separate one for the new product.

What took two days

The title leaves out months of work on Paperwork. We spent two days assembling a product from models and infrastructure we’d already built, with most of the new work going into scheduling, admission checks and the path to the GPU.

I had wondered whether the service templates, generated configuration, secret sync and infrastructure as code were too much ceremony for a small, bootstrapped team. They certainly took longer than setting up any one service by hand. This time, they let us concentrate on the part that needed to change: getting a short inference request through the system without spending most of its time in our gateway.

Milliseconds.ai is live, with a free plan if you’d like to try it. The API docs cover the calls and include examples.


Thanks to the team at CloudRaker for rearranging the week’s work to get this out. To Nina, for being a much better launch video talking head than I am. And to a certain rowing fanatic, who texted me the transfer auth code for milliseconds.ai five minutes after I told him what we were up to.