Skip to main content
CID222 Docs

Integration examples

Call the gateway from cURL, Node.js, Python and React — a working SSE client, a standalone detection call, and a server-side conversation.

  • Version: 0.4
  • Role: admin_user, normal_user
  • Type: task

Build a working client against the gateway: authenticate, discover which models you can call, read the event stream correctly, scan text without a model, and hold a conversation on the server. Every snippet on this page runs against the real routes.

What do I need?

Licence
Any
Role
admin_usernormal_user

Prerequisites

  • The gateway is reachable and answers GET /health with status ok.
  • First-boot setup is complete, and the installed licence is valid — otherwise every route below returns 423 or 402.
  • A provider credential exists for your tenant or its tenant group, so at least one model is callable.
  • You hold a gateway API key (Authorization: Bearer cid_key_…) for chat, models and detection, or local sign-in credentials for a user JWT if you intend to use sessions.

<gateway-host> below is the address the gateway listens on — port 3000 by default, or whatever the reverse proxy in front of it publishes. Route prefixes are not uniform: chat is under /chat, sessions under /sessions, models under /models, and detection is the only surface under /api/v1.

Warning

POST /chat/completions and POST /sessions/:id/messages always answer with text/event-stream. The gateway buffers the model's answer, filters it, and emits the whole reply as a single filtered-response event — there are no token deltas and no choices[] body. Calling response.json() on either route fails.

Confirm which models your credential can reach

GET /models accepts either a JWT or a cid_key_ bearer, and returns only the models whose provider has an active credential for your tenant.

curl -s https://<gateway-host>/models \
  -H "Authorization: Bearer $CID222_API_KEY"

The response is an array of model objects. Each carries the model_name you pass as model and the provider that offers it:

[
  {
    "id": "…",
    "model_name": "gpt-4o",
    "display_name": "GPT-4o (Vision)",
    "provider": { "id": "…", "code": "openai", "name": "OpenAI" },
    "supports_vision": true,
    "supports_streaming": true,
    "is_active": true,
    "created_at": "2026-01-01T00:00:00.000Z"
  }
]

An empty array means no provider credential resolves for your tenant, and every chat call will fail until one does.

Send a chat request from the command line

Use -N so cURL does not buffer the stream.

curl -N -X POST https://<gateway-host>/chat/completions \
  -H "Authorization: Bearer $CID222_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o",
    "provider": "openai",
    "messages": [
      {"role": "system", "content": "You are a helpful assistant."},
      {"role": "user", "content": "Hello, world!"}
    ],
    "max_tokens": 256
  }'

The terminal pauses while the model generates, then prints the whole answer in one frame, then the terminator:

data: {"type":"token_usage","usage":{"prompt_tokens":21,"completion_tokens":14,"total_tokens":35}}

data: {"id":"filtered-response","content":"Hello! How can I help you today?","finish_reason":"stop","filtered":false,"entities_masked":0}

data: [DONE]

Send provider whenever the model name is ambiguous. gpt-4o, gpt-4o-mini and gpt-4-turbo are registered by both openai and azure_openai; Azure's 3.5 model is gpt-35-turbo, without the dot.

Read the stream from your own code

Buffer the decoded bytes and split on the blank line between frames — one read can deliver several frames or half of one.

async function chat(userMessage) {
  const res = await fetch('https://<gateway-host>/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CID222_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      provider: 'openai',
      messages: [
        { role: 'system', content: 'You are a helpful assistant.' },
        { role: 'user', content: userMessage },
      ],
    }),
  });
 
  if (!res.ok) throw new Error(`Gateway returned ${res.status}`);
 
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let answer = '';
 
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
 
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
 
    for (const frame of frames) {
      const line = frame.split('\n').find((l) => l.startsWith('data: '));
      if (!line) continue;
      const data = line.slice(6).trim();
      if (data === '[DONE]') return answer;
 
      const event = JSON.parse(data);
 
      // Prompt blocked before the provider was called, or a server error.
      if (event.error) throw new Error(event.error);
      // Answer discarded by the output filter.
      if (event.type === 'output_content_rejected') throw new Error(event.reason);
      if (event.type === 'token_usage') continue;
 
      // The whole filtered answer, as one event.
      if (!event.type && typeof event.content === 'string') answer = event.content;
    }
  }
  return answer;
}

Both clients return the complete answer, or raise on a policy rejection.

Handle the events that are not content

Between the request and the answer the gateway may emit control events. Ignore the ones you do not use, but do not let an unknown type fall through into your content handler.

EventWhen it appearsWhat to do
{"error":…,"type":"content_rejected"}The prompt violated policy. Terminal, no provider call, no tokens spentShow error to the user; do not retry the same text
{"type":"user_locked",…}The user is locked by an LLM review. TerminalStop, and surface the review_id
{"type":"security_warning",…}A review flagged the user. Not terminalLog it and continue
{"type":"documents_processed",…}You sent documentsOptionally report the summaries
{"type":"model_routed",…} / {"type":"routing_evaluated",…}Model routing evaluated the requestTelemetry only
{"type":"token_optimized",…}Prompt compression ranTelemetry only
{"type":"token_usage",…}Always, when the provider reported usageRecord the prompt and completion split
{"type":"output_content_rejected",…}The answer was blocked. TerminalShow reason; the answer is discarded
{"id":"filtered-response",…}The answerRender content
{"type":"output_content_warning",…}The answer was flagged but deliveredShow a warning next to the answer

Note

