Ga naar inhoud

Core vanuit een browser aanroepen met AG-UI

AG-UI is een open protocol tussen een agent en de pagina die een persoon gebruikt. De clients ervan, zoals @ag-ui/client en CopilotKit, starten een run en lezen het antwoord van de agent als een stroom events. Core spreekt dat protocol op POST /api/v1/ag-ui/runs, dus je pagina kan rechtstreeks met Core praten. Elke run is een gewone beurt: de modellen, policy, tools en het budget van je sleutel gelden precies zoals op POST /responses.

De pagina draait in iemands browser en krijgt je sleutel dus nooit. Je backend geeft haar een subject-token voor de persoon die is ingelogd, en de pagina gebruikt dat token.

Wat je nodig hebt

  • Een sleutel met threads en subjects. Voeg de capability toe van elke tool die Core uitvoert en die de agent moet gebruiken: tooling voor zijn werkgeheugen of een connector, knowledge voor een zoektocht in je kennis. Voeg files toe als de persoon documenten of afbeeldingen meestuurt die hij uploadde.
  • Een model of agent die de sleutel mag gebruiken: GET /models geeft ze.
  • Een eigen backend die weet wie er is ingelogd.

1. Maak op je backend een token en een gesprek aan

Je backend vraagt Core om een token voor de persoon en geeft dat met het gesprek aan de pagina. Hoe een subject-token werkt en wat het mag bereiken, staat in Een token voor de 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"}

Een run zet een gesprek van Core voort, dus maak er een aan. De pagina kan dat zelf met het token doen:

curl -s -X POST "$CORE_URL/threads" \
    -H "Authorization: Bearer ast_..." -H "Content-Type: application/json" -d '{}'

Bewaar het id van het gesprek (thr_...) bij het gesprek van de persoon. Een AG-UI-client verzint standaard zelf een id; een id dat Core niet heeft uitgegeven, weigert Core met 404.

2. Laat de pagina de agent draaien

import { HttpAgent } from "@ag-ui/client";

const agent = new HttpAgent({
  url: "https://<jouw-core>/api/v1/ag-ui/runs",
  headers: { Authorization: `Bearer ${subjectToken}` },
  threadId,
});

agent.addMessage({ id: crypto.randomUUID(), role: "user", content: "Plan mijn reis naar Delft." });
await agent.runAgent({ forwardedProps: { model: "<een model-id>" } });

forwardedProps zegt wat de beurt draait: model, een model-id of agent:<naam>, of agent, {"id": "<naam van de agent>"}, precies zoals op POST /responses. Met tools geef je de run tools die Core uitvoert (stap 4). Andere sleutels negeert Core.

Het antwoord is text/event-stream: RUN_STARTED, een STATE_SNAPSHOT, de berichten en toolaanroepen van de agent, en dan RUN_FINISHED. Daarin is result.responseId het id van de beurt, dat GET /responses/{id} met hetzelfde subject-token teruggeeft; het token van iemand anders krijgt 404. Een beurt die mislukt, eindigt in plaats daarvan met RUN_ERROR. Houdt een live check het antwoord achter, dan bevat de stroom er niets van -- hooguit het denkwerk van de agent, of, bij een patroon dat tijdens het streamen wordt gecontroleerd, de tekst vóór de treffer -- en eindigt hij toch met RUN_FINISHED, met result.status "incomplete" en zonder reden: GET /responses/{id} zegt incomplete_details.reason: "content_filter", en GET /responses/{id}/checks noemt de check. Een verzoek dat Core weigert voordat de stroom begint, bijvoorbeeld voor een model dat de sleutel niet mag gebruiken, krijgt een HTTP-fout met een problem+json-body, en de client gooit een fout.

Stuur het gesprek zoals de client het bijhoudt. De client stuurt bij elke run alle berichten. Het gesprek in Core bevat al alles tot en met het laatste antwoord van de agent, dus Core leest alleen de berichten na dat antwoord. Een run zonder iets nieuws na het laatste antwoord wordt geweigerd op messages.

Opnieuw proberen na een fout. Eindigt een run met RUN_ERROR voordat de agent iets van zijn antwoord schreef, dan staat het bericht van de persoon al in het gesprek. Draai de agent opnieuw met dezelfde berichten en dezelfde id's, wat @ag-ui/client vanzelf doet: Core herkent het bericht dat het al heeft, bewaart het niet twee keer en beantwoordt het. Had de agent al een deel van zijn antwoord geschreven, dan staat dat deel ook in het gesprek; stuur het bericht dan niet opnieuw, maar stuur het volgende bericht van de persoon.

