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", "supports_files": true }
]
}
supports_files says whether the agent's model reads PDFs and images ā the model config's supportsFiles flag, otherwise true for the Anthropic and OpenAI providers (see Agent Type: Model Configuration). A client uses it to refuse a PDF or image before uploading it; text files are always accepted. See Attaching files.
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, whose content may carry files ā see Attaching files ā 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.
Attaching filesā
A user message can carry files next to its text, so a person can ask the agent about a bill of lading, a rate sheet or a damage photo. Upload each file first through the platform's presigned upload flow ā the GraphQL uploadUrl(organizationId, fileName, contentType) query returns a presignedUrl to PUT the bytes to and a fileUrl ā then reference the fileUrl in the message:
{
"model": "TMS_Assistant",
"stream": true,
"input": [
{ "type": "message", "role": "user", "content": [
{ "type": "input_text", "text": "What does the Denver to Phoenix lane cost?" },
{ "type": "input_file", "file_url": "<fileUrl from uploadUrl>", "filename": "rate-sheet.pdf" },
{ "type": "input_image", "image_url": "<fileUrl from uploadUrl>", "filename": "damage.jpg" }
] }
]
}
| Part | Fields |
|---|---|
input_file | file_url ā the uploaded file's URL under this organization's upload area; filename. |
input_image | image_url (same rules as file_url); filename. |
Limits: at most 5 files per message; 25 MB per file; PDF, PNG, JPG/JPEG, TXT, CSV, JSON and MD. A message with files and no text is answered as if it said "See the attached file(s)." Files can't be sent together with approval decisions.
What the agent receives. Each file reaches the model after a label such as [Attached file: rate-sheet.pdf (PDF, 1.2 MB)]:
- PDFs and images: the bytes up to 4 MB, a short-lived presigned URL above that. Only the Anthropic provider reads a PDF by URL, so for other providers a PDF over 4 MB is refused when it is attached.
- Text files: decoded as UTF-8, cut at 200,000 characters with a truncation note, and framed as data rather than instructions.
Files stay part of the conversation, so later turns can still ask about them (older PDFs and images beyond a 16 MB per-call budget go by URL, or as a note asking the user to attach them again). A file that can't be read when a turn runs reaches the model as [File <name> could not be read.] instead of failing the turn.
Where the files go. Once validated, each file becomes an Attachment linked to the conversation's session, stored under a dated folder (attachments/{organizationId}/AgentSession/{yyyy}/{MM}/{dd}/{sessionId}/). A chat file is as private as its chat: the generic attachment queries treat it as not found unless the caller can see the session. The session's files are listed by agentSessionAttachments, and the transcript records each one as an attachment block.
Validation. Every file is checked before a session is created or continued, so a rejected message creates nothing. A failure is 400 invalid_input with a message naming the file:
| Message | Cause |
|---|---|
'<name>' is not a supported file type. Attach a PDF, PNG, JPG, TXT, CSV, JSON or MD file. | Unrecognized extension. |
'<name>' was not uploaded to this organization. | The URL is not under this organization's upload area. |
'<name>' is listed twice. | The same file appears twice in the message. |
'<name>' was not found; upload it again. | The upload is missing or expired. |
'<name>' does not look like a <type> file. | A PDF or image whose stored type contradicts its extension. |
'<name>' is larger than 25 MB. | Over the size limit. |
'<name>' is too large for this model; PDFs up to <N> MB can be attached. | A PDF over 4 MB for a provider other than Anthropic. |
This agent's model can't read PDFs or images. Attach a text file instead. | The model has no file support (supports_files: false). |
A message can carry at most 5 files. | More than 5 file parts. |
Files must be sent with a message, not with approval decisions. | File parts next to mcp_approval_response items. |
If a validated file then can't be attached (moving it or recording it fails), the request fails with 500 attachment_failed ā "Could not attach '<name>'. Upload it again." Because the upload has been moved by then, a retry must upload the file again.
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; or a file failed validation (see Attaching files). |
| 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 | attachment_failed | A validated file could not be attached to the session; upload it again. |
| 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. - Check an agent's
supports_filesand the file limits before uploading, so a person learns a file can't be read before sending the message. - 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).