Skip to content

.NET Functions host returns unparsable text/plain from workflow HTTP endpoints #56

Description

Summary

Two of the .NET Azure Functions host's built-in workflow HTTP endpoints return text/plain where callers need machine-parsable JSON:

  1. The asynchronous workflow-start 202 returns an English sentence with the run ID embedded in it, ignoring the Accept header entirely.
  2. The status and respond endpoints always return JSON on success, but fall back to text/plain on errors when the client sends no Accept header.

Both are pre-existing on main. Neither was introduced by #48, but the Functions-native client API added there makes the start endpoint's response shape more visible.

This was split out of #51, which lists "Plain-text versus JSON asynchronous-start responses" under Additional coherence questions and explicitly scopes it out: "These are broader than casing and should not be changed implicitly, but they affect the scope of a fully coherent public contract." #51 stays a casing/naming issue; this one covers content type and parsability.

1. Asynchronous start returns prose

dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs:91-93, in RunWorkflowOrchestrationHttpTriggerAsync:

HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}");
return response;

POST /api/workflows/{name}/run returns:

Workflow orchestration started for CancelOrder. Orchestration runId: 7f3e2a1b...

The run ID is the only machine-consumable value in the response, and it is the value the caller needs to poll workflows/{name}/status/{runId}. Extracting it requires substring-parsing an English sentence whose wording is not part of any documented contract.

This is the only response in BuiltInFunctions.cs that does not consult AcceptsJson(req). Sending Accept: application/json still returns prose. Nearby code already negotiates correctly:

  • WaitForWorkflowCompletionAsync (line 514) — the wait-for-response branch of the same function
  • CreateAcceptedResponseAsync (line 631) — the agent equivalent, which also surfaces the session ID in a response header

Language parity

The Python host already returns JSON from the equivalent endpoint (python/packages/azurefunctions/agent_framework_azurefunctions/_app.py:515-526):

return func.HttpResponse(
    json.dumps({
        "instanceId": instance_id,
        "statusQueryGetUri": status_url,
        "respondUri": build_workflow_respond_url(...),
        "message": "Workflow started",
    }),
    status_code=202,
    mimetype="application/json",
)

Python's integration tests assert on data["statusQueryGetUri"] and follow it directly. The .NET host offers no equivalent affordance.

2. JSON-only endpoints emit text/plain on errors

CreateErrorResponseAsync (line 569) defaults to text/plain when the request carries no Accept header:

if (acceptsJson ?? AcceptsJson(req))

AcceptsJson requires an explicit Accept header naming application/json; many clients send none. Two endpoints always write JSON on success but route failures through this helper:

Endpoint Success Error (no Accept header)
GET workflows/{name}/status/{runId} JSON (line 126) text/plain (lines 108, 114)
POST workflows/{name}/respond/... JSON (line 200) text/plain (lines 147, 157, 163, 170, 177, 189)

A client calling response.json() succeeds on 200/202 and throws on 400/404. The response shape flips within a single endpoint, and it flips precisely when the caller is trying to determine what went wrong.

RunAgentHttpAsync and WaitForWorkflowCompletionAsync are not affected — they negotiate consistently across success and failure.

Proposed direction

For the asynchronous start response, emit a JSON body carrying at minimum the run ID and the workflow name. Worth deciding as part of this issue:

  • Whether to include a status polling URL (statusQueryGetUri) as Python does. Note the .NET status route is only registered when the workflow opts in via AddWorkflow(enableStatusEndpoint: true), so the field would have to be conditional or the opt-in reconsidered.
  • Whether to switch outright or content-negotiate on Accept, keeping the current string for text clients. Content negotiation matches the agent path and the wait-for-response path, and avoids breaking callers that parse the current sentence today.
  • Field names should be settled against Consider unifying public HTTP API surfaces on camelCase #51 rather than independently, so this does not introduce a third naming convention. Python uses instanceId; .NET's status endpoint uses runId.

For the error responses, either pass acceptsJson: true at the five call sites belonging to JSON-only endpoints, or have those endpoints force JSON since they have no meaningful text representation.

Compatibility

Changing the 202 body is a breaking HTTP contract change for anyone parsing the current string. Content negotiation avoids that; an outright switch does not and would need a breaking-change label plus a CHANGELOG entry.

The error-response fix is lower risk. It only affects requests that send no Accept header, and it moves them from an undocumented plain-text body to the ErrorResponse shape those endpoints already return to JSON clients.

Completion criteria

  • Asynchronous workflow start returns a machine-parsable body, with the compatibility strategy documented.
  • JSON-only endpoints return JSON on both success and failure.
  • Field names agree with the outcome of Consider unifying public HTTP API surfaces on camelCase #51 and with the Python host where the concepts match.
  • Unit tests cover the response shape for both success and error paths, with and without an Accept header.
  • demo.http and the sample READMEs are updated to show the new responses.
  • CHANGELOG updated.

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions