Skip to main content

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​

RouteForOrganization comes from
GET /api/organizations/{organizationId}/ai/models
POST /api/organizations/{organizationId}/ai/responses
The TMS UI (internal route)The URL (numeric id)
GET /public-api/v1/{orgSlug}/ai/models
POST /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/responsesAlias for clients that append /v1 to a base URLSame

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
}
FieldRequiredMeaning
modelyesAn agent's model id, from Listing agents.
inputyesThe 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_idto continueThe id of the last response in this conversation. Omit it to start a new one.
streamnotrue for server-sent events (Streaming); default false.
storenoAccepted and ignored — TMS always stores the conversation.
temperature, max_output_tokens, metadatanoAccepted 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" }
] }
]
}
PartFields
input_filefile_url — the uploaded file's URL under this organization's upload area; filename.
input_imageimage_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:

MessageCause
'<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": {}
}
statusWhenExtra fields
completedThe agent answered, or paused for a tool approval.—
incompleteTurn budget or time budget ran out.incomplete_details.reason: turn_budget_exhausted or timed_out.
failedSomething 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 "}
PhaseEvents
Startresponse.created, response.in_progress
Assistant textresponse.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 callresponse.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 requestresponse.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; every output_item.added gets a matching output_item.done even 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_request item.
  • response.completed's output is 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 id as previous_response_id, with only the new turn in input.
  • 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 failed or incomplete) cannot continue: 400 conversation_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:

StatusMeansCan continue?
RunningA response is being produced now.—
CompletedThe last response answered.yes
ExhaustedTurn budget ran out.no
TimedOutTime budget ran out.no
FailedThe run failed.no
CancelledThe client walked away mid-response; not an error.yes, from the last completed response
AwaitingApprovalPaused 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:

  1. The agent pauses. The model calls the approval tool. The response ends status: completed with one mcp_approval_request item per waiting call — tools called in the same turn that don't need approval have already run. The session is now AwaitingApproval.

  2. The person decides. Continue with previous_response_id and 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: true runs the tool; the new response starts with its mcp_call item, then the agent continues.
    • approve: false (optionally "reason": "…") does not run the tool; the model is told the person declined (and why) — a failed mcp_call with error: "declined by the user".
    • A decision and a new user message may be sent together: decisions are applied first, then the message is answered.
  3. 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.

SituationResult
Decisions must cover every waiting request, exactly onceOtherwise 400 invalid_approval_response, naming the ids. Nothing changes.
Decisions with nothing waiting, or without previous_response_id400 invalid_approval_response.
approval_request_id without the mcpr_ prefix, or approve not a boolean400 invalid_input.
Two requests answer the same pause at onceOne proceeds; the other gets 409 conversation_busy. The tool runs once.
Connection drops while an approved tool runsResend the same decision — the tool is not run twice; its stored result is reused.
Pending requestsDo not expire.
Who may decideAnyone 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" } }
HTTPcodeCause
400invalid_jsonThe body is not valid JSON.
400missing_required_parametermodel missing.
400unsupported_parameterinstructions, tools or tool_choice sent.
400invalid_inputinput missing, empty, or containing an unsupported item; or a file failed validation (see Attaching files).
400invalid_approval_responseDecisions don't match the waiting requests — see Tool Approval.
400response_supersededprevious_response_id is not the latest response.
400conversation_endedThe conversation ended (failed/exhausted/timed out).
400model_not_configuredThe organization has no AI model configured for this agent.
403organization_access_deniedThe caller is not a member of the organization.
404model_not_foundNo active agent with that model id.
404response_not_foundprevious_response_id does not exist, or the caller may not see its session.
404organization_not_foundExternal route only: unknown organization slug.
409conversation_busyAnother request is already continuing this paused conversation.
500attachment_failedA validated file could not be attached to the session; upload it again.
500server_errorFailure 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> }), then client.responses.create({ model, input, previous_response_id, stream: true }). For the UI's internal route use baseURL: "https://<host>/api/organizations/<id>/ai" with the token as the bearer.
  • AI SDK: @ai-sdk/open-responses can target the same base URL from a route handler.

Best Practices​

  • Keep only previous_response_id (the last response's id) client-side per conversation — don't reconstruct or resend history.
  • Render output items by output_index, and reconcile against response.completed's output on the terminal event rather than trusting accumulated deltas.
  • Check an agent's supports_files and 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 onAgentSessionEvent instead 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).