user_message_processed — the event that reports how much of your prompt was masked — is emitted internally on /chat/completions and never reaches the client. It does reach the client on POST /sessions/:id/messages.

Scan text without calling a model

POST /api/v1/guardrails/detect runs the same detection pipeline with no provider involved. It returns 200 and states the verdict in the body, so a block is not an HTTP error.

import os
 
import requests
 
 
def detect(text: str) -> dict:
    response = requests.post(
        'https://<gateway-host>/api/v1/guardrails/detect',
        headers={
            'Authorization': f'Bearer {os.environ["CID222_API_KEY"]}',
            'Content-Type': 'application/json',
        },
        json={'text': text, 'check_type': 'prompt'},
        timeout=30,
    )
    response.raise_for_status()
    return response.json()
 
 
result = detect('Email me at john@example.com')
print(result['action'])       # "mask"
print(result['maskedText'])   # "Email me at [EMAIL]"

action is one of reject, mask, flag or allow. text must be 1–50,000 characters. detectedEntities[].value is a placeholder or [REDACTED]: the endpoint never echoes the raw PII it found, and normalization.originalText is deliberately blank.

Hold a conversation on the server with a session

Sessions keep the history server-side, so you send one message per turn. They are guarded by JwtAuthGuard alone — a cid_key_ bearer is rejected, so sign in first.

# 1. Get a user JWT. Valid for JWT_EXPIRES_IN, 24 hours by default.
CID222_JWT=$(curl -s -X POST https://<gateway-host>/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "sarah_smith", "password": "<password>"}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
 
# 2. Create the session.
curl -s -X POST https://<gateway-host>/sessions \
  -H "Authorization: Bearer $CID222_JWT" \
  -H "Content-Type: application/json" \
  -d '{"session_name": "Demo"}'
 
# 3. Send a turn. The model is chosen per message, not per session.
curl -N -X POST https://<gateway-host>/sessions/<session-id>/messages \
  -H "Authorization: Bearer $CID222_JWT" \
  -H "Content-Type: application/json" \
  -d '{"content": "What can you help me with?", "model": "gpt-4o"}'

The message call streams the same way chat does, with one difference worth coding for: a session rejects a prompt with {"type":"content_rejected","reason":…} and no error field, so a handler written for /chat/completions misses it.

Proxy the stream to a browser

Never put a gateway credential in front-end code. Terminate it in your own route and forward the frames unchanged, so the browser reads the same contract.

import { useCallback, useState } from 'react';
 
type Message = { role: 'user' | 'assistant'; content: string };
 
export function useChat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isLoading, setIsLoading] = useState(false);
 
  const sendMessage = useCallback(
    async (content: string) => {
      setIsLoading(true);
      const next: Message[] = [...messages, { role: 'user', content }];
      setMessages(next);
 
      try {
        // Your own route holds the key and pipes the gateway's SSE frames through.
        const res = await fetch('/api/chat', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ messages: next }),
        });
 
        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 frames = buffer.split('\n\n');
          buffer = frames.pop() ?? '';
 
          for (const frame of frames) {
            const line = frame.split('\n').find((l) => l.startsWith('data: '));
            if (!line) continue;
            const data = line.slice(6).trim();
            if (data === '[DONE]') return;
 
            const event = JSON.parse(data);
            if (event.error) throw new Error(event.error);
            if (event.type === 'output_content_rejected') throw new Error(event.reason);
 
            // One event carries the whole answer — append it as a new message.
            if (!event.type && typeof event.content === 'string') {
              setMessages((prev) => [...prev, { role: 'assistant', content: event.content }]);
            }
          }
        }
      } finally {
        setIsLoading(false);
      }
    },
    [messages],
  );
 
  return { messages, sendMessage, isLoading };
}

The assistant message appears in one step. A typewriter effect is not possible through the gateway, because output filtering needs the complete answer before it can release any of it.

Verify

  1. curl -s https://<gateway-host>/health returns {"status":"ok",…}.
  2. curl -s https://<gateway-host>/models -H "Authorization: Bearer $CID222_API_KEY" returns a non-empty array, and the model_name you intend to call is in it.
  3. Your chat client returns non-empty text, and the raw stream contains exactly one frame whose id is filtered-response, followed by data: [DONE].
  4. detect("Email me at john@example.com") returns action of mask and a maskedText in which the address is replaced by a placeholder.
  5. Send a prompt that your filter set rejects. The stream terminates with a content_rejected event and no filtered-response, and the attempt appears in the dashboard under All Detections with the action rejected.

If it fails

  • Every route answers 423 with code: "SETUP_REQUIRED" → first-boot setup is unfinished; finish the wizard.
  • Every route answers 402 with code: "LICENSE_EXPIRED"402 after setup
  • GET /models returns [] → no provider credential resolves for your tenant. Add one under Credentials, or add the tenant to a group that has one.
  • 401 on a route that worked yesterday → the JWT expired, or the API key was regenerated.
  • 401 on /sessions with a key that works on /chat/completions → sessions are JWT-only.
  • The client hangs and then times out → it is waiting for token deltas that never come. Handle the single filtered-response event instead. See Error handling.

Next steps

  • Error handling — the status codes, the three error body shapes, and which failures are worth retrying.
  • Best practices — credential handling, connection reuse, and choosing between sessions and the stateless endpoint.
  • Chat completions — the full request DTO and the complete event list.
  • Content detection — every field of the detection request and response.

Last updated on

On this page

Download PDF