Rig
rig is an open-source Rust framework for LLM applications: agents, tools, typed extractors, and vector stores behind one provider-agnostic interface. Doubleword ships as a first-class rig provider, and also works through our OpenAI-compatible API, so a Rust service can call open-weights models with a thin client.
Support triage is a natural fit for this stack. A backlog of tickets needs classifying and routing, and most of that work can run when nobody is waiting on it. On a frontier realtime endpoint you pay a latency premium per ticket and risk rate limits when you parallelise. With Doubleword's async (flex) tier, you can run the same sweep at a lower rate with queuing built in, and fire the whole backlog at once. We build a pipeline that classifies each ticket into a Rust enum, drafts a reply, and escalates only the urgent ones to a larger model, so you can avoid paying frontier prices for "please add dark mode".
Based on a real-world benchmark, here is the projected cost to clear a 10,000-ticket backlog:
| Model | Execution tier | Est. cost per 10k tickets | Multiplier |
|---|---|---|---|
| Qwen3.6-35B-A3B on Doubleword | Async (flex) | $23.42 | 1.0x |
| Qwen3.6-35B-A3B on Doubleword | Realtime | $31.20 | 1.3x |
| Claude Haiku 4.5 (Anthropic)* | Realtime | $157.36 | 6.7x |
| GPT-5.6 Luna (OpenAI)* | Realtime | $187.92 | 8.0x |
(The two frontier models are comparable to Qwen3.6-35B-A3B on Artificial Analysis. *Realtime API rates.)
Setup
You need Rust 1.85 or newer and a Doubleword API key. Sign in to the Doubleword Console, and create a key under API Keys. Be sure to copy it, since it is only shown once.
Next, set it in your shell:
export DOUBLEWORD_API_KEY="your-doubleword-key"The provider ships in rig 0.41.0. Until that release lands on crates.io, depend on the branch:
[dependencies]
rig = { git = "https://github.com/0xPlaygrounds/rig", branch = "main" }
anyhow = "1"
futures = "0.3"
schemars = "1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }rig's OpenAI-compatible client defaults to the Responses API, which is where Doubleword exposes the
async tier. Point it at the base URL and set service_tier per request:
use rig::providers::openai;
const BASE_URL: &str = "https://api.doubleword.ai/v1";
fn doubleword_client() -> anyhow::Result<openai::Client> {
let api_key = std::env::var("DOUBLEWORD_API_KEY")?;
Ok(openai::Client::builder().api_key(&api_key).base_url(BASE_URL).build()?)
}
/// Send this request to the async tier instead of realtime.
fn flex() -> serde_json::Value {
serde_json::json!({ "service_tier": "flex" })
}flex() goes into .additional_params(...) on any agent or extractor. Drop it and the same code runs
on realtime.
1. Describe the triage as a Rust type
The routing decision is a type, not a string. Derive JsonSchema and rig turns it into the tool
schema the model must fill in, so you match on Category::Billing, not on the model replying with the
word "billing". The doc comments become field descriptions that steer the model.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize, JsonSchema)]
enum Category { Billing, Technical, Account, FeatureRequest }
#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize, JsonSchema)]
enum Severity { Low, Medium, High }
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct TicketTriage {
/// The team that should own this ticket.
category: Category,
/// How urgently a human must intervene.
severity: Severity,
/// One short sentence explaining the decision.
rationale: String,
}2. Triage the backlog concurrently
Build one extractor and reuse it. extract_with_usage returns the parsed struct plus the token counts
the cost table is built from. The tickets are independent, so join_all runs them together and the
wall clock is the slowest ticket rather than the sum.
let triage = client
.extractor::<TicketTriage>("Qwen/Qwen3.6-35B-A3B-FP8")
.preamble(TRIAGE_PREAMBLE)
.additional_params(flex())
.max_tokens(8192)
.tool_choice(ToolChoice::Required)
.retries(2)
.build();
let triaged = join_all(BACKLOG.iter().map(|ticket| {
let triage = &triage;
async move { (ticket, triage.extract_with_usage(ticket.body).await) }
}))
.await;Triaging 6 tickets on Doubleword flex (Qwen/Qwen3.6-35B-A3B-FP8)...
[TKT-101] Billing / High — Duplicate charge on an invoice, requesting a refund.
[TKT-103] FeatureRequest / Low — Requesting a dark mode theme to reduce eye strain.
... (4 more)
Triaged 6 tickets in 366.6s — 2726 in / 18338 out tokensEvery ticket comes back as a Category and Severity you can match on. Flex queues work, so a sweep
takes minutes rather than seconds, a fair trade when nothing downstream is blocked.
3. Route by severity and escalate only what matters
Routing is a plain match over the enum. High severity goes to a larger model; the rest stay on a
small one. specialist_preamble returns a different prompt per Category, so billing tickets get a
billing voice and feature requests get a product one.
let drafts = join_all(routed.iter().map(|(ticket, t)| {
let client = &client;
async move {
let model = if t.severity == Severity::High {
"Qwen/Qwen3.5-397B-A17B-FP8"
} else {
"Qwen/Qwen3.5-9B"
};
let agent = client
.agent(model)
.preamble(specialist_preamble(t.category))
.additional_params(flex())
.build();
(ticket, model, agent.prompt(ticket.body).await)
}
}))
.await;Routing: 3 escalated to Qwen/Qwen3.5-397B-A17B-FP8, 3 handled by Qwen/Qwen3.5-9B
[TKT-103] via Qwen/Qwen3.5-9B
Thanks for asking us to add dark mode. We have logged it on the product roadmap for review.
Drafted replies in 192.8s — total run 559.5sThe saving comes from the tickets that stay on the small model.
Picking the extractor model
An extractor is a forced tool call, and tool_choice: Required is a request and not a guarantee. Smaller
models tend to answer a question-shaped ticket as prose and return an empty extraction, so use a model
that reliably holds the tool call for triage. In testing Qwen3.6-35B-A3B and larger did. Qwen3.5-9B did
not, so keep the small models for free-text drafting.
Next steps
Now you have a triage pipeline that classifies each ticket into a typed enum, drafts a reply, and
escalates only the urgent ones, all on Doubleword's flex tier. From here you can use rig's built-in
doubleword provider (doubleword::Client::from_env()?) for realtime work a user waits on, drop the
sweep to the more cost effective 24h batch tier when it can wait overnight, or add rig's vector stores
to pull past resolved tickets in as context.
Grab a Doubleword API key, browse the model list and pricing, and visit rig.rs to explore the full framework.