We built an agent that turns messy RFQ emails into priced quotes, and shipped it on Alibaba Cloud A developer built Distill.ai, an AI agent that turns messy RFQ emails into priced quotes, and deployed it on Alibaba Cloud. The system uses a seven-stage pipeline with models from Alibaba Cloud Model Studio and a 0.70 match threshold to flag uncertain items for human review. The developer noted that 'An agent that says I got 47 of these 50 lines, here are the 3 I could not resolve saves real hours.' Every distributor we spoke to has the same quiet bottleneck, and none of them call it a problem. They call it Tuesday. A request for quote lands in a shared inbox. Sometimes it is a tidy bulleted list. More often it is three lines of text from someone's phone, or a PDF that was scanned at an angle. Someone on the sales desk reads it, works out which catalog part each line actually refers to, checks pricing, and types up a quote. A busy desk does this thirty or forty times a day. It is slow, it is boring, and it is exactly the kind of work where a tired person on a Friday afternoon quotes the wrong bolt and nobody notices until the shipment arrives. We spent three weeks building Distill.ai to do that job. This is what we learned, including the parts that went badly. You paste an email or upload a PDF. From there a seven stage pipeline runs: php parse - extract - classify - match - price - policy - score Parse cleans the document into text. Extract pulls out the individual line items, quantities, and specs. Classify works out what kind of request this is. Match maps each line to a real catalog SKU. Price applies the pricing rules. Policy runs the business checks. Score attaches a confidence value to every match. The interesting part is not the happy path. It is what happens when the model is unsure. Any line that scores below a 0.70 match threshold does not get quoted. It gets flagged with a reason and routed to a human review queue. A person confirms or corrects it, and the quote goes out clean. That one decision is the difference between a demo and something a sales desk would actually put its name on. An agent that is confidently wrong 5% of the time is worse than useless in procurement, because someone has to check all 100% of the output anyway. An agent that says "I got 47 of these 50 lines, here are the 3 I could not resolve" saves real hours. We used two models from Alibaba Cloud Model Studio: Model Studio exposes an OpenAI-compatible endpoint, which mattered more than we expected. Our provider layer is a thin fetch wrapper, and switching models is a config change rather than a rewrite: js const response = await fetch ${env.LLM BASE URL}/chat/completions , { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: Bearer ${env.LLM API KEY} , }, body: JSON.stringify { model: env.LLM MODEL, messages: { role: 'user', content: prompt } , temperature, max tokens: maxTokens, } , } ; LLM BASE URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1 LLM MODEL=qwen-plus EMBEDDINGS MODEL=text-embedding-v4 EMBEDDINGS DIMENSIONS=1024 The embeddings go into Postgres with pgvector, so catalog matching is a similarity search against real SKU rows rather than keyword guessing. "M8 hex bolt, grade 8.8, zinc plated" and "Bolt, hexagon head, M8x50, 8.8, ZP" are not a string match, but they are close neighbours in vector space. One deliberate choice: we deployed in Singapore ap-southeast-1 specifically to sit next to the Model Studio endpoint. On a pipeline that makes several model calls per request, the round trips add up. A NestJS modular monolith in TypeScript, split into two processes: Splitting them matters because parsing a 40 page PDF and calling a model six times is not something you do inside an HTTP request. The API answers immediately and the browser watches progress over Server-Sent Events, so you see each stage light up in real time instead of staring at a spinner. Everything is containerized: api, worker, client React and Vite behind Nginx , Postgres with pgvector, and Redis. The whole thing runs on a single Alibaba Cloud ECS instance via docker-compose, with Caddy in front for TLS. The pipeline was the fun part. Deployment is where we lost days. Our deploy job ran database migrations inside the production container: docker compose exec -T api pnpm migration:run That script resolves to ts-node -r tsconfig-paths/register node modules/typeorm/cli.js migration:run -d src/database/data-source.ts . Now look at the production Dockerfile. The runner stage installs --prod dependencies only and copies dist/ , not src/ . So in the production image there is no ts-node , and there is no TypeScript data source. The command was guaranteed to fail the moment it touched a real server, and it had been sitting in the workflow the whole time because nobody had run a real deploy yet. The fix is to point the TypeORM CLI at the compiled data source, which only needs packages that exist in the runtime image: docker compose exec -T api \ node node modules/typeorm/cli.js migration:run -d dist/database/data-source.js We checked that dist/database/data-source.js resolves entities and migrations through dirname -relative globs, and confirmed no @ -alias requires survive compilation. All 21 migrations then applied cleanly. Lesson: your production image is a different computer. Any command in your deploy pipeline that you have only ever run locally is an untested command. The api and worker containers came up and immediately died, over and over: Invalid environment variables: { SENTRY DSN: 'Invalid url' } Our env schema validates SENTRY DSN as a URL. We were not using Sentry in this deployment, so the value was an empty string, and an empty string is not a valid URL. The variable was optional in spirit but not in schema. Deleting the line entirely fixed it. Optional means absent, not blank. We build images on the instance itself. On a 2 vCPU / 4 GB box, the client build got OOM killed partway through. Adding 4 GB of swap got the builds through. Not elegant, but it was the difference between shipping and not shipping. Our staging and main branches require a pull request and three passing checks, and none of us can self merge. Correct policy. Also completely immovable at 11pm with a deadline coming. So we brought the first release up by hand: git archive , scp to the box, docker compose build , migrate, up -d . Then we wrote the pipeline fix back as a proper reviewed PR, so the next deploy goes through CI like it should. We do not regret the branch protection. Shipping around your own safety rails once, deliberately, and then closing the gap properly afterwards, is very different from not having the rails. We used Caddy with a DuckDNS subdomain. Caddy handles the ACME challenge and certificate renewal on its own, so the entire TLS configuration is this: distill-ai.duckdns.org { reverse proxy client:8080 } Two lines and a real Let's Encrypt certificate. Then we locked SSH to a single operator IP and closed the raw API port, so the only public surface is the HTTPS front door. Deploy on day two, not day nineteen. Every bug above was a deployment bug, not a logic bug. None of them were findable locally. The pipeline worked on our machines the whole time. Confidence scoring is the product, not a feature. We nearly shipped without the review queue. The moment we added it, the whole thing changed from a party trick into something you could show a customer. An OpenAI-compatible endpoint is worth real money in engineering time. We never wrote a Qwen-specific client. We wrote an HTTP client and changed a base URL. "It runs locally" and "it runs in production" are separated by a surprising amount of unglamorous work. Swap space. Empty strings. Missing dev dependencies. None of it is interesting and all of it is required. The deployment is live and open, no login required: https://distill-ai.duckdns.org https://distill-ai.duckdns.org The seeded catalog is zinc-plated fasteners, so write your RFQ around M6, M8, or M10 bolts, nuts, and washers. Here is one to paste: Hi team, please quote the following for our new assembly line: - 1500 x M10 hex bolts, grade 8.8, zinc plated - 1500 x M10 hex nuts, zinc plated - 3000 x M10 flat washers, zinc plated We need delivery within two weeks. Kindly include pricing and lead time. Regards, Sarah Bennett, Procurement, Northgate Industrial Ltd. Watch the trace run, then open the review page. If one of the lines lands in the review queue rather than the quote, that is not a bug. That is the whole point. Built with NestJS, TypeScript, React, PostgreSQL with pgvector, Redis, BullMQ, Docker, and Caddy, running on Alibaba Cloud ECS with Qwen-Plus and text-embedding-v4 via Alibaba Cloud Model Studio.