Voxli Voxli

Running Tests

This guide shows you how to run a complete test suite against your AI agent using Voxli’s REST API.

Complete Example

Here’s a full Python script that executes all tests in a scenario:

"""
Run Voxli tests against your chatbot or AI agent.
1. Create a test run
2. Get all tests for the scenario
3. Simulate each test
"""
import os
import time
import requests
def poll_next_message(endpoint: str, headers: dict, timeout: int = 30) -> dict | None:
"""Poll next-message until the tester is ready or the chat ends.
Returns None when the chat is over, otherwise a dict with either a
`message` (free text) or an `action` (an ActionInvocation) field.
"""
start_time = time.time()
while True:
response = requests.post(endpoint, headers=headers)
response.raise_for_status()
data = response.json()
if data["ready"]:
if data.get("end_chat"):
return None
return {"message": data.get("message"), "action": data.get("action")}
if time.time() - start_time > timeout:
raise TimeoutError("Timed out waiting for message")
time.sleep(1)
# --- Configuration ---
api_key = os.getenv("VOXLI_API_KEY")
base_url = os.getenv("VOXLI_API_URL", "https://api.voxli.io")
scenario_id = os.getenv("VOXLI_SCENARIO_ID")
agent_id = os.getenv("VOXLI_AGENT_ID")
headers = {"Authorization": f"Bearer {api_key}"}
# 1. Create a test run
run = requests.post(f"{base_url}/runs/", headers=headers, json={
"scenario": scenario_id,
"agent": agent_id,
"status": "running"
}).json()
run_id = run["id"]
# 2. Get all tests for this scenario
tests = requests.get(f"{base_url}/scenarios/{scenario_id}/tests", headers=headers).json()["data"]
# 3. Simulate each test
for test in tests:
# 3a. Create a test result entry
result = requests.post(f"{base_url}/test-results/", headers=headers, json={
"test": test["id"],
"run": run_id,
"agent": agent_id
}).json()
result_id = result["id"]
generate_endpoint = f"{base_url}/test-results/{result_id}/next-message"
conversation_endpoint = f"{base_url}/test-results/{result_id}/conversation"
# 3b. Get first turn from Voxli
turn = poll_next_message(generate_endpoint, headers)
# 3c. Conversation loop
while turn is not None:
# TODO: Replace with your agent's response
start = time.monotonic()
if turn.get("action"):
# Tester invoked a registered action instead of typing. Apply
# it in your system, then record the chatbot's follow-up.
agent_response = your_agent.apply_action(
turn["action"]["name"],
turn["action"].get("arguments", {}),
)
else:
agent_response = your_agent.process(turn["message"])
response_time_ms = round((time.monotonic() - start) * 1000)
# 3d. Record agent response (include metadata for performance tracking)
requests.post(
conversation_endpoint,
headers=headers, json={
"type": "message",
"content": agent_response,
"metadata": {
"responseTime": response_time_ms,
"inputTokens": input_tokens,
"outputTokens": output_tokens,
"cost": cost,
}
}
)
# 3e. Get next turn from Voxli
turn = poll_next_message(generate_endpoint, headers)
print(f"Test run {run_id} completed.")

How It Works

1. Create a Test Run: Initialize a new test run for your scenario with status: "running".

2. Fetch Tests: Retrieve all tests associated with the scenario.

3. Execute Each Test:

  • Create a result entry by posting to /test-results/ with the test, run, and agent IDs
  • Start the conversation by calling next-message to get the first tester turn
  • Enter a conversation loop where you relay each turn between Voxli and your agent
  • Continue until Voxli signals end_chat: true

Each next-message response may return ready: false if the next turn is not yet available. Poll the endpoint with a short delay until it returns ready: true.

Each ready turn contains either message (free text from the tester) or action (an invocation of one of the actions your chatbot registered for this turn). Branch on which one is populated - they are mutually exclusive. See Tools, Events, and Actions for how to register actions.

The run is automatically marked as completed once all tests finish.

Message Metadata

When posting agent messages to the conversation endpoint, you can include a metadata object with performance metrics. Voxli reads the following recognized keys:

KeyTypeDescription
responseTimenumberTime in milliseconds for the agent to respond
inputTokensnumberInput/prompt token count for the LLM call
outputTokensnumberOutput/completion token count for the LLM call
costnumberCost of the LLM call in USD
import time
start = time.monotonic()
agent_response = get_agent_response(tester_message) # your agent logic
response_time_ms = round((time.monotonic() - start) * 1000)
requests.post(conversation_endpoint, headers=headers, json={
"type": "message",
"content": agent_response,
"metadata": {
"responseTime": response_time_ms,
"inputTokens": input_tokens,
"outputTokens": output_tokens,
"cost": cost,
}
})

When available, these metrics are displayed as averages in test result details and comparison views. They help identify performance regressions and cost differences across agent configurations.

Attaching Data After the Chat

By default, Voxli starts processing and scoring a result as soon as the conversation ends. If your metrics or tracing data arrive after the chat is over, create the test result with completionMode: "manual" instead. The conversation then ends as usual (end_chat: true), but the result stays in running and waits for you. Attach the remaining data, then finish the result yourself:

