Traffic
Send conversations
Send your agent's real conversations to have each one judged like a simulated one. Issues, signals, experiments and cost all come from them.
#Set up with your coding agent
The agent's Connect panel opens on Coding agent: a setup prompt (version 2026-11-01.2) with your agent's uid filled in, to paste into Claude Code, Cursor or Codex in your agent's repository with DUET_API_KEY exported. The coding agent detects the framework (OpenAI Agents SDK, Vercel AI SDK, LangChain or LangGraph, LiveKit Agents, raw OpenAI or Anthropic clients, existing OpenTelemetry, OpenInference or OpenLLMetry tracing; Vapi and Retell need only their webhook), adds recording with the config snapshot, variables, tool durations where the code runs the tools and the end signal, sends a check conversation and confirms it arrived through the REST API.
The prompt never lets the key into the repository: the code reads it from the environment, and the coding agent stops and asks when it is not set. It refuses to start on uncommitted changes without asking, adds recording only, and commits nothing. It installs nothing: on every code path it writes one small module on the language's own HTTP client against the ingest contract and calls it where that framework keeps the conversation. It never installs duetmachines or @duetmachines/sdk from a registry, where those names are not ours. In the app the prompt and its one-liner point at the deployment you opened them on.
Duet Machines setup prompt 2026-11-01.2
Instrument this repository's conversational AI agent so every conversation it has is sent to Duet Machines (https://platform.duetmachines.com), which judges each one and proves failures with quotes. The Duet Machines agent uid is <agent uid>.
Ground rules
- Add recording only: do not change what the agent says or does. A Duet Machines failure must never raise into the agent; catch it, log it, continue. When DUET_API_KEY is not set at runtime, recording is skipped with one logged warning; the agent still starts.
- Keep the diff minimal: one small module plus the calls that feed it. Do not commit; I review the diff.
- Secrets: the code reads the API key at runtime from the environment variable DUET_API_KEY. Never write the key, or any other secret, into a file of this repository (code, .env, tests, docs, fixtures) or a commit message. You may add an empty DUET_API_KEY= line to an existing .env.example. If DUET_API_KEY is not set in your shell, stop and ask me to export it (the agent's Connect panel in Duet Machines creates one and shows it once).
- First run git status. If the working tree has uncommitted changes, list them and ask me before editing.
1. Detect the stack. Read the dependency files and the code that calls the model, tell me what you found, and follow exactly one path:
A. OpenAI Agents SDK (openai-agents; from agents import ...).
B. Vercel AI SDK (the ai package; generateText or streamText).
C. LangChain or LangGraph.
D. LiveKit Agents (livekit-agents).
E. Raw OpenAI or Anthropic clients, or any other code that keeps a message list.
F. Code that already exports OpenTelemetry spans in the GenAI conventions, OpenInference (Arize, Phoenix) or OpenLLMetry (Traceloop).
G. Vapi or Retell: no code. Tell me to set the webhook from the agent's Connect panel (Vapi https://platform.duetmachines.com/api/integrations/vapi/<agent uid>, Retell https://platform.duetmachines.com/api/integrations/retell/<agent uid>) and stop.
2. Paths A to E: write one small module. There is nothing to install: Duet Machines' SDKs are not published to PyPI or npm, and those registry names are not Duet Machines', so never run pip install duetmachines or npm install @duetmachines/sdk. With the language's standard HTTP client, the module POSTs the whole conversation so far as JSON to https://platform.duetmachines.com/api/ingest/conversations with the header Authorization: Bearer <DUET_API_KEY from the environment> and a 10-second timeout, in the background or after the reply is sent. Body: {"agent": "<agent uid>", "external_id", "user_id", "agent_version", "started_at" (ISO 8601 with offset), "turns": [{"role": "customer" | "agent", "text", "at", "usage"} or {"role": "tool", "text": "", "at", "duration_seconds", "tool": {"name", "arguments": {...}, "status": "executed" | "failed" | "blocked", "result": <string>}}], "ended", "config", "kind"}. Duet Machines keeps the latest post per external_id; 202 means stored, and any other status is logged with its body.
3. Call it after every turn, from where the path's conversation lives.
A. OpenAI Agents SDK: after each Runner.run, map result.to_input_list() (user and assistant messages, function calls with their outputs) to turns, with the agent's instructions and function tools as the config and the app's conversation or thread id as external_id. Time a tool call inside the function tool if the code can; the run's items carry no timing.
B. Vercel AI SDK: after each generateText, or once a streamText's response has resolved, map [...messages, ...result.response.messages] to turns, with the system prompt and each tool's name, description and JSON schema as the config; in a serverless handler, await the post before returning. Messages carry no tool timing.
C. LangChain or LangGraph: after each turn, rebuild the conversation from the graph's message state: customer for each HumanMessage, agent for each AIMessage's text, a tool turn for each ToolMessage with its AIMessage tool call. Time tool calls where the code runs them.
D. LiveKit Agents: before session.start(), subscribe to the session's conversation_item_added (user text a customer turn, assistant text an agent turn), function_tools_executed (each call and output a tool turn, duration_seconds from output.created_at minus call.created_at, status failed when output.is_error) and close (ended: true) events; https://platform.duetmachines.com/docs/platforms#livekit shows the event shapes.
E. Raw OpenAI or Anthropic clients, or any other message list: wherever the code appends the model's reply to its list.
F. Point the exporter at Duet Machines: OTEL_EXPORTER_OTLP_ENDPOINT=https://platform.duetmachines.com/api/otlp, OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf and OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer%20$DUET_API_KEY,x-duet-agent=<agent uid>", set in the deployment, never in a committed file; record message content. Group each conversation with gen_ai.conversation.id (OpenInference: session.id) and set duet.conversation.ended=true on its last span. The attributes read are at https://platform.duetmachines.com/docs/opentelemetry.
What every path sends:
- external_id: the app's own conversation id, the same on every turn.
- user_id: a pseudonymous id, never a name, email or phone number.
- agent_version: the release, when the app has a version or git sha setting.
- config: {"instructions": the system prompt as written, "tools": the tool definitions the model gets (OpenAI or Anthropic format; [] for none), "variables": {...}, "model": the model id the code calls with that prompt}. Model checks and reproductions replay your logged prompt and tools on that model, and a new model there records a switch. If the prompt is filled with per-conversation data, send the template with each value as a {{name}} placeholder and the values in variables, never the filled text.
- Each tool call: its name, arguments, status, result and, where your code runs the tool, duration_seconds measured around it.
- usage on each agent turn: {"input_tokens", "output_tokens", "model", and "cached_input_tokens" when the response reports cache reads (OpenAI usage.prompt_tokens_details.cached_tokens; Anthropic: input_tokens + cache_read_input_tokens + cache_creation_input_tokens as input_tokens, cache_read_input_tokens as cached_input_tokens)} exactly as the model call's response reports them. Cost per conversation and spend by model are priced from it.
- If this repository also serves Duet Machines' endpoint contract (a POST handler taking conversation_id, variables and messages), honor its optional "model": when present, run that turn on the named model instead of the configured one and return "usage" whose "model" is that exact requested id; if the code cannot run it, return {"model_unsupported": "<why>"} alone.
- Only what the code knows: never invent or derive a value to fill a field (no hand-written durations, no user id hashed from a name, no made-up version). Leave a field out when the app has no value for it, and say so in your summary.
- ended: true when the conversation is over; otherwise Duet Machines ends it after 30 minutes without a new turn.
4. Send a check. Run one conversation through the code you added with kind "check" (the module's "kind": "check"; OpenTelemetry duet.conversation.kind=check) and the id setup-check-<8 random hex characters>. Prefer running the agent itself on one customer message; if it needs credentials that are not set, call your module directly with a short hand-written exchange that includes a tool call if the agent has tools. Never contact real customers or real external systems. A check is listed in Duet Machines but never judged or counted.
5. Confirm it arrived: curl -s -H "Authorization: Bearer $DUET_API_KEY" "https://platform.duetmachines.com/api/agents/<agent uid>/production-conversations?kind=check&q=<your check id>", then GET https://platform.duetmachines.com/api/production-conversations/<its uid> with the same header. Report the turns, whether the config arrived (instructions, tool count, variables), which tool calls carry duration_seconds, and anything missing with the reason. If nothing arrived within 30 seconds, show me the logged error.
6. Finish. List the files you changed, how to run them, what I must set where the agent is deployed (DUET_API_KEY), and what was not recorded and why. Then tell me the next step: real conversations go without kind and each is judged when it ends, within the judged conversations the workspace's plan allows a month; https://platform.duetmachines.com/agents/<agent uid>/traffic shows them as they arrive, and https://platform.duetmachines.com/agents/<agent uid>/tests runs a stress test before launch.
The same prompt as plain text, to start from a terminal with nothing to install (/setup-prompt/<agent uid> serves it):
claude "$(curl -fsSL https://platform.duetmachines.com/setup-prompt/<agent uid>)" codex "$(curl -fsSL https://platform.duetmachines.com/setup-prompt/<agent uid>)"
While the panel is open it waits for the check, then shows what arrived: the turns, the config snapshot with its tool and variable counts, how many tool calls were timed and whether the conversation ended, each missing piece with what it costs. The next step follows: a stress test with the simulated conversations it reserves when Duet Machines can run the agent; for a production-only agent, hosting the instructions and tools the check sent on a model you pick (tool calls answered by a simulated world, your own code out of the loop), then the stress test. Real conversations, sent without kind, are judged as they end and the first issue the judge proves waits in Review.
#Ways in
One HTTP request per conversation update, the Python or TypeScript SDK, OpenTelemetry traces, or a Vapi webhook, all with a workspace API key in DUET_API_KEY (Settings > API keys, or the Connect panel); a Retell webhook is signed with your Retell key instead (see Vapi, Retell, LiveKit and Braintrust). The agent's Traffic shows each path with your agent's uid filled in, a button that creates an API key for the environment the header shows, a test conversation to send and Check it arrived, which confirms Duet Machines received it.
The test conversation is sent with kind check: Duet Machines lists a connection check but never judges or counts it. Real conversations leave kind out or send conversation; over OpenTelemetry the span attribute duet.conversation.kind carries it.
#One request
Each request carries one conversation, the whole of it so far: POST /api/ingest/conversations answers 202 with {"conversation": "<uid>"}. Duet Machines keeps one conversation per agent and external_id, so send it again after every turn and the turns you send replace the ones it had.
curl -X POST https://platform.duetmachines.com/api/ingest/conversations \
-H "Authorization: Bearer $DUET_API_KEY" -H "Content-Type: application/json" \
-d '{
"agent": "<agent uid>",
"external_id": "conv_8f2a",
"kind": "conversation",
"user_id": "user_193",
"agent_version": "a1b2c3d",
"started_at": "2026-10-11T14:02:11Z",
"turns": [
{"role": "customer", "text": "Can I move my payment to Friday?", "at": "2026-10-11T14:02:11Z"},
{"role": "tool", "text": "", "at": "2026-10-11T14:02:12Z",
"tool": {"name": "get_account", "arguments": {"id": "193"}, "status": "executed", "result": "{\"due\": \"2026-10-14\"}"}},
{"role": "agent", "text": "Yes, I can move it to Friday the 16th.", "at": "2026-10-11T14:02:13Z"}
],
"ended": false,
"metadata": {"channel": "web"},
"config": {
"instructions": "You are Riley, a billing assistant. Never move a due date more than 7 days.",
"tools": [{"name": "get_account", "description": "Look up an account.",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}}}]
}
}'turnscustomer,agent,tool(withtool: name, arguments, statusexecuted,blockedorfailed, and result) andfailurefor a turn of the agent that failed and sent the user nothing.atis optional.ended- Send
truewhen the conversation ends, so it is judged now (the SDKs'.end()). One that gets no new turns for 30 minutes counts as ended, and new turns open it again. config- Optional and recommended: what the agent ran with,
{"instructions": "...", "tools": [...], "variables": {...}}, tools as OpenAI or Anthropic definitions. Send the instructions as written, with the conversation's lead data as{{name}}placeholders and their values invariables(each one the customer's identity, replaced with a synthetic value before a reproduction plans on it, unlessnon_identifyinglists its name, e.g.["plan"]), so a rule that names the customer stays one rule across conversations and a reproduction of it builds a test that fills the live prompt's placeholders.modelnames the model it ran on, as your code calls it: reproductions and model checks host the snapshot on it, and a model the agent never ran before records a switch. Turnusageonly prices calls. Findings may only cite rules the agent had in that conversation; the template is stored once and each conversation keeps its own variables. Without it, the conversation is judged against the agent's live version, which its findings say. - Optional
user_id(a pseudonymous id),agent_version(a release label such as a git sha),environment(see Environments),metadata(string values only),kind(conversation, the default, orcheckfor a connection check) andsignals(see Signals).- Errors
- 401 for a missing or revoked key, 404 for an agent outside the key's workspace, 413 over 1 MB, 422 for an invalid body, 429 with
Retry-Afterpast 1200 ingest requests a minute per workspace (conversations, outcomes, OTLP and platform webhooks together; a batch counts once).
In Python, with only the standard library:
import json, os, urllib.request
def send_to_duet(conversation: dict) -> None:
"""Send the whole conversation so far; Duet Machines replaces what it had under the same external_id."""
request = urllib.request.Request(
"https://platform.duetmachines.com/api/ingest/conversations",
data=json.dumps(conversation).encode(),
headers={"Authorization": f"Bearer {os.environ['DUET_API_KEY']}", "Content-Type": "application/json"},
)
urllib.request.urlopen(request, timeout=10)
# After every turn, and once more with "ended": True when the conversation ends.
send_to_duet({
"agent": "<agent uid>",
"external_id": conversation_id,
"user_id": user_id,
"agent_version": os.environ.get("GIT_SHA"),
"started_at": started_at.isoformat(),
"turns": [{"role": "customer" if m["role"] == "user" else "agent", "text": m["content"]} for m in messages],
"ended": False,
# What the agent ran with, so findings cite only rules it had; the same config is stored once.
"config": {"instructions": SYSTEM_PROMPT, "tools": TOOLS},
})#Import past conversations
Send up to 100 conversations per request to the batch endpoint (10 MB). Each conversation is stored or refused on its own, with the status the single endpoint would answer. To upload a file of past conversations from the agent's Traffic instead, see Import files.
# Up to 100 conversations per request, each answered on its own:
curl -X POST https://platform.duetmachines.com/api/ingest/conversations:batch \
-H "Authorization: Bearer $DUET_API_KEY" -H "Content-Type: application/json" \
-d '{"conversations": [{"agent": "<agent uid>", "external_id": "conv_8f2a", ...}, ...]}'
# [{"status": 202, "conversation": "<uid>", "error": null},
# {"status": 422, "conversation": null, "error": "started_at: Field required"}]#Import LangSmith runs
Post runs as LangSmith exports them (Client.list_runs, or a run export's JSON) to POST /api/ingest/langsmith with {"agent": "<agent uid>", "runs": [...]} (10 MB). Only llm runs carry a conversation; other run types are counted under ignored and never read. As over OpenTelemetry, a thread's conversation is the input and output of its llm run that ended last, stored ended; each run that cannot be read is refused on its own with the reason, never guessed.
- Thread
extra.metadatasession_id,thread_idorconversation_id(LangSmith's thread keys) is theexternal_id; a run naming two different threads is refused; without one, itstrace_id.- Messages
inputs.messagesas one list of LangChain serialized messages (HumanMessage,AIMessagewithtool_calls,SystemMessage,ToolMessagewithtool_call_id, and their chunks), or as OpenAI chat messages (role,content,tool_calls,tool_call_id). Text content blocks are read; other blocks are not. System messages are the instructions.- Reply
outputs.generations[0][0].message(LangChain) oroutputs.choices[0].message(OpenAI).- Config
- With system messages and recorded tools (
extra.invocation_params.toolsfor LangChain,inputs.toolsfor OpenAI;[]is no tools), the instructions and tools; otherwise no snapshot. - Other fields
extra.metadata.user_idis theuser_id,revision_idthe release; the earliest llm run'sstart_timestarts the conversation (a time without offset is UTC). Tool runs, token usage and timings are not read.- Answer
{"conversations": {"<external_id>": "<uid>"}, "rejected": [{"run", "reason"}], "ignored": {"chain": 3}}; 404 for an agent outside the key's workspace.
#Cost
On an agent or tool turn, send usage ({"input_tokens": 812, "output_tokens": 64, "model": "gpt-5.4-mini"}, the turn's model call) and/or cost_usd (what you measured, which wins). Add cached_input_tokens (of input_tokens, those read from the provider's prompt cache) when the response reports it: they are priced at the model's cached rate; without it every input token is priced at the full rate. Duet Machines prices usage from its list-price table; a model not in it leaves the cost unknown, never estimated. A conversation costs the sum of its turns that report either, and is unknown when none does or one cannot be priced. Totals say how many conversations were priced.
#Agent self-reports
Your agent can tell Duet Machines about its own trouble while it talks: it could not do what was asked (cannot_do), a tool failed it (tool_failed), or it is unsure of its answer (unsure). Add the report_trouble tool to your agent and answer its calls with reported; nothing reaches the customer.
{
"type": "function",
"function": {
"name": "report_trouble",
"description": "Report trouble you are having in this conversation, when it happens: you cannot do what the customer asked, a tool failed you, or you are unsure of your answer. The customer never sees it.",
"parameters": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["cannot_do", "tool_failed", "unsure"]},
"detail": {"type": "string", "description": "What happened, in one sentence."}
},
"required": ["kind", "detail"],
"additionalProperties": false
},
"strict": true
}
}Every path reads the call the same way: a tool turn named report_trouble in JSON ingest, the batch endpoint and the SDKs, or a report_trouble tool call answered by a later tool_call_response over OpenTelemetry. Its arguments must be exactly kind and a non-empty detail; anything else is refused.
from duetmachines import REPORT_TROUBLE_TOOL # TypeScript: import { REPORT_TROUBLE_TOOL } from "@duetmachines/sdk"
tools = [*TOOLS, REPORT_TROUBLE_TOOL] # what the agent may call; answer report_trouble with "reported"
# When the agent calls it, record the call like any tool call, or with the helper:
conversation.report_trouble("tool_failed", "The payment service timed out twice.") # reportTrouble(kind, detail)A self-report is a signal, never a verdict. The conversation shows it as self-reported and the agent's Traffic health counts each kind over 14 days, with how many of those conversations a grader failed. The judge reads self-reports as context and never cites one as evidence. Self-reported conversations are judged first when judging waits for the allowance.
#Judging and retention
Every ended conversation is judged once, and again only when its turns change, oldest first and 20 at a time per agent. Judging counts against the judged conversations your plan allows per month (the starter plan's from sign-up); when they are used, judging stops until the 1st and the agent's Traffic marks what waits and why. Nothing is sampled. See Plans and allowances.
The Conversations view of the agent's Traffic filters by text, status, release, findings, judging and self-report, and Filter narrows further: a tool call (name, status executed, blocked or failed), a signal hit (the current version or one you pick), an issue proven, a metadata key=value (keys and values suggested from your conversations), a user, a started range, a duration range (seconds from the first to the last timed turn) or a turn range. Filters live in the URL, so a filtered list is a link; saved filters and exports read the same filter.
Duet Machines keeps no user traits and needs no personal data: send a pseudonymous user_id, never a name, email or phone number.
Transcripts number turns from #1, and so do ?turn=n links; the REST API's turn fields are 0-based indexes into turns. Production conversations stay in your workspace, are never used to plan stress tests, and are deleted after the workspace's retention (90 days after their last turn by default). With a member's translation preference on, a non-English conversation is shown in English; what is stored and judged stays the original. Every value is redacted before it is stored.