Skip to main content
The /api/ctai endpoint responds with a text/event-stream body using the Server-Sent Events protocol. Each event in the stream consists of an event: line, a data: line, and a blank line separator.
event: chunk
data: {"content":"**Veredicto:** Malicioso"}

The data: value is always a JSON object.

Event sequence

A successful analysis produces events in this order:
meta → model → chunk (×N) → done
  • meta is always the first event emitted.
  • model is emitted once, immediately after meta, when OpenRouter confirms the routed model.
  • chunk events follow — one per streaming fragment — until the AI response is complete.
  • done signals the end of the stream.
If an error occurs mid-stream, an error event is emitted in place of done.
When all CTI sources return no data for an IoC, the stream emits only meta followed by done — no AI analysis is performed.

Event types

meta

Emitted first. Contains metadata about the IoC being analyzed, the model that will be used, and any per-source warnings.
ioc
string
required
The indicator of compromise that was submitted.
type
string
required
The detected subtype of the IoC. One of: IPv4, IPv6, domain, hash/md5, hash/sha1, hash/sha256.
model
string
required
The model ID requested for this analysis (e.g., openrouter/free). This is the requested model, not necessarily the final routed model — see the model event for that.
warnings
SourceWarning[]
Present only when one or more CTI sources could not be queried. The analysis continues with the remaining sources.
{
  "ioc": "1.2.3.4",
  "type": "IPv4",
  "model": "openrouter/free",
  "warnings": [
    {
      "source": "AbuseIPDB",
      "message": "La API Key de AbuseIPDB es incorrecta.",
      "reason": "invalid_api_key"
    }
  ]
}

model

Emitted once after meta, when OpenRouter reports the actual model that processed the request. This may differ from the requested model when using openrouter/auto or openrouter/free, which route to whichever model is available.
model
string
required
The real model ID that OpenRouter routed the request to (e.g., "google/gemma-3-4b-it:free").
{ "model": "google/gemma-3-4b-it:free" }

chunk

Emitted once per streaming fragment of the AI response. Concatenate all chunk values in order to reconstruct the full response.
content
string
required
A fragment of the AI-generated Markdown text. The complete response is in Spanish and follows a structured format with verdict, confidence, summary, reasons, and recommended actions.
{ "content": "**Veredicto:** Malicioso" }
The full assembled AI response uses this structure:
**Veredicto:** <Malicioso|Sospechoso|Benigno>
**Confianza:** <Baja|Media|Alta>

**Resumen:** <short summary>

**Motivos:**
- <reason>
- <reason>

**Acciones recomendadas:**
- <action>
- <action>
Render chunk content with a Markdown library (e.g., marked) and sanitize the output with DOMPurify before injecting it into the DOM.

done

Emitted as the final event when the stream ends successfully.
done
boolean
required
Always true.
{ "done": true }

error

Emitted in place of done when an error occurs during streaming — for example, an invalid OpenRouter key or a model-level failure. After an error event the stream closes.
error
string
required
A human-readable error message in Spanish.
stage
string
required
Where in the pipeline the error occurred. One of:
  • ioc — error during CTI data gathering (threat intelligence sources)
  • ai — error during AI analysis (OpenRouter)
  • unknown — unexpected error with no known stage
errorType
string
Machine-readable error classification. See Error Codes for the full list. May be absent for unknown stage errors.
{
  "error": "La API Key de OpenRouter no es válida o no tiene permisos suficientes.",
  "stage": "ai",
  "errorType": "invalid_api_key"
}

Full stream example

Below is a complete raw SSE stream for a successful IPv4 analysis:
event: meta
data: {"ioc":"1.2.3.4","type":"IPv4","model":"openrouter/free"}

event: model
data: {"model":"google/gemma-3-4b-it:free"}

event: chunk
data: {"content":"**Veredicto:** Sospechoso\n"}

event: chunk
data: {"content":"**Confianza:** Media\n"}

event: chunk
data: {"content":"\n**Resumen:** La dirección IP 1.2.3.4..."}

event: done
data: {"done":true}

Parsing SSE in JavaScript

const response = await fetch("http://localhost:4321/api/ctai?ioc=1.2.3.4");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullMarkdown = "";

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const parts = buffer.split("\n\n");
  buffer = parts.pop() ?? "";

  for (const part of parts) {
    const lines = part.split("\n");
    const eventName = lines.find((l) => l.startsWith("event:"))?.slice(6).trim();
    const dataLine = lines.find((l) => l.startsWith("data:"))?.slice(5).trim();
    if (!eventName || !dataLine) continue;

    const payload = JSON.parse(dataLine);

    switch (eventName) {
      case "meta":
        console.log(`Analyzing ${payload.ioc} (${payload.type}) with ${payload.model}`);
        if (payload.warnings?.length) {
          console.warn("Source warnings:", payload.warnings);
        }
        break;
      case "model":
        console.log("Routed to model:", payload.model);
        break;
      case "chunk":
        fullMarkdown += payload.content;
        break;
      case "done":
        console.log("Analysis complete.");
        console.log(fullMarkdown);
        break;
      case "error":
        console.error(`[${payload.stage}] ${payload.error} (${payload.errorType})`);
        break;
    }
  }
}

Parsing SSE in TypeScript

type MetaEvent = { ioc: string; type: string; model: string; warnings?: SourceWarning[] };
type ModelEvent = { model: string };
type ChunkEvent = { content: string };
type DoneEvent = { done: true };
type ErrorEvent = { error: string; stage: "ioc" | "ai" | "unknown"; errorType?: string };
type SourceWarning = { source: string; message: string; reason?: string };

async function analyzeIoc(ioc: string, openRouterKey?: string) {
  const headers: Record<string, string> = {};
  if (openRouterKey) headers["X-OpenRouter-Key"] = openRouterKey;

  const res = await fetch(
    `http://localhost:4321/api/ctai?ioc=${encodeURIComponent(ioc)}`,
    { headers }
  );

  if (!res.ok || !res.body) {
    const err = await res.json();
    throw new Error(err.error);
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const parts = buffer.split("\n\n");
    buffer = parts.pop() ?? "";

    for (const part of parts) {
      const lines = part.split("\n");
      const event = lines.find((l) => l.startsWith("event:"))?.slice(6).trim();
      const data = lines.find((l) => l.startsWith("data:"))?.slice(5).trim();
      if (!event || !data) continue;

      const payload = JSON.parse(data);
      // handle payload based on event type
    }
  }
}

Build docs developers (and LLMs) love