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" }
]
}

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, 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.

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.
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.
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.
  • 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).