"""
Attach data after a chat ends, then finish the result yourself.
1. Create a test result with completionMode "manual"
2. Run the conversation loop until end_chat
3. Attach post-chat data to the conversation
4. Finish the result to start scoring
"""
import os
import time
import requests
api_key = os.getenv("VOXLI_API_KEY")
base_url = os.getenv("VOXLI_API_URL", "https://api.voxli.io")
test_id = os.getenv("VOXLI_TEST_ID")
agent_id = os.getenv("VOXLI_AGENT_ID")
headers = {"Authorization": f"Bearer {api_key}"}
# 1. Create a test result that waits for an explicit finish call.
result = requests.post(f"{base_url}/test-results/", headers=headers, json={
"test": test_id,
"agent": agent_id,
"completionMode": "manual",
}).json()
result_id = result["id"]
conversation_endpoint = f"{base_url}/test-results/{result_id}/conversation"
# 2. Run the conversation loop as usual (see the complete example).
# Every recorded entry gets a server-generated id. Keep the ids you
# care about; they are also returned by GET /test-results/{id}.
reply = requests.post(conversation_endpoint, headers=headers, json={
"type": "message",
"content": agent_response,
}).json()
reply_id = reply["conversation"][-1]["id"]
# ... keep relaying turns until next-message returns end_chat: true ...
# 3a. Merge late data into an earlier message, for example metrics or
# tracing details that your observability stack delivers after the fact.
requests.patch(f"{conversation_endpoint}/{reply_id}", headers=headers, json={
"metadata": {
"responseTime": 840,
"traceId": "trace-8c14",
}
})
# 3b. Insert a tool call at the position where it actually happened.
requests.post(conversation_endpoint, headers=headers, json={
"type": "tool",
"name": "lookup_order",
"metadata": {"order_id": "A-1042"},
"after": reply_id,
})
# 3c. Internal events recorded now are visible to scoring but were never
# part of the chat, so they cannot influence the conversation itself.
requests.post(conversation_endpoint, headers=headers, json={
"type": "internal-event",
"name": "session_summary",
"metadata": {"handoff": False, "sentiment": "positive"},
})
# 4. Finish the result. Scoring runs in the background, so poll the
# result until its status turns "completed".
requests.post(f"{base_url}/test-results/{result_id}/finish", headers=headers)
while True:
status = requests.get(
f"{base_url}/test-results/{result_id}", headers=headers
).json()["status"]
if status in ("completed", "failed", "canceled"):
break
time.sleep(2)
print(f"Test result {result_id} finished with status {status}.")

You can also switch an in-flight result to manual completion with PATCH /test-results/{id} and body {"completionMode": "manual"}. The change must land before the conversation ends.

Message IDs

Every conversation entry carries a server-generated id. You get it back from the conversation endpoint’s response, from GET /test-results/{id}, and, for tester turns, as message_id in the next-message response. Use these ids to address messages in the two operations below. If you need your own correlation keys, put them in the message metadata.

Updating a Message

PATCH /test-results/{id}/conversation/{message_id} merges a metadata object into an existing message. Top-level keys overwrite existing ones; other keys are preserved. The merged metadata must stay under 100KB, and the actions key cannot be changed after recording.

Inserting a Message

The conversation endpoint accepts an optional after or before field with a message id. The new entry is inserted at that position instead of appended, so a tool call or event that surfaced late can sit where it actually happened. The two fields are mutually exclusive, and inserted messages cannot register actions.

Finishing the Result

POST /test-results/{id}/finish starts processing and scoring in the background and returns immediately with status 202. Poll GET /test-results/{id} until the status turns completed. The call is rejected while the conversation is still going (400) and once the result is already completed, failed, or canceled (400).

A few rules to keep in mind:

  • Finish all conversation writes before calling finish. Updating and inserting messages is rejected (409) from that call onwards, because scoring reads the conversation as recorded.
  • Finishing a result that is already being processed is rejected (409), so a retried finish never scores the same conversation twice.
  • Nothing times out. A manual result waits until you finish it, and its run stays running until then. If you abandon a result, cancel it with PATCH /test-results/{id} and body {"status": "canceled"}.

Retrying a Test Result

If a finished test result is unusable — for example, the conversation ended on a fallback handler or hit a transient error — you can retry it in place. A single call clones the result into a new pending attempt on the same run, cancels the original (so it drops out of the run’s score average), re-opens a completed run, and dispatches execution server-side:

new_result = requests.post(
f"{base_url}/test-results/{result_id}/retry",
headers=headers,
).json()
# new_result['id'] is the new PENDING attempt, attached to the same run as
# the original. new_result['metadata']['previous_test_result_id'] points
# back to the original, which is now canceled.

The clone re-runs the identical generated case and inherits the original’s test, run, agent, personality, instruction, and assertion criteria. Retrying is supported for all agent types. The result must be finished (completed, failed, or canceled); retrying a result whose run was manually canceled is rejected.

Execution is dispatched for you per agent type: hosted agents run server-side, GitHub agents trigger their workflow, and Local agents pick up the new attempt the next time their runner connects.

Test your Agent

Tools and Events