Context wordt elke run gelezen en nooit bewaard. De context van een run bereikt het model als instructies voor alleen die run, dus een pagina die elke keer dezelfde context stuurt, vult het gesprek niet met kopieën.

Stuur een bestand mee dat de persoon uploadde. De pagina uploadt het met zijn token naar POST /files en zet het id in een image- of document-deel met een file-bron. Een kleine afbeelding mag ook inline mee, als data.

agent.addMessage({
  id: crypto.randomUUID(),
  role: "user",
  content: [
    { type: "text", text: "Berg dit contract op bij de andere van het project in Delft." },
    { type: "document", source: { type: "file", value: fileId } },
  ],
});

Een afbeelding bereikt het model als de afbeelding zelf. Een document bereikt het als verwijzing, zijn id en mediatype, nooit de inhoud, precies zoals op POST /responses: wil je dat de agent het leest, neem het dan op in een kenniscollectie of geef de agent een tool die het leest (Bestanden en afbeeldingen meesturen).

Core controleert het id zoals POST /responses dat doet: een bestand dat Core niet voor deze persoon heeft, wordt geweigerd met 404 voordat de stroom begint. provider mag ontbreken of artific-core zijn; een bestands-id van een andere aanbieder wordt geweigerd. mimeType is optioneel en wordt niet gelezen: Core gebruikt het type waarmee het bestand is geüpload.

3. Laat de pagina een tool uitvoeren

Een tool in de tools van de run voert je pagina zelf uit. Roept het model hem aan, dan eindigt de run na TOOL_CALL_END. Je pagina voert de tool uit, voegt een tool-bericht met het resultaat toe en draait de agent opnieuw; dat resultaat is de invoer van de volgende run.

const agent = new HttpAgent({ url, headers, threadId });
agent.addMessage({ id: crypto.randomUUID(), role: "user", content: "Wat voor weer wordt het?" });

const tools = [
  {
    name: "get_weather",
    description: "Het weer van vandaag in een stad",
    parameters: { type: "object", properties: { city: { type: "string" } } },
  },
];
await agent.runAgent({ tools, forwardedProps: { model: "<een model-id>" } });

const call = agent.messages.at(-1)?.toolCalls?.[0];
if (call) {
  agent.addMessage({ id: crypto.randomUUID(), role: "tool", toolCallId: call.id, content: "zonnig" });
  await agent.runAgent({ tools, forwardedProps: { model: "<een model-id>" } });
}

Een tool die Core zelf uitvoert, zoals een connector of een zoekopdracht in kennis, verschijnt als toolaanroep met daarna TOOL_CALL_RESULT, en de run gaat door. Kon Core zo'n aanroep niet uitvoeren -- het beleid weigerde hem, de tool bestaat niet, hij duurde te lang -- dan is de tekst van het resultaat Core's eigen korte zin en zegt de metadata waarom, zodat de pagina het als fout kan tonen:

agent.subscribe({
  onToolCallResultEvent({ event }) {
    // `code` names why, bijv. "timeout"
    if (event.metadata?.error) showToolError(event.toolCallId, event.metadata.code);
  },
});

Wil je de persoon midden in een run iets vragen, gebruik dan een tool van je pagina zoals get_weather hierboven: Core kent geen AG-UI-interrupts.

Toon de bronnen van het antwoord. Heeft de agent in kennis gezocht of een pagina gelezen, dan komen de bronnen ook in metadata binnen, in precies de vorm van POST /responses (waar de bronnen van een antwoord staan). TEXT_MESSAGE_END draagt artific.annotations, de plekken waar het antwoord een bron aanhaalt, en TOOL_CALL_RESULT draagt artific.citations, alles wat de tool las:

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"] ?? []);
  },
});

Een positie telt Unicode-codepunten, dus knip in Array.from(text) en niet in de string zelf. De file_id van een file_citation is een passage uit je eigen inhoud: de pagina leest die met GET /knowledge/chunks/{file_id} en hetzelfde token (een aangehaalde passage lezen). Een antwoord dat niets aanhaalt, draagt geen artific.annotations, en een resultaat dat niets las geen artific.citations.

