The turns/v1 contract
The columns a custom agent query returns.
Updated 3 October 2026
Columns
Your query returns one result set with one row per turn. A turn is one question and the agent’s answer to it. All the columns in the table below are columns of that single row.
The agent’s steps, such as the SQL it ran, are not separate tables either. They are JSON arrays in columns of the same row.
A result with two turns looks like this. It shows five columns; a real result has more, up to the full list below.
turn_id | asked_at | question_text | answer_text | statements | …
--------+------------------+---------------------+---------------+-----------------+--
101 | 2026-10-02 12:00 | Orders last week? | 1,204 orders. | [{"sql": "…"}] | …
102 | 2026-10-02 12:05 | How many employees? | I can't tell. | [] | … If your agent stores turns across several tables, as most do, join and aggregate them in the query. The database pages show how for each database.
Column names are not case-sensitive. Only the first four columns are required.
If your agent never records a value, leave the column out. If it records the
value but a turn has none, return NULL, or [] for an array. A missing column
is shown as not available for the agent; a NULL is shown as empty for that
turn.
This matters most for statements. If your agent doesn’t expose its SQL, leave
the column out. If it does, return [] for a turn that ran no SQL.
| Column | Type | Required | Description |
|---|---|---|---|
turn_id | text | Yes | A unique, stable ID for the turn. Don’t change it when you edit the query. |
asked_at | timestamp | Yes | When the question was asked. A timestamp without a time zone is read as UTC. |
question_text | text | Yes | The user’s question. |
answer_text | text | Yes, can be NULL | The answer the user saw. NULL if there was none, for example after an error. |
updated_at | timestamp | No | When the row last changed. See Incremental reads. |
completed_at | timestamp | No | When the answer finished. Used for latency. |
outcome | text | No | How the turn ended. success, ok and completed count as completed; error, failed and failure as failed; running and in_progress as in progress. Other values are kept and shown as unknown. |
error_type | text | No | The error class, if the turn failed. |
error_message | text | No | The error message, if the turn failed. |
conversation_id | text | No | Groups turns into a conversation. Leave it out if your agent has no conversations. |
position | integer | No | The turn’s position in the conversation, starting at 1. Defaults to the order of asked_at. |
user_id | text | No | The ID of the user who asked. |
user_name | text | No | Their display name. |
user_email | text | No | Their email address. |
user_role | text | No | The database or platform role the agent used for them. |
client_interface | text | No | Where the question was asked, such as slack, web or api. |
session_id | text | No | The client session ID. |
model_name | text | No | The model that answered. Needed to price tokens. |
input_tokens | integer | No | Input tokens for the turn. |
output_tokens | integer | No | Output tokens for the turn. |
semantic_model_ref | text | No | The semantic model or layer the agent used. |
statements | JSON array | No | SQL the agent generated or ran. See statements. |
reasoning | JSON array | No | The agent’s reasoning steps. See reasoning. |
messages | JSON array | No | Anything else the agent said, such as follow-up suggestions. See messages. |
tool_calls | JSON array | No | Other tools the agent used. See tool_calls. |
extra | JSON | No | Anything else you want stored. We keep it but don’t use it yet. |
JSON columns
Each of statements, reasoning, messages and tool_calls holds a JSON
array of objects, one object per step, in the order the steps happened. Extra
keys are ignored. Each section lists the keys and ends with a complete example
value for one turn.
statements
| Key | Type | Description |
|---|---|---|
sql | text | Required. The statement. An empty one is ignored, so a turn that wrote no SQL still reads as one. |
status | text | succeeded, failed, not_executed, or your own value. |
error | text | The error message, if it failed. |
rows | integer | Rows returned. |
started_at, ended_at | timestamp | When it started and finished, in ISO 8601. |
is_answer | boolean | Marks the statement the answer is based on. Defaults to the last one that succeeded. |
result | JSON | A small sample of what it returned, in any shape: {"columns": [...], "rows": [[...]]}, [{"count": 1204}] or your own. Kept as given. |
Example, for a turn where the first query failed and the retry answered the question:
[
{
"sql": "SELECT count(*) FROM orders WHERE week = 39",
"status": "failed",
"error": "column week does not exist",
"rows": null,
"started_at": "2026-10-02T12:00:01Z",
"ended_at": "2026-10-02T12:00:02Z",
"is_answer": false
},
{
"sql": "SELECT count(*) FROM orders WHERE order_week = 39",
"status": "succeeded",
"error": null,
"rows": 1,
"started_at": "2026-10-02T12:00:02Z",
"ended_at": "2026-10-02T12:00:03Z",
"is_answer": true,
"result": {"columns": ["count"], "rows": [[1204]]}
}
] reasoning
| Key | Type | Description |
|---|---|---|
text | text | Required. |
label | text | restatement, assumption, data_sourcing, plan, deliberation, or your own value, shown as other. |
Example:
[
{"label": "plan", "text": "Count the orders shipped in ISO week 39."},
{"label": "deliberation", "text": "There is no week column. Use order_week instead."}
] messages
| Key | Type | Description |
|---|---|---|
text | text | Required. |
kind | text | clarifying_question, suggested_questions, visualization, restatement, or your own value, shown as other. |
Example:
[
{"kind": "suggested_questions", "text": "How many of those orders were late?"}
] tool_calls
| Key | Type | Description |
|---|---|---|
name | text | Required. |
input, output | JSON | What the tool received and returned. |
status | text | How the call ended. |
started_at, ended_at | timestamp | When it started and finished, in ISO 8601. |
Example:
[
{
"name": "search_docs",
"input": {"query": "shipping week"},
"output": {"tables": ["orders"]},
"status": "ok",
"started_at": "2026-10-02T12:00:00Z",
"ended_at": "2026-10-02T12:00:01Z"
}
] Incremental reads
Each run reads only the rows that are new or changed since the last run, with a one-hour overlap.
We find them with updated_at, or with asked_at if you don’t return updated_at. If a row can change after it’s first written, for example when
the answer arrives later, return updated_at and update it on every change.
Otherwise, only include a turn once it’s finished.
Rows you delete from your database are not deleted from Benchouse.
Versioning
This is version turns/v1. Adding optional columns doesn’t change the version.
Each stored row records the version and the columns it was read with, so
changing your query doesn’t affect turns already stored.