Skip to content

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 threads and subjects. Add the capability of each tool Core runs that the agent should use: tooling for its working state or a connector, knowledge for a search over your knowledge. Add files if the person sends documents or images they uploaded.
  • A model or an agent the key may use: GET /models lists 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 with 422 before the model is called, naming the entry: forwardedProps.tools.0.name. A name the organisation never registered is 404.
  • 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 /responses does. The turn's trace records why.
  • A tool your page runs (function, custom or namespace) is refused in forwardedProps.tools: it belongs in the run's own tools.
  • 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_SNAPSHOT shows the state as the turn reads it.
  • When the agent updates it with builtin__update_working_state, a STATE_DELTA follows the tool's result, as JSON Patch. Give the agent that tool, builtin__update_working_state, in forwardedProps.tools or through an agent definition, on a key with tooling.
  • When your page changes the state, send the new object as state on the next run. Core writes it before the turn starts, under the same limits as PUT /threads/{id}/state, and keeps it even when the run is then refused. An empty state changes 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 resume is 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.