Responses
POST /v1/responses is one model call with tools you define and run yourself. The
model sees your tools; when it wants one, the call comes back to you unexecuted. You
run it in your own system, against your own data and with your own permissions, send
the result back, and call again.
Nothing is stored between calls: every request carries the whole conversation, and each one is a single model step, answered synchronously.
| You want | Use |
|---|---|
| A model that acts on your data: searches your listings, reads your orders, checks your stock | Responses |
| JSON in a shape you define, pulled out of free text | Responses with responseFormat |
| The platform’s own tools (OCR, speech, web search) run for you behind one call | Agents |
| A chat with conversations stored for you and replies streamed token by token | Chat |
Authorization
Call it with an API key that carries the responses scope, or from a dashboard
session. Add the scope to a key on the API keys page; you can change an existing key’s
scopes without issuing a new one. A key without it gets 403 SCOPE_FORBIDDEN.
The key needs nothing else. Your tools run on your side, so they need no scope here.
The tool loop
/v1/responses API key - Send the conversation and your tools.
- If
finishReasonistool-calls, run each call inoutput.toolCallsyourself. - Append the model’s turn (an
assistantmessage carrying thosetoolCalls, unchanged) and onetoolmessage per call with your result. - Call again with the same
tools. Repeat untilfinishReasonisstop.
The loop is yours, and so is its length: decide how many steps you allow before you give up. Each step is a separate call, billed on its own.
Step 1: call with your tools
curl https://ai-api.vanguardinitiative.com/v1/responses \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "fast",
"system": "Use search_gigs to find freelancers. Answer in the language you are asked in.",
"messages": [
{ "role": "user", "content": "Find me a logo designer in Vientiane" }
],
"tools": [{
"name": "search_gigs",
"description": "Search gigs by keyword, optionally in one city.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"city": { "type": "string" }
},
"required": ["query"]
}
}],
"endUserId": "user-42"
}'const API = 'https://ai-api.vanguardinitiative.com/v1/responses';
const SYSTEM = 'Use search_gigs to find freelancers. Answer in the language you are asked in.';
const tools = [
{
name: 'search_gigs',
description: 'Search gigs by keyword, optionally in one city.',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' }, city: { type: 'string' } },
required: ['query'],
},
},
];
const messages: object[] = [
{ role: 'user', content: 'Find me a logo designer in Vientiane' },
];
// One step: the model, your system prompt, the whole history, your tools.
function respond() {
return fetch(API, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'fast',
system: SYSTEM,
messages,
tools,
endUserId: 'user-42',
}),
}).then((r) => r.json());
}
const first = await respond();
first.finishReason; // 'tool-calls'{
"status": "success",
"id": "0b5e9c1a-...",
"model": "fast",
"output": {
"text": "",
"toolCalls": [
{
"id": "call_242746",
"name": "search_gigs",
"input": { "query": "logo design", "city": "Vientiane" },
"signature": "CiQB..."
}
]
},
"finishReason": "tool-calls",
"usage": { "inputTokens": 107, "outputTokens": 128 },
"credits": 1,
"latencyMs": 2522
}Step 2: run the tool yourself
The API does not run search_gigs, and cannot: all it knows is the description and
schema you sent. input was written by a model, so treat it like any other untrusted
input. Check it before you use it, and run the tool with the end user’s permissions,
not your own.
Step 3: send the result back
curl https://ai-api.vanguardinitiative.com/v1/responses \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "fast",
"system": "Use search_gigs to find freelancers. Answer in the language you are asked in.",
"messages": [
{ "role": "user", "content": "Find me a logo designer in Vientiane" },
{ "role": "assistant", "toolCalls": [{
"id": "call_242746",
"name": "search_gigs",
"input": { "query": "logo design", "city": "Vientiane" },
"signature": "CiQB..."
}] },
{ "role": "tool", "toolCallId": "call_242746", "name": "search_gigs",
"output": "[{\"title\":\"Logo design\",\"seller\":\"Noy\",\"priceLak\":250000}]" }
],
"tools": [{
"name": "search_gigs",
"description": "Search gigs by keyword, optionally in one city.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"city": { "type": "string" }
},
"required": ["query"]
}
}],
"endUserId": "user-42"
}'// 1. The model's turn, exactly as it came back: ids, inputs, signatures.
messages.push({ role: 'assistant', toolCalls: first.output.toolCalls });
// 2. One tool message per call. output is a string: serialize your result.
for (const call of first.output.toolCalls) {
const rows = await searchGigs(call.input.query, call.input.city); // your code
messages.push({
role: 'tool',
toolCallId: call.id,
name: call.name,
output: JSON.stringify(rows),
});
}
// 3. Call again. respond() sends the same tools, and it has to: every tool
// named in the history must be declared.
const second = await respond();
second.finishReason; // 'stop'
second.output.text; // 'Noy in Vientiane does professional logo design, ...'{
"status": "success",
"id": "7c41d2e8-...",
"model": "fast",
"output": {
"text": "Noy in Vientiane does professional logo design, from 250,000 kip.",
"toolCalls": []
},
"finishReason": "stop",
"usage": { "inputTokens": 236, "outputTokens": 38 },
"credits": 1,
"latencyMs": 1480
}The whole loop in TypeScript
// API, SYSTEM and tools as in step 1.
type ToolCall = {
id: string;
name: string;
input: Record<string, unknown>;
signature?: string;
};
type Message =
| { role: 'user'; content: string }
| { role: 'assistant'; content?: string; toolCalls?: ToolCall[] }
| { role: 'tool'; toolCallId: string; name: string; output: string };
type ResponsesResult = {
status: 'success';
id: string;
model: string;
output: { text: string; toolCalls: ToolCall[]; object?: unknown };
finishReason: 'stop' | 'tool-calls' | 'length' | 'content-filter';
usage: { inputTokens: number; outputTokens: number };
credits: number;
latencyMs: number;
};
// Your tools, run by your code. The API only ever tells you which one to call.
async function runTool(call: ToolCall): Promise<unknown> {
switch (call.name) {
case 'search_gigs': {
const { query, city } = call.input as { query: string; city?: string };
return searchGigs(query, city);
}
default:
return { error: 'Unknown tool: ' + call.name };
}
}
async function respond(messages: Message[]): Promise<ResponsesResult> {
const res = await fetch(API, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.VANGUARD_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'fast', system: SYSTEM, messages, tools }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.code + ': ' + body.message);
return body;
}
export async function ask(question: string, maxSteps = 5): Promise<string> {
const messages: Message[] = [{ role: 'user', content: question }];
for (let step = 0; step < maxSteps; step++) {
const r = await respond(messages);
if (r.finishReason !== 'tool-calls') return r.output.text;
// Echo the calls back as they came, signature included.
messages.push({
role: 'assistant',
content: r.output.text || undefined,
toolCalls: r.output.toolCalls,
});
// Answer every call before calling again. A tool that throws still gets an
// answer, so the model can see what went wrong and try something else.
for (const call of r.output.toolCalls) {
const result = await runTool(call).catch((e) => ({ error: String(e) }));
messages.push({
role: 'tool',
toolCallId: call.id,
name: call.name,
output: JSON.stringify(result),
});
}
}
throw new Error('Still calling tools after ' + maxSteps + ' steps');
}Rules the history must follow
Each of these is a 400 INVALID_REQUEST whose message names the message at fault. They
are checked before anything is charged.
- Every
toolmessage answers a call from the immediately preceding assistant message, matching itstoolCallIdandname, exactly once. - Every call is answered before the next
userorassistantmessage, and before the history ends. - Every tool named anywhere in the history is declared in
tools. Send the full tool list on every step of the loop. - Tool call ids are unique across the conversation.
- An
assistantmessage hascontent,toolCalls, or both. Ausermessage’scontentis not blank.
Request
JSON body
model * string | A model id from GET /v1/chat/models. When you send tools, pick one whose supportsTools is true. |
messages * array | The whole conversation so far, 1 to 80 messages. Nothing is stored between calls. Shapes below. |
system string | Up to 20,000 characters. Sent exactly as written; no platform prompt is added. |
tools array | Up to 32 tools the model may ask for. You run them. Shape below. |
toolChoice string or object | "auto" (the default), "none", "required", or { "name": "search_gigs" } to force one tool. |
responseFormat object | { "type": "json_schema", "name": "...", "schema": { ... } }. The answer comes back parsed in output.object. Cannot be combined with tools. |
maxOutputTokens integer | 1 to 4,096, default 1,024. Reasoning models count their thinking against it. |
temperature number | 0 to 1. Ignored by models that do not take one. |
endUserId string | Your own id for the person behind the request, up to 200 characters. Accepted and ignored. Safe to keep sending. |
metadata object | { "feature": "..." }, up to 64 characters. Accepted and validated; not used for anything yet. |
Messages
role | Fields | Notes |
|---|---|---|
user | content | Text. Not blank. |
assistant | content, toolCalls | Both optional, but not both empty. Each tool call is { id, name, input, signature }, copied from a response; signature only when the response had one. |
tool | toolCallId, name, output | Your result for one call. output is a string: serialize objects with JSON.stringify. |
Tools
| Field | Rules |
|---|---|
name | Letters, digits and underscores, 1 to 64 characters. Unique within tools. |
description | Up to 1,000 characters. It is what the model reads when deciding whether to call the tool, so say when to use it, not only what it is. |
inputSchema | A JSON Schema with "type": "object" at the root, up to 8 KB serialized. properties, if present, is an object; required is an array of property names. |
Response
| Field | Meaning |
|---|---|
id | A fresh id for this call. Nothing is stored under it. |
model | The model that answered. |
output.text | The answer. Empty when the model only called tools. |
output.toolCalls | The calls for you to run, each { id, name, input, signature }. Empty unless finishReason is tool-calls. |
output.object | Only with responseFormat, and only when finishReason is stop: the answer, parsed. A truncated answer has none. |
finishReason | stop, tool-calls, length (it hit maxOutputTokens) or content-filter (the provider withheld the answer). |
usage | inputTokens and outputTokens, as the provider counted them. |
credits | What this call cost. |
latencyMs | How long the call took on our side. |
Structured output
Add responseFormat and the model answers in JSON that follows your schema. The parsed
value is in output.object, and the raw JSON text in output.text.
curl https://ai-api.vanguardinitiative.com/v1/responses \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "smart",
"messages": [{
"role": "user",
"content": "Lao-English logo for my cafe in Luang Prabang, ~300,000 kip, in 2 weeks"
}],
"responseFormat": {
"type": "json_schema",
"name": "job_brief",
"schema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["design", "writing", "translation", "development", "other"]
},
"city": { "type": "string" },
"budgetLak": { "type": "integer" },
"deadlineDays": { "type": "integer" }
},
"required": ["category", "city", "budgetLak", "deadlineDays"]
}
}
}'const res = await fetch('https://ai-api.vanguardinitiative.com/v1/responses', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'smart',
messages: [{ role: 'user', content: jobPostText }],
responseFormat: {
type: 'json_schema',
name: 'job_brief',
schema: {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['design', 'writing', 'translation', 'development', 'other'],
},
city: { type: 'string' },
budgetLak: { type: 'integer' },
deadlineDays: { type: 'integer' },
},
required: ['category', 'city', 'budgetLak', 'deadlineDays'],
},
},
}),
}).then((r) => r.json());
// Only a finished answer carries an object; a truncated one does not.
const brief = res.finishReason === 'stop' ? res.output.object : null;{
"status": "success",
"id": "e2a7f310-...",
"model": "smart",
"output": {
"text": "{\"category\":\"design\",\"city\":\"Luang Prabang\",\"budgetLak\":300000,\"deadlineDays\":14}",
"toolCalls": [],
"object": {
"category": "design",
"city": "Luang Prabang",
"budgetLak": 300000,
"deadlineDays": 14
}
},
"finishReason": "stop",
"usage": { "inputTokens": 142, "outputTokens": 61 },
"credits": 1,
"latencyMs": 1910
}- It works the same on both models. Strict mode is off, so an ordinary schema is
accepted as written; you do not need
additionalProperties: falseon every object. - The schema steers the model; it is not a guarantee. Validate
output.objectbefore you store it. - An answer that does not parse as JSON is a
502 GENERATION_FAILED, refunded. responseFormat.nameis letters, digits,_and-, 1 to 64 characters. The schema follows the same rules as a tool’sinputSchema: an object at the root, up to 8 KB.
Pricing
A call is billed as the model turn it is, at the same rates as Chat and Agents:
- the model’s
creditsPerMessage, reserved before the call, plus round(outputTokens / 1000) × creditsPer1kOutput, settled after it.
Both rates are listed on each model in GET /v1/chat/models. Tools cost nothing here,
because they run on your side. On a model priced at 1 credit per message and 1 credit
per 1,000 output tokens, a call that writes 2,400 output tokens costs 1 + 2 = 3 credits.
A 502 or 504 is refunded in full. An answer that stops at length or content-filter is charged: the model did the work. In your usage, the spend appears
under chat: and the model’s id, next to a zero-credit responses:call row for each request, so you can count calls.
Limits
| Limit | Value |
|---|---|
| Messages per request | 1 to 80 |
| Tools per request | Up to 32 |
system | 20,000 characters |
Tool description | 1,000 characters |
inputSchema, responseFormat.schema | 8 KB serialized, an object at the root |
maxOutputTokens | 1 to 4,096, default 1,024 |
| Request body | 400,000 characters |
| Time per call | 25 seconds, then 504 TIMEOUT and a refund |
The whole history travels on every call, so a long loop grows towards the body limit. Drop the oldest turns and keep tool outputs short: return the fields the model needs, not whole database rows. Your plan’s per-minute rate limit applies as it does everywhere else.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | INVALID_REQUEST | The body is not JSON or breaks the shape above; a history rule is broken; toolChoice names a tool that is not in tools, or forces one when there are none; responseFormat is sent with tools |
| 400 | INVALID_MODEL | An unknown or unavailable model, or tools sent to a model without tool support |
| 400 | INVALID_TOOL_SCHEMA | An inputSchema is not an object schema or is over 8 KB, or a tool name appears twice |
| 402 | QUOTA_EXCEEDED | Not enough credits |
| 403 | SCOPE_FORBIDDEN | The key does not carry responses |
| 403 | SERVICE_DISABLED | Responses are switched off for now |
| 403 | VERIFICATION_REQUIRED | Your organization has to complete identity verification first |
| 413 | PAYLOAD_TOO_LARGE | The body is over 400,000 characters. Trim the oldest messages or shorten tool outputs |
| 429 | RATE_LIMITED | Too many requests this minute |
| 429 | USAGE_LIMIT_REACHED | Your daily or monthly credit cap is reached |
| 502 | GENERATION_FAILED | The model failed, returned only a tool call that could not be parsed, or gave a responseFormat answer that is not JSON. Refunded |
| 504 | TIMEOUT | No answer within 25 seconds. Refunded |
See Errors for the shared envelope.