4. Geef een run servertools

Een tool die Core zelf uitvoert, zoals een zoektocht in je kennis of een connector, hoort in forwardedProps.tools, in dezelfde vorm als tools hem op POST /responses neemt: {"type": "tool", "name": "<gekwalificeerde naam>"} voor één tool, zoals GET /tools hem noemt, of {"type": "toolset", "name": "<toolset>"} voor een groep. Een agent die je opsloeg is ook een tool, onder agent__<naam>.

await agent.runAgent({
  tools, // de tools die je pagina uitvoert, zoals in stap 3
  forwardedProps: {
    model: "<een model-id>",
    tools: [{ type: "tool", name: "knowledge__handbook" }],
  },
});

Het model krijgt beide lijsten aangeboden. Roept het knowledge__handbook aan, dan doorzoekt Core de collectie erachter, en de pagina ziet de aanroep en zijn TOOL_CALL_RESULT, met bronnen, terwijl de run doorgaat. Gebruik dit als de pagina bepaalt welke tools een gesprek krijgt; een agentdefinitie die haar tools noemt doet hetzelfde voor elke run van die agent.

Over deze tools wordt precies zo beslist als op POST /responses, voor de persoon van het token:

  • Een tool waar de capabilities van de sleutel niet bij komen, zoals een zoektocht in kennis op een sleutel zonder knowledge, weigert de hele run met 422 voordat het model wordt aangeroepen, en noemt de regel: forwardedProps.tools.0.name. Een naam die de organisatie nooit registreerde is 404.
  • Een tool die de regels van je policy deze persoon niet laten gebruiken, wordt het model niet aangeboden, en de run gaat zonder door, zoals een beurt op POST /responses. De trace van de beurt legt vast waarom.
  • Een tool die je pagina uitvoert (function, custom of namespace) wordt in forwardedProps.tools geweigerd: die hoort in de eigen tools van de run.
  • Een tool van je pagina met de naam van een tool die Core uitvoert, wordt geweigerd op de tool van de pagina, tools.0.name; geef de jouwe een andere naam.
  • Samen bevatten de twee lijsten hoogstens 128 tools.

5. Deel state met de agent

De state van een run is het werkgeheugen van het gesprek, het kleine JSON-kladblok dat elke beurt in het gesprek leest.

  • Aan het begin van elke run toont STATE_SNAPSHOT de state zoals de beurt hem leest.
  • Werkt de agent hem bij met builtin__update_working_state, dan volgt na het resultaat van de tool een STATE_DELTA, als JSON Patch. Geef de agent die tool, builtin__update_working_state, in forwardedProps.tools of via een agentdefinitie, op een sleutel met tooling.
  • Verandert je pagina de state, stuur dan het nieuwe object als state mee met de volgende run. Core schrijft het voordat de beurt begint, met dezelfde grenzen als PUT /threads/{id}/state, en houdt het ook als de run daarna wordt geweigerd. Een lege state verandert niets, dus een pagina die net is geladen, wist het kladblok niet.

Een state met woorden uit een gepubliceerde, verzegelde agent wordt getoond als "[sealed]", en de pagina krijgt er geen delta's voor.

6. Een run stoppen

Een stopknop roept agent.abortRun() aan. De client breekt zijn verzoek af, en Core stopt de beurt zodra de stroom sluit: wat de agent tot dan toe schreef, blijft in het gesprek, de beurt wordt vastgelegd als cancelled, en je betaalt alleen voor wat het model al deed. De pagina sluiten of de verbinding midden in een run verliezen, stopt de beurt op dezelfde manier.

stopButton.onclick = () => agent.abortRun();

Dit is anders dan bij POST /responses, waar een beurt doorloopt na een verbroken verbinding en alleen POST /responses/{id}/cancel hem stopt.

Wat Core op deze route niet doet

  • Geen interrupts, en resume wordt geweigerd: vraag de persoon iets via een tool van je pagina.
  • De argumenten van een toolaanroep komen in één TOOL_CALL_ARGS, niet stukje voor stukje.
  • Een verbroken verbinding kun je niet hervatten: hij stopt de beurt, zoals een stopknop.
  • Geen MESSAGES_SNAPSHOT: de pagina heeft elk bericht al uit de events.
  • Een afbeelding via een adres, audio en video worden geweigerd, net als een document dat inline of via een adres meekomt: upload het eerst.