AG-UI
SharpOMatic has optional AG-UI support for clients that want to start or resume conversation workflows over a single SSE endpoint.
Install the package
dotnet add package SharpOMatic.AGUI
Register the endpoint
Register the package in your ASP.NET Core host and choose the route you want to expose:
builder.Services.AddSharpOMaticAgUi();
By default that adds:
- AG-UI endpoint:
/sharpomatic/api/agui - DemoServer endpoint:
https://localhost:9001/sharpomatic/api/agui
If you want a different base path, share the same base path variable with the editor, transfer, and AG-UI registration:
var sharpOMaticBasePath = "/example/path";
builder.Services.AddSharpOMaticEditor(sharpOMaticBasePath);
builder.Services.AddSharpOMaticTransfer(sharpOMaticBasePath);
builder.Services.AddSharpOMaticAgUi(sharpOMaticBasePath);
app.MapSharpOMaticEditor(sharpOMaticBasePath);
MapSharpOMaticEditor automatically adds /editor to the base path.
If you also want to override the AG-UI child path, pass a second argument:
builder.Services.AddSharpOMaticAgUi(sharpOMaticBasePath, "/myChatBot");
Request contract
SharpOMatic uses the AG-UI request to identify both the target workflow and the target conversation:
threadIdis required.threadIdbecomes the SharpOMaticconversationId.forwardedPropsmust specify exactly one ofworkflowIdorworkflowName.- If
workflowNameis used, it must match exactly one workflow. Zero matches and multiple matches are both errors.
The recommended selector shape is forwardedProps.sharpomatic:
{
"threadId": "support-chat-001",
"runId": "run-001",
"messages": [
{
"id": "msg-1",
"role": "user",
"content": "Summarize the latest customer issue."
}
],
"state": {
"profile": {
"name": "Sharpy Demo",
"tier": "test"
},
"tags": ["one", "two", "three"]
},
"context": [],
"forwardedProps": {
"sharpomatic": {
"workflowId": "11230021-5144-471a-8ec7-9b460354b745"
}
}
}
You can also use a workflow name:
{
"threadId": "support-chat-001",
"forwardedProps": {
"sharpomatic": {
"workflowName": "Support Chat"
}
}
}
For compatibility, SharpOMatic also accepts workflowId or workflowName directly under forwardedProps, but the nested sharpomatic object is the preferred convention.
Conversation behavior
AG-UI threadId is the real SharpOMatic conversationId.
That means:
- the first request for a
(workflow, threadId)pair starts a new conversation - the same
threadIdon later requests continues a completed conversation as a new turn - the same
threadIdon later requests resumes a suspended conversation from its suspend point
Because conversation identifiers are strings, your AG-UI client can use any stable identifier that fits your application.
Workflow context values
The AG-UI controller does not dump the entire request into workflow context.
Instead it maps a focused subset into agent:
agent.latestUserMessage: the final item inmessages, but only when that item is a user text messageagent.latestToolResult: the final item inmessages, but only when that item is a tool result message. Itscontentstays as the original string, and if that string is non-empty JSON then SharpOMatic also stores the parsed payload inagent.latestToolResult.value.agent.messages: the full incomingmessagesarrayagent.state: the incoming AG-UIstateagent.context: the incoming AG-UIcontext
These values are preserved as structured JSON-compatible data.
For example, agent.state remains an object or array tree inside SharpOMatic context rather than becoming one large JSON string.
On each AG-UI start or resume, SharpOMatic only updates agent.
If agent already exists in workflow context, the incoming AG-UI agent object replaces it entirely.
SSE behavior
The endpoint streams AG-UI SSE events from SharpOMatic workflow stream events:
RUN_STARTEDis emitted after the workflow turn starts- text stream events become
TEXT_MESSAGE_START,TEXT_MESSAGE_CONTENT, andTEXT_MESSAGE_END - step stream events become
STEP_STARTEDandSTEP_FINISHED - visible reasoning stream events become
REASONING_START,REASONING_MESSAGE_START,REASONING_MESSAGE_CONTENT,REASONING_MESSAGE_END, andREASONING_END - tool-call stream events become
TOOL_CALL_START,TOOL_CALL_ARGS,TOOL_CALL_END, andTOOL_CALL_RESULT - activity stream events become
ACTIVITY_SNAPSHOTandACTIVITY_DELTA TOOL_CALL_RESULTpreserves both the tool resultmessageIdand the linkedtoolCallIdTOOL_CALL_STARTincludes the tool name and can includeparentMessageIdwhen the underlying model output supplies it- reasoning events use AG-UI-specific
messageIdvalues prefixed withreason:so they cannot collide with assistant text messages when a provider reuses one underlying response id for both - tool result messages use AG-UI-specific
messageIdvalues prefixed withtool:so tool messages also stay distinct from assistant and reasoning messages, while the linkedtoolCallIdremains unchanged - activity messages use AG-UI-specific
messageIdvalues prefixed withactivity:so activity updates stay distinct from assistant, reasoning, and tool messages - when a model call runs in batch mode and the provider does not supply message ids, SharpOMatic synthesizes distinct assistant
messageIdvalues for each separate assistant text lifecycle, seeded from the stream sequence so they remain unique across conversation turns - model-call nodes can suppress assistant text, reasoning, or tool-call stream categories, in which case the AG-UI endpoint simply emits fewer events for that run
- code-node stream-event helpers can mark events as
silent, in which case SharpOMatic still stores them in stream history but skips the live AG-UI SSE translation for that event - successful runs emit
RUN_FINISHED - suspended runs also emit
RUN_FINISHED - failed runs emit
RUN_ERROR
The SSE request ends when the underlying workflow run finishes, suspends, or fails.
Silent replay for incoming user messages
AG-UI clients already know about the incoming user message they just submitted.
If your workflow also wants that message recorded in SharpOMatic stream history, add it through a code node with silent: true so the chat client does not render the same user message twice.
var latestUserMessage = Context.Get<ContextObject>("agent.latestUserMessage");
var messageId = latestUserMessage.Get<string>("id");
var text = latestUserMessage.Get<string>("content");
await Events.AddTextMessageAsync(StreamMessageRole.User, messageId, text, silent: true);
The silent flag only affects the live AG-UI SSE output for the current run.
The stored run or conversation stream history still contains the event, and no persisted stream-event field is added for the flag.
Emitting tool calls from code nodes
Code nodes can also publish tool-call stream events directly.
Use AddToolCallAsync when the frontend should execute the tool and return the result later:
await Events.AddToolCallAsync(
"call-1",
"lookup_weather",
"{\"city\":\"Sydney\"}",
"assistant-1"
);
Use AddToolCallWithResultAsync when the workflow already knows the result and should emit the full lifecycle immediately:
await Events.AddToolCallWithResultAsync(
"call-1",
"lookup_weather",
"{\"city\":\"Sydney\"}",
"tool-result-1",
"Sunny",
"assistant-1"
);
Use AddActivitySnapshotAsync and AddActivityDeltaAsync when the frontend should render structured activity updates:
await Events.AddActivitySnapshotAsync(
"plan-1",
"PLAN",
new { steps = new[] { new { title = "Search", status = "in_progress" } } },
replace: false
);
await Events.AddActivityDeltaAsync(
"plan-1",
"PLAN",
new object[] { new { op = "replace", path = "/steps/0/status", value = "done" } }
);
Use AddStepStartAsync and AddStepEndAsync when the frontend should render simple AG-UI step lifecycle markers:
await Events.AddStepStartAsync("Search");
await Events.AddStepEndAsync("Search");