Workflow: Agent Responses API
Every active Agent workflow in an organization is reachable over an OpenAI Responses-compatible HTTP API: list the organization's agents as "models", start a conversation, stream the answer, hand over tool approvals, and resume later from a stored response id. A conversation held over this API always runs the agent as a chat session (see Agent Type: Introduction) ā every OpenAI-compatible SDK, and the AI SDK, can point at it directly.
Session history, transcripts, ownership, and live events are administered separately over GraphQL ā see GraphQL: Agent Sessions.
Routesā
| Route | For | Organization comes from |
|---|---|---|
GET /api/organizations/{organizationId}/ai/modelsPOST /api/organizations/{organizationId}/ai/responses | The TMS UI (internal route) | The URL (numeric id) |
GET /public-api/v1/{orgSlug}/ai/modelsPOST /public-api/v1/{orgSlug}/ai/responses | Third-party clients (external route) | The URL (organization slug) |
/public-api/v1/{orgSlug}/ai/v1/models, /public-api/v1/{orgSlug}/ai/v1/responses | Alias for clients that append /v1 to a base URL | Same |
Both routes need Authorization: Bearer <access token> ā the same OpenIddict token used elsewhere in TMS ā and the caller must be a member of the organization (or a system administrator), otherwise 403 organization_access_denied. The two routes behave the same except that the internal route also streams TMS-specific events (response.tms.history_compressed; see Streaming).
Listing agentsā
GET /api/organizations/42/ai/models
Authorization: Bearer <token>
{
"object": "list",
"data": [
{ "id": "Sessions_E2E_Order_Assistant", "object": "model", "created": 1790110000, "owned_by": "tms" }
]
}
Only active Agent workflows are listed. id is the workflow's display name with every character outside [A-Za-z0-9_-] replaced by _ ā get it from this list rather than building it yourself, since collisions get numeric suffixes the same way a tool name does. Use it as model when creating a response.
Creating a responseā
POST /api/organizations/42/ai/responses
Authorization: Bearer <token>
Content-Type: application/json
{
"model": "Sessions_E2E_Order_Assistant",
"input": "Where is order ORD-1?",
"stream": true
}
| Field | Required | Meaning |
|---|---|---|
model | yes | An agent's model id, from Listing agents. |
input | yes | The new turn only ā a string, or an array of items (a message, or approval decisions; see Tool Approval). Never resend the whole history. |
previous_response_id | to continue | The id of the last response in this conversation. Omit it to start a new one. |
stream | no | true for server-sent events (Streaming); default false. |
store | no | Accepted and ignored ā TMS always stores the conversation. |
temperature, max_output_tokens, metadata | no | Accepted and ignored; the agent's YAML decides. |
instructions, tools, tool_choice | ā | Rejected with 400 unsupported_parameter: an agent's instructions and tools come from its workflow YAML, not from the caller. |
Assistant messages and tool outputs in input are refused: tools run on the server, so a client never submits one.
The response objectā
A non-streaming request returns the object directly; a streaming one sends it inside the first and last events.
{
"id": "resp_2f1cā¦",
"object": "response",
"created_at": 1790115018,
"status": "completed",
"model": "Sessions_E2E_Order_Assistant",
"output": [ /* items, in order */ ],
"error": null,
"incomplete_details": null,
"usage": {
"input_tokens": 1834, "input_tokens_details": { "cached_tokens": 0, "cache_write_tokens": 0 },
"output_tokens": 61, "output_tokens_details": { "reasoning_tokens": 0 },
"total_tokens": 1895
},
"previous_response_id": "resp_9a0eā¦",
"store": true,
"metadata": {}
}
status | When | Extra fields |
|---|---|---|
completed | The agent answered, or paused for a tool approval. | ā |
incomplete | Turn budget or time budget ran out. | incomplete_details.reason: turn_budget_exhausted or timed_out. |
failed | Something went wrong server-side. | error.code: server_error; details are logged, not returned. |
usage covers every model call in the response, including any history-compression summary call (see History Compression).
Output itemsā
Assistant text
{
"id": "msg_4b9eā¦", "type": "message", "status": "completed", "role": "assistant",
"content": [ { "type": "output_text", "text": "ORD-1 is delayed at customs.", "annotations": [] } ]
}
A tool the server ran
{
"id": "mcp_call_7d2ā¦", "type": "mcp_call", "status": "completed",
"server_label": "Sessions E2E / Order Assistant",
"name": "Sessions_E2E_Get_Order_Status",
"arguments": "{\"orderNumber\":\"ORD-1\"}",
"output": "\"Order ORD-1 is Delayed, held at customs since 2026-09-14\"",
"error": null
}
status is in_progress while running, then completed or failed (with error set). arguments and output are JSON strings. name is the tool's sanitized workflow name; server_label is the agent's own workflow name. A call a person declined comes back as a failed item with error: "declined by the user".
A tool call waiting for approval ā see Tool Approval.
{
"id": "mcpr_call_abc", "type": "mcp_approval_request",
"server_label": "Sessions E2E / Approval Assistant",
"name": "Sessions_E2E_Cancel_Shipment",
"arguments": "{\"orderNumber\":\"ORD-1\"}"
}
Streamingā
With "stream": true the response is text/event-stream. Every event's data.type equals the event name, and sequence_number increases by one per event:
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_4b9eā¦","output_index":0,"content_index":0,"delta":"ORD-1 is "}
| Phase | Events |
|---|---|
| Start | response.created, response.in_progress |
| Assistant text | response.output_item.added (message) ā response.content_part.added ā response.output_text.delta Ć n ā response.output_text.done ā response.content_part.done ā response.output_item.done |
| Tool call | response.output_item.added (mcp_call) ā response.mcp_call_arguments.done ā response.mcp_call.in_progress ā response.mcp_call.completed or response.mcp_call.failed ā response.output_item.done |
| Approval request | response.output_item.added ā response.output_item.done (both carry the mcp_approval_request item) |
| History compressed (internal route only) | response.tms.history_compressed with { "through_sequence": n } |
| End (exactly one) | response.completed, response.incomplete, or response.failed, carrying the full response object |
- Items are identified by
output_index, which increases in the order items are added; everyoutput_item.addedgets a matchingoutput_item.doneeven when the response fails part-way. - An approval call is never announced as a running tool while streaming ā it appears only as its
mcp_approval_requestitem. response.completed'soutputis the final, authoritative list of items ā reconcile incremental state against it, don't just trust the deltas.response.tms.*events are TMS extensions; ignore any unrecognized event type.
If the connection drops before a terminal event, that response is abandoned ā its id never becomes valid, and the conversation resumes from the last response that completed (see Conversation State).
Conversation stateā
- Continue by sending the last response's
idasprevious_response_id, with only the new turn ininput. - Only the latest response of a conversation can be continued ā continuing an older one is 400
response_superseded. - An abandoned response (client disconnected mid-stream) never becomes a valid
previous_response_id; messages written before the disconnect stay in the audit history. - An ended conversation (its last response was
failedorincomplete) cannot continue: 400conversation_ended. Start a new one. - History is compressed automatically as it grows ā see History Compression. Clients don't need to do anything; the internal route just announces it.
Session statusā
What the session's stored status becomes after each response ā visible over GraphQL, see GraphQL: Agent Sessions:
| Status | Means | Can continue? |
|---|---|---|
Running | A response is being produced now. | ā |
Completed | The last response answered. | yes |
Exhausted | Turn budget ran out. | no |
TimedOut | Time budget ran out. | no |
Failed | The run failed. | no |
Cancelled | The client walked away mid-response; not an error. | yes, from the last completed response |
AwaitingApproval | Paused on tool calls waiting for a person. | yes, with decisions or a message |
Tool Approvalā
A tool listed with mode: approval in the agent's YAML (see Tool Approval) never runs without a person's decision when called in a chat session:
-
The agent pauses. The model calls the approval tool. The response ends
status: completedwith onemcp_approval_requestitem per waiting call ā tools called in the same turn that don't need approval have already run. The session is nowAwaitingApproval. -
The person decides. Continue with
previous_response_idand one decision per request:{"model": "Sessions_E2E_Approval_Assistant","previous_response_id": "resp_ā¦","input": [{ "type": "mcp_approval_response", "approval_request_id": "mcpr_call_abc", "approve": true }]}approve: trueruns the tool; the new response starts with itsmcp_callitem, then the agent continues.approve: false(optionally"reason": "ā¦") does not run the tool; the model is told the person declined (and why) ā afailedmcp_callwitherror: "declined by the user".- A decision and a new user message may be sent together: decisions are applied first, then the message is answered.
-
Or the person just types. A normal message sent while calls are waiting declines all of them ("The user replied without approving.") and answers the message instead.
| Situation | Result |
|---|---|
| Decisions must cover every waiting request, exactly once | Otherwise 400 invalid_approval_response, naming the ids. Nothing changes. |
Decisions with nothing waiting, or without previous_response_id | 400 invalid_approval_response. |
approval_request_id without the mcpr_ prefix, or approve not a boolean | 400 invalid_input. |
| Two requests answer the same pause at once | One proceeds; the other gets 409 conversation_busy. The tool runs once. |
| Connection drops while an approved tool runs | Resend the same decision ā the tool is not run twice; its stored result is reused. |
| Pending requests | Do not expire. |
| Who may decide | Anyone who may see the session ā see Session Ownership. |
| A task session (run from a workflow, no person present) | The call is refused instead of pausing ā see Tool Approval. |
Every decision ā who, when, approved or not, and the reason ā is recorded on the tool result in the transcript. The person who started the session also gets an approval notification.
Errorsā
Every rejection uses the OpenAI error shape:
{ "error": { "message": "The model 'X' does not exist.", "type": "invalid_request_error", "param": "model", "code": "model_not_found" } }
| HTTP | code | Cause |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON. |
| 400 | missing_required_parameter | model missing. |
| 400 | unsupported_parameter | instructions, tools or tool_choice sent. |
| 400 | invalid_input | input missing, empty, or containing an unsupported item. |
| 400 | invalid_approval_response | Decisions don't match the waiting requests ā see Tool Approval. |
| 400 | response_superseded | previous_response_id is not the latest response. |
| 400 | conversation_ended | The conversation ended (failed/exhausted/timed out). |
| 400 | model_not_configured | The organization has no AI model configured for this agent. |
| 403 | organization_access_denied | The caller is not a member of the organization. |
| 404 | model_not_found | No active agent with that model id. |
| 404 | response_not_found | previous_response_id does not exist, or the caller may not see its session. |
| 404 | organization_not_found | External route only: unknown organization slug. |
| 409 | conversation_busy | Another request is already continuing this paused conversation. |
| 500 | server_error | Failure before the stream started; details are logged, not returned. |
Once a stream has started, a failure arrives as a response.failed event instead of an HTTP status.
Using an SDKā
- OpenAI SDK:
new OpenAI({ baseURL: "https://<host>/public-api/v1/<orgSlug>/ai/v1", apiKey: <access token> }), thenclient.responses.create({ model, input, previous_response_id, stream: true }). For the UI's internal route usebaseURL: "https://<host>/api/organizations/<id>/ai"with the token as the bearer. - AI SDK:
@ai-sdk/open-responsescan target the same base URL from a route handler.
Best Practicesā
- Keep only
previous_response_id(the last response'sid) client-side per conversation ā don't reconstruct or resend history. - Render output items by
output_index, and reconcile againstresponse.completed'soutputon the terminal event rather than trusting accumulated deltas. - Collect every pending approval decision and send them in a single request ā a second request against the same pause gets
conversation_busy. - On a dropped stream, don't reuse that response's id: continue from the last known
lastResponseId(see GraphQL: Agent Sessions) and, for a dropped approval, resend the same decision. - Subscribe to
onAgentSessionEventinstead of polling for an approvals inbox, a monitoring view, or a task session's progress (a task session has no HTTP stream of its own to follow).