{"slug": "we-built-an-agent-that-turns-messy-rfq-emails-into-priced-quotes-and-shipped-it", "title": "We built an agent that turns messy RFQ emails into priced quotes, and shipped it on Alibaba Cloud", "summary": "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.'", "body_md": "Every distributor we spoke to has the same quiet bottleneck, and none of them call it a problem. They call it Tuesday.\n\nA 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.\n\nIt 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.\n\nWe spent three weeks building **Distill.ai** to do that job. This is what we learned, including the parts that went badly.\n\nYou paste an email or upload a PDF. From there a seven stage pipeline runs:\n\n``` php\nparse -> extract -> classify -> match -> price -> policy -> score\n```\n\nParse 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.\n\nThe interesting part is not the happy path. It is what happens when the model is unsure.\n\nAny 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.\n\nThat 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.\n\nWe used two models from Alibaba Cloud Model Studio:\n\nModel Studio exposes an OpenAI-compatible endpoint, which mattered more than we expected. Our provider layer is a thin `fetch`\n\nwrapper, and switching models is a config change rather than a rewrite:\n\n``` js\nconst response = await fetch(`${env.LLM_BASE_URL}/chat/completions`, {\n  method: 'POST',\n  headers: {\n    'Content-Type': 'application/json',\n    Authorization: `Bearer ${env.LLM_API_KEY}`,\n  },\n  body: JSON.stringify({\n    model: env.LLM_MODEL,\n    messages: [{ role: 'user', content: prompt }],\n    temperature,\n    max_tokens: maxTokens,\n  }),\n});\nLLM_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1\nLLM_MODEL=qwen-plus\nEMBEDDINGS_MODEL=text-embedding-v4\nEMBEDDINGS_DIMENSIONS=1024\n```\n\nThe 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.\n\nOne 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.\n\nA NestJS modular monolith in TypeScript, split into two processes:\n\nSplitting 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.\n\nEverything 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.\n\nThe pipeline was the fun part. Deployment is where we lost days.\n\nOur deploy job ran database migrations inside the production container:\n\n```\ndocker compose exec -T api pnpm migration:run\n```\n\nThat script resolves to `ts-node -r tsconfig-paths/register node_modules/typeorm/cli.js migration:run -d src/database/data-source.ts`\n\n.\n\nNow look at the production Dockerfile. The runner stage installs `--prod`\n\ndependencies only and copies `dist/`\n\n, not `src/`\n\n. So in the production image there is no `ts-node`\n\n, 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.\n\nThe fix is to point the TypeORM CLI at the compiled data source, which only needs packages that exist in the runtime image:\n\n```\ndocker compose exec -T api \\\n  node node_modules/typeorm/cli.js migration:run -d dist/database/data-source.js\n```\n\nWe checked that `dist/database/data-source.js`\n\nresolves entities and migrations through `__dirname`\n\n-relative globs, and confirmed no `@`\n\n-alias requires survive compilation. All 21 migrations then applied cleanly.\n\n**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.\n\nThe api and worker containers came up and immediately died, over and over:\n\n```\nInvalid environment variables: { SENTRY_DSN: [ 'Invalid url' ] }\n```\n\nOur env schema validates `SENTRY_DSN`\n\nas 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.\n\nDeleting the line entirely fixed it. Optional means absent, not blank.\n\nWe 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.\n\nOur `staging`\n\nand `main`\n\nbranches 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.\n\nSo we brought the first release up by hand: `git archive`\n\n, scp to the box, `docker compose build`\n\n, migrate, `up -d`\n\n. Then we wrote the pipeline fix back as a proper reviewed PR, so the next deploy goes through CI like it should.\n\nWe 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.\n\nWe used Caddy with a DuckDNS subdomain. Caddy handles the ACME challenge and certificate renewal on its own, so the entire TLS configuration is this:\n\n```\ndistill-ai.duckdns.org {\n  reverse_proxy client:8080\n}\n```\n\nTwo 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.\n\n**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.\n\n**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.\n\n**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.\n\n**\"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.\n\nThe deployment is live and open, no login required:\n\n[https://distill-ai.duckdns.org](https://distill-ai.duckdns.org)\n\nThe seeded catalog is zinc-plated fasteners, so write your RFQ around M6, M8, or M10 bolts, nuts, and washers. Here is one to paste:\n\n```\nHi team, please quote the following for our new assembly line:\n- 1500 x M10 hex bolts, grade 8.8, zinc plated\n- 1500 x M10 hex nuts, zinc plated\n- 3000 x M10 flat washers, zinc plated\n\nWe need delivery within two weeks. Kindly include pricing and lead time.\nRegards, Sarah Bennett, Procurement, Northgate Industrial Ltd.\n```\n\nWatch 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.\n\nBuilt 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.", "url": "https://wpnews.pro/news/we-built-an-agent-that-turns-messy-rfq-emails-into-priced-quotes-and-shipped-it", "canonical_source": "https://dev.to/johnughiovhe/we-built-an-agent-that-turns-messy-rfq-emails-into-priced-quotes-and-shipped-it-on-alibaba-cloud-2oip", "published_at": "2026-07-20 18:40:59+00:00", "updated_at": "2026-07-20 19:06:27.225305+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-products", "ai-agents", "ai-infrastructure", "developer-tools"], "entities": ["Distill.ai", "Alibaba Cloud", "Alibaba Cloud Model Studio", "Postgres", "pgvector", "NestJS", "Redis", "Caddy"], "alternates": {"html": "https://wpnews.pro/news/we-built-an-agent-that-turns-messy-rfq-emails-into-priced-quotes-and-shipped-it", "markdown": "https://wpnews.pro/news/we-built-an-agent-that-turns-messy-rfq-emails-into-priced-quotes-and-shipped-it.md", "text": "https://wpnews.pro/news/we-built-an-agent-that-turns-messy-rfq-emails-into-priced-quotes-and-shipped-it.txt", "jsonld": "https://wpnews.pro/news/we-built-an-agent-that-turns-messy-rfq-emails-into-priced-quotes-and-shipped-it.jsonld"}}