Call Core from a browser with AG-UI¶
AG-UI is an open protocol between an agent and the page a person uses.
Its clients, such as @ag-ui/client and CopilotKit, post a run and read the agent's answer as a
stream of events. Core answers that protocol at POST /api/v1/ag-ui/runs, so your page can talk
to Core directly. Each run is a normal turn: your key's models, policy, tools and budget apply
exactly as they do on POST /responses.
The page runs in somebody's browser, so it never holds your key. Your backend gives it a subject token for the person who is signed in, and the page uses that token.
What you need¶
- A key with
threadsandsubjects. Add the capability of each tool Core runs that the agent should use:toolingfor its working state or a connector,knowledgefor a search over your knowledge. Addfilesif the person sends documents or images they uploaded. - A model or an agent the key may use:
GET /modelslists them. - A backend of your own that knows who is signed in.
1. Mint a token and start a thread on your backend¶
Your backend asks Core for a token for the person, and gives it to the page with their thread. How a subject token works, and what it may reach, is in A token for the browser.
curl -s -X POST "$CORE_URL/subjects/tokens" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"subject": {"id": "anna"}, "expires_in": 900}'
{"token": "ast_...", "subject_id": "anna", "expires_at": "2026-10-22T11:15:00Z"}
A run continues a Core thread, so create one. The page can do this itself with the token, or your backend does it with the key, naming the person:
curl -s -X POST "$CORE_URL/threads" \
-H "Authorization: Bearer ast_..." -H "Content-Type: application/json" -d '{}'
Keep the thread's id (thr_...) with the person's conversation. An AG-UI client makes up its own
thread id by default; Core refuses an id it did not issue with 404.
2. Run the agent from the page¶
import { HttpAgent } from "@ag-ui/client";
const agent = new HttpAgent({
url: "https://<your-core>/api/v1/ag-ui/runs",
headers: { Authorization: `Bearer ${subjectToken}` },
threadId,
});
agent.addMessage({ id: crypto.randomUUID(), role: "user", content: "Plan my trip to Delft." });
await agent.runAgent({ forwardedProps: { model: "<a model id>" } });
forwardedProps names what runs the turn: model, a model id or agent:<name>, or agent,
{"id": "<agent name>"}, exactly as on POST /responses. Its tools gives the run tools Core
runs (step 4). Core ignores every other key.
The answer is text/event-stream: RUN_STARTED, a STATE_SNAPSHOT, the agent's messages and tool
calls, then RUN_FINISHED. Its result.responseId is the turn's id, which
GET /responses/{id} reads back with the same subject token; another person's token gets 404.
A turn that fails ends in RUN_ERROR instead. When a
live check withholds the answer, the stream
carries none of it -- at most the agent's thinking, or, for a pattern checked as the text streams, the
text before the match -- and still ends in RUN_FINISHED, with result.status "incomplete" and no
reason: GET /responses/{id} says incomplete_details.reason: "content_filter", and
GET /responses/{id}/checks names the check. A request Core refuses before the stream starts,
such as a model the key may not use, answers an HTTP error with a problem+json body, and the
client throws.
Send the conversation as the client keeps it. The client sends every message on every run.
Core's thread already holds everything up to the agent's last answer, so it reads only the messages
after that answer. A run with nothing new after the last answer is refused on messages.
Retrying after an error. When a run ends in RUN_ERROR before the agent wrote any of its
answer, the person's message is already on the thread. Run the agent again with the same messages
and their same ids, which is what @ag-ui/client does: Core recognises the message it already
holds, does not store it twice, and answers it. When the agent had already written part of its
answer, that part is on the thread too; do not resend the message, send the person's next one
instead.
Context is read every run, kept never. The run's context entries reach the model as
instructions for that run only, so a page that sends the same context each time does not fill the
thread with copies.
Send a file the person uploaded. The page uploads it to POST /files with its token, then
puts its id in an image or document part with a file source. A small image may also go
inline, as data.
agent.addMessage({
id: crypto.randomUUID(),
role: "user",
content: [
{ type: "text", text: "File this contract with the others for the Delft project." },
{ type: "document", source: { type: "file", value: fileId } },
],
});
An image reaches the model as the image itself. A document reaches it as a reference, its id and
media type, never its contents, exactly as on POST /responses: to let the agent read it, index
it in a knowledge collection or give the agent a tool that reads it
(Attach files and images).
The id is checked as POST /responses checks it: a file Core does not hold for this person is
refused with 404 before the stream starts. provider may be left out or be artific-core; a
file id another provider issued is refused. mimeType is optional and not read: Core uses the
type the file was uploaded with.
3. Let the page run a tool¶
A tool in the run's tools is one your page runs. When the model calls it, the run ends after
TOOL_CALL_END. Your page runs the tool, adds a tool message with the result and runs the agent
again; that result is the next run's input.
const agent = new HttpAgent({ url, headers, threadId });
agent.addMessage({ id: crypto.randomUUID(), role: "user", content: "What is the weather?" });
const tools = [
{
name: "get_weather",
description: "Today's weather for a city",
parameters: { type: "object", properties: { city: { type: "string" } } },
},
];
await agent.runAgent({ tools, forwardedProps: { model: "<a model id>" } });
const call = agent.messages.at(-1)?.toolCalls?.[0];
if (call) {
agent.addMessage({ id: crypto.randomUUID(), role: "tool", toolCallId: call.id, content: "sunny" });
await agent.runAgent({ tools, forwardedProps: { model: "<a model id>" } });
}
A tool Core runs itself, such as a connector or knowledge search, shows up as a tool call followed
by TOOL_CALL_RESULT, and the run goes on. When Core could not run such a call -- the policy
refused it, the tool does not exist, it timed out -- the result's text is Core's own short
sentence and its metadata says why, so the page can show it as an error:
agent.subscribe({
onToolCallResultEvent({ event }) {
// `code` names why, e.g. "timeout"
if (event.metadata?.error) showToolError(event.toolCallId, event.metadata.code);
},
});
For asking the person something in the middle of a run, use a frontend tool like get_weather
above: Core has no AG-UI interrupts.
Show the answer's sources. When the agent searched knowledge or read a page, the sources
arrive in metadata too, shaped exactly as on POST /responses
(where an answer's sources are). TEXT_MESSAGE_END carries
artific.annotations, the places the answer cited a source, and TOOL_CALL_RESULT carries
artific.citations, everything the tool read:
agent.subscribe({
onTextMessageEndEvent({ event }) {
// [{ type: "file_citation", file_id, filename, index }, ...]
const annotations = event.metadata?.["artific.annotations"] ?? [];
linkMarkers(event.messageId, annotations);
},
onToolCallResultEvent({ event }) {
// [{ kind: "file" | "url", ref, title }, ...]
listSources(event.toolCallId, event.metadata?.["artific.citations"] ?? []);
},
});
An offset counts Unicode code points, so index Array.from(text) rather than the string itself.
A file_citation's file_id is a passage of your own content: the page reads it with
GET /knowledge/chunks/{file_id} and the same token
(read a cited passage). An answer that
cites nothing carries no artific.annotations, and a result that read nothing carries no
artific.citations.
4. Give a run server tools¶
A tool Core runs itself, such as a search over your knowledge or a connector, goes in
forwardedProps.tools, in the same shape tools takes it on POST /responses: {"type": "tool",
"name": "<qualified name>"} for one tool, as GET /tools lists it, or {"type": "toolset",
"name": "<toolset>"} for a group. An agent you stored is a tool too, under agent__<name>.
await agent.runAgent({
tools, // the tools your page runs, as in step 3
forwardedProps: {
model: "<a model id>",
tools: [{ type: "tool", name: "knowledge__handbook" }],
},
});
The model is offered both lists, and when it calls knowledge__handbook, Core searches the
collection behind it and the page sees the call and its TOOL_CALL_RESULT, sources included,
while the run goes on. Use this when the page decides which tools a conversation gets; an agent
definition that names its tools does the same for every run of that agent.
These tools are decided exactly as on POST /responses, for the person the token is for:
- A tool the key's capabilities do not reach, such as a knowledge search on a key without
knowledge, refuses the whole run with422before the model is called, naming the entry:forwardedProps.tools.0.name. A name the organisation never registered is404. - A tool your policy's rules do not let this person use is not offered to the model, and the run
goes on without it, as a turn on
POST /responsesdoes. The turn's trace records why. - A tool your page runs (
function,customornamespace) is refused inforwardedProps.tools: it belongs in the run's owntools. - A tool of your page with the name of a tool Core runs is refused on the page's tool,
tools.0.name, so rename yours. - The two lists together hold at most 128 tools.
5. Share state with the agent¶
The run's state is the thread's working state, the small
JSON notepad every turn on the thread reads.
- At the start of every run,
STATE_SNAPSHOTshows the state as the turn reads it. - When the agent updates it with
builtin__update_working_state, aSTATE_DELTAfollows the tool's result, as JSON Patch. Give the agent that tool,builtin__update_working_state, inforwardedProps.toolsor through an agent definition, on a key withtooling. - When your page changes the state, send the new object as
stateon the next run. Core writes it before the turn starts, under the same limits asPUT /threads/{id}/state, and keeps it even when the run is then refused. An emptystatechanges nothing, so a page that just loaded does not wipe the notepad.
A state that holds words from a published, sealed agent is shown as "[sealed]", and the page gets
no deltas for it.
6. Stop a run¶
A Stop button calls agent.abortRun(). The client aborts its request, and Core stops the turn the
moment the stream closes: what the agent wrote so far is kept on the thread, the turn is recorded
as cancelled, and you pay only for what the model already did. Closing the page or losing the
connection mid-run stops the turn the same way.
stopButton.onclick = () => agent.abortRun();
This differs from POST /responses, where a turn goes on after a dropped connection and only
POST /responses/{id}/cancel stops it.
What Core does not do on this route¶
- No interrupts, and
resumeis refused: ask the person through a tool your page runs. - A tool call's arguments arrive in one
TOOL_CALL_ARGS, not piece by piece. - A dropped connection cannot be resumed: it stops the turn, as a Stop button does.
- No
MESSAGES_SNAPSHOT: the page already has every message from the events. - An image by address, audio and video are refused, and so is a document sent inline or by address: upload it first.