Endpoint Reference
SharpOMatic has optional AG-UI support for clients that want to drive workflows over a single SSE endpoint. It supports both stateless workflow runs and conversation-enabled workflows.
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.MapControllers();
app.MapSharpOMaticEditor(sharpOMaticBasePath);
MapSharpOMaticEditor automatically adds /editor to the base path.
AddSharpOMaticAgUi registers an MVC controller route, so the host must call app.MapControllers().
There is no separate MapSharpOMaticAgUi call.
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 the target workflow and the AG-UI thread:
threadIdis required.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": {
"example": {
"first": "Sharpy Demo",
"second": "test"
}
},
"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.
Request context enrichment
Register IAgUiNotification when your host needs to inspect the incoming AG-UI request before the workflow starts.
This hook can read headers and request data, validate a bearer token, and add derived values into workflow context.
public sealed class MyAgUiNotification : IAgUiNotification
{
public Task OnRunStartingAsync(AgUiRunContextNotification notification)
{
var authorization = notification.Headers.TryGetValue("Authorization", out var values)
? values.FirstOrDefault()
: null;
// Validate the token with your application's auth service, then store derived values only.
notification.Context.Set("auth.userName", "Ada");
notification.Context.Set("auth.permissions", new[] { "workflow:run" });
notification.Agent.Set("auth.userName", "Ada");
return Task.CompletedTask;
}
}
builder.Services.AddScoped<IAgUiNotification, MyAgUiNotification>();
AgUiRunContextNotification includes:
Request: the originalAgUiRunRequestHeaders: a case-insensitive snapshot of request headersThreadId: the normalized AG-UI thread idWorkflowId: the resolved SharpOMatic workflow idIsConversationEnabled: whether the target workflow uses conversation storageContext: mutable root/additional workflow contextAgent: the mutable AG-UIagentcontext for the current request
For non-conversation workflows, Context is the same root context passed to the workflow run and already contains agent, including agent.chat.
For conversation-enabled workflows, Context is merged into the loaded conversation context before the current turn starts, while Agent replaces the root agent value for that turn.
If a notification throws, SharpOMatic does not start the workflow and returns a RUN_ERROR SSE event.
Do not write raw bearer tokens or other secrets into workflow context because context is stored with run and conversation history. Store derived claims instead, such as user id, display name, tenant, roles, or permissions.
Conversation history POST
The configured AG-UI endpoint also supports a JSON history POST request at the same path plus /history.
Use it to restore a conversation-enabled AG-UI chat after a browser refresh or app restart:
POST /sharpomatic/api/agui/history
Content-Type: application/json
{
"threadId": "support-chat-001",
"workflowId": "11230021-5144-471a-8ec7-9b460354b745",
"maxMessages": 10
}
or:
{
"threadId": "support-chat-001",
"workflowName": "Support Chat"
}
The response body is a history envelope:
{
"messages": [],
"state": null,
"pendingFrontendTools": []
}
messages is an AG-UI Message[] suitable for an AG-UI client's initial message array.
state is the latest AG-UI state, restored from the saved conversation checkpoint when possible and otherwise reduced from visible state stream events.
pendingFrontendTools contains at most one item: the final unresolved SharpOMatic frontend tool call, and only when the last restored AG-UI message is that frontend tool call.
The selector rules are:
threadIdis required and maps to the SharpOMatic conversation id- exactly one of
workflowIdorworkflowNameis required maxMessagesis optional; a positive integer returns the most recent restored messages, while omitted, null, zero, negative, fractional, or non-number values keep the default unlimited history behavior- unknown workflow id, unknown workflow name, unknown thread id, and workflow/thread mismatches return
404 - malformed selectors, invalid GUIDs, and ambiguous workflow names return
400 - non-conversation workflows return
{ "messages": [], "state": null, "pendingFrontendTools": [] }because they do not have conversation stream history
The returned messages are reduced from persisted visible stream events.
Text, reasoning, tool-call/tool-result, and final activity messages are included.
Step and custom stream events are ignored because AG-UI initialMessages accepts messages, not protocol event replay.
Reasoning, tool-result, and activity message ids use the same reason:, tool:, and activity: prefixes as live SSE output.
When maxMessages is used, SharpOMatic may return more messages than requested if older messages are required to keep the restored history renderable.
For example, a selected tool result includes the assistant tool-call message it belongs to, and a selected activity delta is rebuilt from the matching activity snapshot plus later deltas before it is returned.
The state value is not limited by maxMessages; it is still restored from the saved checkpoint when possible and otherwise from all visible state stream events.
Execution modes
SharpOMatic resolves the target workflow first and then chooses the execution mode automatically:
- non-conversation workflows are treated as stateless AG-UI targets
- conversation-enabled workflows keep their normal SharpOMatic conversation behavior
For non-conversation workflows:
- the AG-UI client must send the full message history on every request
- SharpOMatic rebuilds
agent.chatfrom that incoming history for the current run only, excluding the latest user text message when it is exposed asagent.latestUserMessage threadIdremains required AG-UI metadata, but it does not create a SharpOMatic conversation
For conversation-enabled workflows:
- the first request for a
(workflow, threadId)pair starts a SharpOMatic conversation usingthreadIdas the realconversationId - later requests with the same
threadIdcontinue or resume that conversation - the controller loads the stored conversation context, replaces
agentwith the latest AG-UI request mapping, and sets or clearsagent._hidden.statefrom the incomingstate - the controller does not append incoming AG-UI messages into
input.chat - the controller exposes only the latest incoming AG-UI message at
agent.messages, so a normal user turn has exactly one user text message there - later requests should be incremental only and send the new AG-UI message for the current turn
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: for non-conversation workflows, the full incomingmessagesarray; for conversation-enabled workflows, only the latest incoming messageagent.chat: for non-conversation workflows, provider-neutralChatMessagehistory rebuilt from the incomingmessagesarray for the current runagent.state: the incoming AG-UIstateagent.context: the incoming AG-UIcontextagent._hidden.state: a hidden deep copy of the incoming AG-UIstate, used as the baseline forAddStateSyncAsync()and theState Syncnode
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 updates agent.
If agent already exists in workflow context, the incoming AG-UI agent object replaces it entirely.
ChatMessage history behavior
input.chat is the conventional context path for provider-neutral ChatMessage history used by ModelCall nodes.
It is not a transcript mirror of every AG-UI event.
For non-conversation AG-UI workflows, the controller writes rebuilt incoming history to agent.chat so AG-UI request data stays under agent.
Use these settings when a non-conversation workflow should pass that history into a model:
ChatInputPath = "agent.chat"ChatOutputPath = "agent.chat"when the rest of the current stateless run should see the model request and response transcript
Use these settings when a conversation-enabled workflow should pass durable workflow-owned history into a model:
ChatInputPath = "input.chat"ChatOutputPath = "input.chat"for conversation-enabled workflows that want the model request and response to become the next turn's replay history
Non-conversation workflows
For non-conversation workflows, the AG-UI client must send the full relevant message history on every request.
The controller creates agent.chat from that incoming history before the workflow starts.
Supported conversion rules:
system->ChatRole.Systemdeveloper->ChatRole.Systemuser->ChatRole.User, except the latest user text message when it is exposed asagent.latestUserMessageassistantmessages with stringcontent,toolCalls, or both ->ChatRole.Assistanttoolresults with stringcontentandtoolCallId->ChatRole.Tool
The latest user text message is deliberately kept out of agent.chat.
Use {{$agent.latestUserMessage.content}} as the current model prompt instead.
If a ModelCall node also writes ChatOutputPath = "agent.chat", the model call output becomes the agent.chat value for the rest of that run.
Because the run is stateless, the next AG-UI request starts from the next incoming messages array again.
Conversation workflows
For conversation-enabled workflows, input.chat is canonical model history owned by workflow nodes.
The AG-UI controller does not rebuild it from incoming AG-UI messages on every turn.
On a new turn, SharpOMatic loads the stored workflow context from the previous checkpoint.
If that context contains input.chat, it is reused.
The incoming AG-UI message is exposed under agent, and a ModelCall usually appends it to input.chat by using Prompt = "{{$agent.latestUserMessage.content}}" and ChatOutputPath = "input.chat".
Each AG-UI turn uses the AG-UI-specific resume input and replaces the root agent context for that turn, so latestUserMessage and latestToolResult reflect only the latest incoming AG-UI message.
Other workflow-owned context, including input.chat, continues from the previous checkpoint unless a workflow node writes a new value.
Generic context-merge resume inputs still merge context recursively; the atomic agent replacement is specific to AG-UI resume handling.
Incoming AG-UI stream-event echoes from prior model output are not appended back into input.chat.
Incoming frontend tool results are also not appended by the controller; the waiting Frontend Tool Call node owns any optional chat persistence for that result.
Workflow-owned writers
ModelCall reads Chat Input Path to build the provider request, then writes Chat Output Path only after the model call has produced responses.
When that output path is input.chat, the written list includes portable input chat, prompt text, image messages, assistant responses, and synthetic assistant messages for model tool results.
If Drop Tool Calls is enabled on the model call, model tool calls and tool results are omitted from the written chat history.
Frontend Tool Call and Backend Tool Call are the only non-model nodes that can write tool-call ChatMessage entries into input.chat.
Both are controlled by Chat Persistence:
None: no chat messages are writtenFunction Call Only: an assistant message withFunctionCallContentis writtenFunction Call And Result: the assistant function-call message and the matching tool-result message are written
The two tool-call nodes always emit AG-UI tool-call stream events for display. Those stream events are separate from chat persistence.
Conversion details
developermessagenameis preserved asAuthorName- assistant
toolCallsmust be a JSON array - each assistant tool call must contain a
functionobject with a non-emptyname - assistant tool call
function.argumentsmust be a JSON string that decodes to a JSON object - assistant messages with neither non-empty string
contentnor any tool calls are skipped rather than added toagent.chat - AG-UI
reasoningandactivitymessages are ignored when buildingagent.chat - unsupported roles are rejected with
RUN_ERROR - multimodal or other non-string message
contentis rejected withRUN_ERROR
Frontend tool calls
SharpOMatic also includes a Frontend Tool Call node for AG-UI workflows that need to suspend, wait for one frontend tool result, and then branch.
This is useful when the frontend interaction is workflow control flow rather than durable model history, for example:
- asking the user for approval
- collecting a UI choice
- waiting for a browser-side action before continuing
The node:
- emits AG-UI
TOOL_CALL_START,TOOL_CALL_ARGS, andTOOL_CALL_ENDstream events before suspending - suspends the conversation
- expects exactly one incoming AG-UI message on resume
- emits
TOOL_CALL_RESULTwhen that message is the expected tool result - routes to
toolResultafter storing the result at the configured result output path - routes to
otherInputfor any other single valid incoming message
It can optionally keep or remove the function call and tool result from input.chat.
New Frontend Tool Call nodes default this chat persistence setting to None, which keeps frontend control-flow tool calls out of model chat history unless a workflow explicitly opts in.
If Hide From Stream is enabled, handled frontend tool-call stream events are marked with HideFromReply so future AG-UI replay does not ask the same frontend question again.
The AG-UI controller does not append the incoming frontend tool result to input.chat.
On resume, the node compares the single incoming tool message to the stored expected toolCallId.
If it matches, the node owns the optional chat persistence and the TOOL_CALL_RESULT stream event.
If it does not match, the node abandons the pending frontend tool call, removes any chat messages it created for that pending call, hides the original tool-call stream events from future replay, and follows otherInput.
Invalid resume payloads, such as missing message ids or non-string content, fail the run rather than branching.
Backend tool calls
SharpOMatic also includes a Backend Tool Call node for workflows that already know the tool result during the current run.
This node:
- emits
TOOL_CALL_START,TOOL_CALL_ARGS,TOOL_CALL_END, andTOOL_CALL_RESULTimmediately - does not suspend or branch
- can optionally add the assistant function call and tool result into
input.chat
Use it when the workflow wants AG-UI tool-call rendering for backend-generated data without waiting for a later frontend response.
The emitted stream events are created regardless of Chat Persistence.
New Backend Tool Call nodes default Chat Persistence to Function Call And Result.
When Chat Persistence is None, the frontend can still render the tool call in the live stream and stored stream history, but no ChatMessage is added for future model input.
Suspend resume behavior
The Suspend node pauses a conversation-enabled workflow and stores a checkpoint.
The node itself does not write to input.chat and does not emit any stream events.
On resume:
- an empty resume continues from the checkpoint without changing context
- a context-merge resume overwrites or adds the provided context values before execution continues
- an AG-UI conversation request is normally converted into a context-merge resume that loads checkpoint context, replaces
agent, and sets or clears the hidden state baseline
For a user reply, the resumed workflow reads the text from agent.latestUserMessage.content.
SharpOMatic does not store that incoming user text as stream history during conversation resume, and it is not appended to input.chat.
If the workflow needs a user message in stream history without a model call, add it explicitly from a Code node with Events.AddTextMessageAsync(StreamMessageRole.User, messageId, text, silent: true).
For a frontend tool result, the resumed workflow reads the result from agent.latestToolResult or from the waiting Frontend Tool Call node.
The suspend resume mechanism does not append that tool result to input.chat and does not create a TOOL_CALL_RESULT event by itself.
The Frontend Tool Call node creates the tool-result stream event and any optional chat messages when the resumed message matches its pending toolCallId.
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 - state stream events become
STATE_SNAPSHOTandSTATE_DELTA - 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 - custom stream events become
CUSTOM 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 - some workflow nodes, such as Frontend Tool Call, can mark persisted stream events with
HideFromReply, in which case those events are excluded from future AG-UI replay/history even though they remain stored in the database - 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.
Incoming user message history
AG-UI clients already know about the incoming user message they just submitted.
When a conversation turn includes agent.latestUserMessage, SharpOMatic exposes that value in workflow context but does not automatically store it as user text stream events.
Model Call nodes store their resolved prompt as silent user text stream events immediately before the provider call unless Disable User Event is enabled on the node's AG-UI tab.
If a workflow needs to store the incoming user text without a model call, add it explicitly from a Code node:
var messageId = Context.Get<string>("agent.latestUserMessage.id");
var text = Context.Get<string>("agent.latestUserMessage.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 manually emitted user message events, 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" } }
);
If the activity state already lives in workflow context, prefer the higher-level sync helper that persists a hidden baseline snapshot, computes the delta automatically, and falls back to a replacement snapshot when that is smaller:
await Events.AddActivitySyncFromContextAsync(
"plan-1",
"PLAN",
"activity.plan",
replace: false
);
await Events.AddActivitySyncFromContextAsync(
"plan-1",
"PLAN",
"activity.plan"
);
If you want the higher-level activity helper to emit snapshots only and skip delta generation entirely, call:
await Events.AddActivitySyncFromContextAsync(
"plan-1",
"PLAN",
"activity.plan",
snapshotsOnly: true
);
Use AddStateSnapshotAsync and AddStateDeltaAsync when the frontend should render AG-UI state changes directly:
await Events.AddStateSnapshotAsync(
new { mode = "assistant", count = 1 }
);
await Events.AddStateDeltaAsync(
new object[] { new { op = "replace", path = "/mode", value = "review" } }
);
If the state already lives in agent.state, prefer the higher-level sync helper. It compares agent.state to the hidden agent._hidden.state baseline, emits a JSON Patch delta when possible and smaller, and falls back to a full snapshot for root replacements or when the snapshot is cheaper:
Context.Set("agent.state.mode", "assistant");
await Events.AddStateSyncAsync();
Context.Set("agent.state.mode", "review");
await Events.AddStateSyncAsync();
If you want the higher-level helper to emit snapshots only and skip delta generation entirely, call:
await Events.AddStateSyncAsync(snapshotsOnly: true);
Use AddStepStartAsync and AddStepEndAsync when the frontend should render simple AG-UI step lifecycle markers:
await Events.AddStepStartAsync("Search");
await Events.AddStepEndAsync("Search");
Use AddCustomEventAsync for AG-UI CUSTOM events:
await Events.AddCustomEventAsync(
"weather_progress",
"{\"stage\":\"fetch\"}"
);
SharpOMatic maps the custom event name to persisted TextDelta and the custom value string to Metadata, then emits:
type = "CUSTOM"name = TextDeltavalue = Metadata
If you do not need custom code, use the dedicated Step Start, Step End, Activity Sync, and State Sync workflow nodes to emit the same protocol-aware updates declaratively.