Tools and structured output
Chat completions support two ways to get machine-usable output from a bot: function calling, where the model asks your application to run a function and then uses the result, and structured output, where the model's answer is a JSON object, optionally conforming to a schema you supply. Both follow the OpenAI request and response shapes, so the official SDKs work unchanged.
The two cannot be combined in one request: a JSON response_format with a
non-empty tools array is rejected with 400.
Function calling
Defining tools
tools is an array of function definitions:
{
"tools": [
{
"type": "function",
"function": {
"name": "lookup_hearing",
"description": "Look up the next scheduled hearing for a case number in the court calendar.",
"parameters": {
"type": "object",
"properties": {
"case_number": { "type": "string", "description": "Case number, for example CR-2026-0412." }
},
"required": ["case_number"]
}
}
}
]
}
| Field | Type | Description |
|---|---|---|
type | string | Required. Must be function. Any other value is rejected with 400 and param: "tools[i]". |
function.name | string | Required. The name the model will call: 1 to 64 letters, digits, underscores, or dashes. A tool without a name, or with a name outside that pattern, is rejected with 400 and param: "tools[i]". |
function.description | string | What the function does and when to use it. Default ""; any other non-string value is rejected with 400 and param: "tools[i]". Write it for the model: a clear description is the main thing that determines whether and when the tool is called. |
function.parameters | object | A JSON Schema object describing the arguments. Default {"type": "object", "properties": {}}; any other non-object value is rejected with 400 and param: "tools[i]". |
Only name, description, and parameters are forwarded to the model.
function.strict and any other keys are accepted and dropped. An empty
tools array is the same as omitting the field, everywhere.
tool_choice
| Value | Effect |
|---|---|
"auto" | Default. The model decides whether to call a tool. |
"none" | Tool definitions are withheld from the model; it answers in text. The allowlist check below still runs. |
"required" | The model must call at least one tool. |
{"type": "function", "function": {"name": "lookup_hearing"}} | The model must call the named function. |
Any other string or object, and a named function that is not in tools, is
rejected with 400 and param: "tool_choice". tool_choice sent without
tools is validated the same way (a malformed value is still 400) and a
well-formed value is then ignored. The check runs after the message rules
and the tool definitions and before the allowlist, so a request with both
a malformed tool and a malformed tool_choice reports tools[i].
The tool_calls response
When the model decides to call a function, the response has
finish_reason: "tool_calls", message.content is null, and
message.tool_calls lists the calls. finish_reason is tool_calls
whenever tool_calls is non-empty, so you can branch on either.
{
"id": "chatcmpl-a6d2fb47cda64437b436f627",
"object": "chat.completion",
"created": 1787360473,
"model": "clerk-tools",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"refusal": null,
"tool_calls": [
{
"id": "call_01Hx7Qm2xT9vL4nR8sW1pB6c",
"type": "function",
"function": {
"name": "lookup_hearing",
"arguments": "{\"case_number\": \"CR-2026-0412\"}"
}
}
],
"citations": null
},
"logprobs": null,
"finish_reason": "tool_calls"
}
],
"usage": { "prompt_tokens": 412, "completion_tokens": 38, "total_tokens": 450 }
}
| Field | Type | Description |
|---|---|---|
tool_calls[].id | string | Opaque id for this call. Send it back unchanged as tool_call_id. Do not parse or validate its format. |
tool_calls[].type | string | Always function. |
tool_calls[].function.name | string | The function to run. |
tool_calls[].function.arguments | string | A JSON-encoded string of the arguments. Parse it as JSON; never compare it as a string (whitespace is not canonical). |
The model may also produce text alongside tool calls; content then holds
that text and tool_calls is still present. On a tool-call turn
citations is always null.
Treat the arguments as untrusted input: validate them against your own schema before acting, and if they are unusable, return an error message as the tool result so the model can correct itself.
The tool loop
Function calling is a loop driven by your application:
- Send the user's message with
tools. - If the response has
tool_calls, run each function yourself. - Append the assistant message (with its
tool_calls) and onetoolmessage per call tomessages, then send the request again. - Repeat until the response has no
tool_calls. The finalcontentis the answer.
The tool message is {"role": "tool", "tool_call_id": "<id>", "content": "<result>"}.
tool_call_id is required (and must be non-empty); content is a string
or an array of text parts, or null / "" for an empty result. Return
results as a compact string, typically JSON.
Every tool message must answer a call: its tool_call_id must be one of
the id values in the tool_calls of the nearest preceding assistant
message, and each call may be answered at most once. A tool message whose
id matches none of them, that answers a call a second time, or that has no
assistant tool call before it at all, is rejected with 400,
param: "messages[i].tool_call_id", and the message
tool_call_id does not match a preceding tool call.. Send the tool
messages for one assistant turn directly after it, one per call.
When replaying the assistant message, every tool_calls entry must carry
id, type: "function", function.name, and function.arguments;
an entry missing any of them is rejected with 400 and
param: "messages[i].tool_calls[j]". Send content as null (or "")
when the original turn had no text. arguments must be a JSON-encoded
string; a value that is not valid JSON is forwarded to the model as an empty
object {}.
Retrieval from the knowledge base does not run on a turn whose last message
is a tool result, so the answer that follows a tool call is not grounded:
on a bot that cites, its citations is an empty array. Retrieval runs again
on the next user message.
A complete example
Three requests: the model calls a function, answers from its result, and then handles a follow-up that is grounded in the knowledge base.
Request 1: the question and the tool.
curl -s "https://api.chatbots.ecourtdate.com/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "clerk-tools",
"messages": [
{ "role": "user", "content": "Is case CR-2026-0412 on the calendar this week?" }
],
"tools": [
{
"type": "function",
"function": {
"name": "lookup_hearing",
"description": "Look up the next scheduled hearing for a case number in the court calendar.",
"parameters": {
"type": "object",
"properties": { "case_number": { "type": "string" } },
"required": ["case_number"]
}
}
}
]
}'
The response is the tool_calls object shown above.
Your application runs lookup_hearing("CR-2026-0412") against the court
calendar and gets, say,
{"hearing_type": "pretrial conference", "date": "2026-08-27", "time": "09:30", "courtroom": "4B"}.
Request 2: the same messages plus the assistant turn and the tool
result. Repeating tools is optional; include it if the model may need to
call again.
curl -s "https://api.chatbots.ecourtdate.com/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "clerk-tools",
"messages": [
{ "role": "user", "content": "Is case CR-2026-0412 on the calendar this week?" },
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_01Hx7Qm2xT9vL4nR8sW1pB6c",
"type": "function",
"function": { "name": "lookup_hearing", "arguments": "{\"case_number\": \"CR-2026-0412\"}" }
}
]
},
{
"role": "tool",
"tool_call_id": "call_01Hx7Qm2xT9vL4nR8sW1pB6c",
"content": "{\"hearing_type\": \"pretrial conference\", \"date\": \"2026-08-27\", \"time\": \"09:30\", \"courtroom\": \"4B\"}"
}
]
}'
{
"id": "chatcmpl-3e7b9d0c51f24a8ab6e1d2c4",
"object": "chat.completion",
"created": 1787360496,
"model": "clerk-tools",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Yes. Case CR-2026-0412 has a pretrial conference on Thursday, August 27, 2026 at 9:30 a.m. in Courtroom 4B.",
"refusal": null,
"tool_calls": null,
"citations": []
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 498, "completion_tokens": 41, "total_tokens": 539 }
}
Request 3: a follow-up. The full history is replayed and ends with a new
user message, so retrieval runs again and the answer can cite the
knowledge base.
curl -s "https://api.chatbots.ecourtdate.com/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "clerk-tools",
"messages": [
{ "role": "user", "content": "Is case CR-2026-0412 on the calendar this week?" },
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_01Hx7Qm2xT9vL4nR8sW1pB6c",
"type": "function",
"function": { "name": "lookup_hearing", "arguments": "{\"case_number\": \"CR-2026-0412\"}" }
}
]
},
{
"role": "tool",
"tool_call_id": "call_01Hx7Qm2xT9vL4nR8sW1pB6c",
"content": "{\"hearing_type\": \"pretrial conference\", \"date\": \"2026-08-27\", \"time\": \"09:30\", \"courtroom\": \"4B\"}"
},
{ "role": "assistant", "content": "Yes. Case CR-2026-0412 has a pretrial conference on Thursday, August 27, 2026 at 9:30 a.m. in Courtroom 4B." },
{ "role": "user", "content": "What should I bring to a pretrial conference?" }
],
"tools": [
{
"type": "function",
"function": {
"name": "lookup_hearing",
"description": "Look up the next scheduled hearing for a case number in the court calendar.",
"parameters": {
"type": "object",
"properties": { "case_number": { "type": "string" } },
"required": ["case_number"]
}
}
}
]
}'
{
"id": "chatcmpl-9c2d4e6f8a1b4c3d9e5f7a8b",
"object": "chat.completion",
"created": 1787360512,
"model": "clerk-tools",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Bring a government-issued photo ID, your copy of the charging document, and any discovery you have received. Arrive 15 minutes early to pass through security [Source 1].",
"refusal": null,
"tool_calls": null,
"citations": [
{
"source_index": 1,
"document_id": "4d8a2b6c-1e3f-4a5b-8c7d-9e0f1a2b3c4d",
"source_filename": "pretrial-conference-guide.pdf",
"chunk_index": 1,
"text_preview": "What to bring: a government-issued photo ID, your copy of the charging document, and any discovery materials. Plan to arrive 15 minutes early for security screening.",
"score": 0.95
}
]
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 1104, "completion_tokens": 52, "total_tokens": 1156 }
}
The same loop with the OpenAI Python SDK:
import json
messages = [{"role": "user", "content": "Is case CR-2026-0412 on the calendar this week?"}]
tools = [{"type": "function", "function": {"name": "lookup_hearing", "parameters": {
"type": "object", "properties": {"case_number": {"type": "string"}}, "required": ["case_number"]}}}]
while True:
completion = client.chat.completions.create(model="clerk-tools", messages=messages, tools=tools)
message = completion.choices[0].message
if not message.tool_calls:
print(message.content)
break
messages.append({"role": "assistant", "content": message.content, "tool_calls": [
{"id": c.id, "type": "function", "function": {"name": c.function.name, "arguments": c.function.arguments}}
for c in message.tool_calls]})
for call in message.tool_calls:
args = json.loads(call.function.arguments)
result = lookup_hearing(**args) # your function
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
Streaming a tool-call turn delivers the same information as incremental deltas; see Tool-call deltas.
Per-bot tool allowlists
Each bot has an allowed_tools setting, configured by eCourtDate for your
account, that limits which function names a request may offer:
allowed_tools | Effect |
|---|---|
| Not set | Any tool may be offered. |
| An empty list | No tools may be offered; the bot is text-only. |
| A list of names | Only those function names may be offered. |
Offering a tool the bot does not allow is rejected before the model is
called, with 400 tool_not_allowed. param
is tools[i], the index of the first offending tool in request order. The
check runs even when tool_choice is "none".
{
"error": {
"message": "Tool 'delete_case' is not allowed for this model.",
"type": "invalid_request_error",
"param": "tools[1]",
"code": "tool_not_allowed"
}
}
Use allowlists to keep public-facing bots from being offered tools that were meant for staff integrations.
Prompt-injection guard
When a request offers tools and retrieval returns passages from the knowledge base, the API adds a fixed instruction telling the model to treat retrieved content and tool results as data, never as instructions, and to act only on the user's own requests. It is applied automatically and cannot be removed by the caller.
Structured output
response_format controls the shape of message.content:
response_format | Effect |
|---|---|
{"type": "text"} | Default. Free text. |
{"type": "json_object"} | JSON mode: the model is instructed to respond with a single JSON object and nothing else. No schema is enforced and the API does not validate the output. Mention the word "JSON" and the fields you want in your message. |
{"type": "json_schema", "json_schema": {"name", "schema", "strict"}} | The model's output is constrained to schema (a JSON Schema object). name (a non-empty string) and schema are both required; description and strict are accepted and ignored. |
type is required; a response_format without it, a json_schema without
json_schema.name or json_schema.schema, or an unsupported type is
rejected with 400 and param: "response_format". response_format is validated before the model
is looked up, so an unknown model combined with a bad response_format
reports the response_format error.
JSON schema example
Extract the details of a hearing notice into a fixed shape:
curl -s "https://api.chatbots.ecourtdate.com/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "court-assistant",
"messages": [
{ "role": "user", "content": "Extract the hearing details from this notice: NOTICE OF HEARING. Case CR-2026-0412, State v. Alvarez. A pretrial conference is set for August 27, 2026 at 9:30 AM in Courtroom 4B before Judge Okafor." }
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "hearing_details",
"schema": {
"type": "object",
"properties": {
"case_number": { "type": "string" },
"hearing_type": { "type": "string" },
"hearing_date": { "type": "string", "description": "ISO 8601 date" },
"hearing_time": { "type": "string", "description": "24-hour HH:MM" },
"courtroom": { "type": "string" }
},
"required": ["case_number", "hearing_type", "hearing_date", "hearing_time", "courtroom"]
}
}
}
}'
{
"id": "chatcmpl-5b8e1f2a3c4d4e5f8a9b0c1d",
"object": "chat.completion",
"created": 1787360530,
"model": "court-assistant",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{\"case_number\": \"CR-2026-0412\", \"hearing_type\": \"pretrial conference\", \"hearing_date\": \"2026-08-27\", \"hearing_time\": \"09:30\", \"courtroom\": \"4B\"}",
"refusal": null,
"tool_calls": null,
"citations": []
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 521, "completion_tokens": 47, "total_tokens": 568 }
}
content is the JSON document as a string; parse it with your JSON library.
finish_reason is stop and tool_calls is null. On a bot that cites,
citations is an empty array unless the JSON text itself carries
[Source N] markers. If the model produces no structured result, content
is ""; treat that as a failure and retry.
With the OpenAI Python SDK:
import json
completion = client.chat.completions.create(
model="court-assistant",
messages=[{"role": "user", "content": notice_text}],
response_format={
"type": "json_schema",
"json_schema": {"name": "hearing_details", "schema": HEARING_SCHEMA},
},
)
details = json.loads(completion.choices[0].message.content)
JSON mode example
{
"model": "court-assistant",
"messages": [
{ "role": "system", "content": "Reply with a JSON object with keys \"answer\" (string) and \"confidence\" (\"high\", \"medium\", or \"low\")." },
{ "role": "user", "content": "Can I pay a traffic ticket online?" }
],
"response_format": { "type": "json_object" }
}
Put the shape you want in a system message, as above, and always validate
the parsed object: nothing is enforced server-side in JSON mode.
Structured output and streaming
response_format works with stream: true. The JSON text arrives as
ordinary content deltas, so accumulate them and parse only after the finish
chunk.
Errors
| Status | code | When |
|---|---|---|
400 | null | A tool whose type is not function, that has no function.name, whose name is outside the allowed pattern, whose function.description is not a string, or whose function.parameters is not an object (param: "tools[i]"); an invalid tool_choice (param: "tool_choice"); a tool message without tool_call_id, or whose tool_call_id does not match an unanswered call in the nearest preceding assistant message, including a tool message with no assistant tool call before it (param: "messages[i].tool_call_id"); a malformed replayed tool_calls entry (param: "messages[i].tool_calls[j]"); an assistant message with neither text nor tool calls (param: "messages[i].content"); a conversation ending with an assistant message (param: "messages"); response_format without type, with an unsupported type, json_schema without name or schema, or JSON mode combined with tools (param: "response_format"). |
400 | tool_not_allowed | A tool is not on the bot's allowlist; param: "tools[i]". |
401 | invalid_api_key | Missing or invalid key. |
403 | insufficient_scope | The key lacks the chat scope. |
404 | model_not_found | Unknown or inaccessible model. |
413 | request_too_large | Body over 5 MiB. |
429 | rate_limit_exceeded, insufficient_quota | Rate limit or daily quota reached; honor Retry-After. |
500 | null | type: server_error. Unexpected failure; retry with backoff. |
503 | upstream_unavailable | The underlying language model is unavailable; retry after Retry-After. |
Check param to locate the offending field; message text may change. All
errors use the envelope described in Errors.