responsible-ai-platform
v1.1.1
Published
Responsible AI Platform (RAIA) — SDK for tracing, evaluation, and governance of AI agents
Readme
responsible-ai-platform
Responsible AI Platform (RAIA) — Node.js/TypeScript SDK for tracing, evaluating, and governing AI agents.
Instrument an agent in a few lines. Every run is uploaded to RAIA, where it is scored for quality, safety, tool accuracy, efficiency, and goal completion.
Works with the Vercel AI SDK, LangChain, LangGraph, OpenAI, Anthropic, Google Gemini, AWS Bedrock, any OpenAI-compatible provider, and hand-rolled agents with no framework at all.
Installation
npm install responsible-ai-platformRequires Node.js 18 or newer (the SDK uses native fetch, AsyncLocalStorage, and
node:crypto). Ships with both ESM and CommonJS builds and full TypeScript types. No runtime
dependencies.
Any Node backend framework works — Express, Fastify, NestJS, Koa, Hapi, Hono, Next.js route
handlers. The SDK has no web-framework integration to configure: you call it from your handler like
any other function. Per-request isolation is handled by AsyncLocalStorage, so concurrent requests
never share a trace.
Node runtime only. Edge runtimes, browsers, Cloudflare Workers, and Deno Deploy are not supported — they lack
node:async_hooksand process signals. In a Next.js route handler, setexport const runtime = 'nodejs'.
Configuration
Put these in your .env:
RAIA_API_KEY=raia_xxxxxxxxxxxxxxxxxxxx
RAIA_API_BASE_URL=https://raia.cirruslabs.ioThe SDK reads .env from the working directory automatically — same as the Python SDK — so nothing
else is needed. Real environment variables take precedence over the file, so containers and CI can
override it without touching .env. If RAIA_API_BASE_URL is unset it defaults to
http://localhost:8000.
Generate the key per agent in the RAIA UI. It identifies the agent, its project, and its tenant — the SDK never sends those itself. Use the key belonging to the agent you're tracing; reusing another agent's key files your traces under that other agent.
Many agents in one process
A host that runs many agents — each registered as its own RAIA asset, possibly for different
customers' RAIA tenants — passes the key per trace instead of setting RAIA_API_KEY:
const trace = new AgentTrace({
taskDescription,
agents: topologyFor(employee),
credentials: { apiKey: employee.raiaApiKey }, // baseUrl optional
});Which key a trace uploads with:
| Order | Source |
|---|---|
| 1 | credentials on the AgentTrace |
| 2 | RAIA_API_KEY environment variable |
| 3 | RAIA_API_KEY in .env |
| — | none: the trace is dropped with a warning |
credentials.baseUrl falls back the same way to RAIA_API_BASE_URL, then http://localhost:8000.
- The key travels with the queued upload, so traces finishing at the same moment can never upload under each other's keys.
- It is validated in the constructor: anything not starting with
raia_— including a RAIA Tenant API key (raiat_…), which cannot upload traces — throws immediately. - It is never written into the trace entries,
metadata, or log lines. - Don't fall back to a shared key yourself. If a caller has no key for an agent, skip tracing
that run; a process-wide
RAIA_API_KEYwould file it under someone else's asset.
All traces in a process share one upload queue. If you trace many agents, raise its depth with
RAIA_MAX_PENDING_UPLOADS (default 100).
Quick start
import { AgentTrace, withTrace, tool } from 'responsible-ai-platform';
// 1. Declare your agent topology once, at startup.
AgentTrace.configure({
agents: [
{ agent_name: 'support_bot', role: 'orchestrator', parent_agent_name: null,
tools: ['search_kb', 'issue_refund'] },
],
modelVersion: 'gpt-4.1',
});
// 2. Wrap the tools you want measured.
const searchKb = tool('search_kb', async (query: string) => db.search(query));
// 3. Trace a run.
const trace = new AgentTrace({ taskDescription: userInput });
const answer = await withTrace(trace, 'support_bot', async () => {
const hits = await searchKb(userInput); // logged automatically
return summarize(hits);
});
// Outcome is set from whether the function threw. The upload happens in the
// background, so tracing adds no latency to your agent's response.That's the whole integration. Everything below is for richer traces.
Agent topology (required)
Every trace declares who the agents are and how they relate. RAIA uses this to attribute tool calls, score per-agent behaviour, and detect tools an agent invoked but was never granted.
AgentTrace.configure({
agents: [
{ agent_name: 'orchestrator', role: 'orchestrator', parent_agent_name: null,
tools: ['plan', 'delegate'] },
{ agent_name: 'researcher', role: 'sub_agent', parent_agent_name: 'orchestrator',
tools: ['web_search'], max_steps_allowed: 5 },
{ agent_name: 'writer', role: 'sub_agent', parent_agent_name: 'orchestrator',
tools: [] },
],
modelVersion: 'claude-opus-5', // default model recorded on every entry
systemPrompt: SYSTEM_PROMPT, // used by the quality and safety metrics
});| Field | Required | Notes |
|---|---|---|
| agent_name | ✅ | Unique within the topology |
| role | ✅ | orchestrator | sub_agent | worker | router | tool_runner |
| parent_agent_name | ✅ | null for the single root agent |
| tools | ✅ | Tools this agent is allowed to call; [] is fine |
| model_version | — | Per-agent model override |
| framework | — | Free-form label (langgraph, vercel-ai, …) |
| max_steps_allowed | — | Per-agent step budget, scored by the efficiency metric |
| optimal_steps | — | Expected step count for the task |
| agent_actions | — | Actions this agent is permitted to take |
| boundary_definitions | — | Policies it must not break, checked by boundary-compliance metrics |
| escalation_conditions | — | When it should hand off to a human |
| ground_truth_description | — | What a correct outcome looks like |
Single-agent systems still declare one node. The topology is validated up front: exactly one
root, no duplicate names, no parent that isn't declared. A bad topology throws at configure()
rather than silently producing unattributable traces.
Hosting more than one agent in a process? Pass agents per trace instead:
const trace = new AgentTrace({ taskDescription, agents: [...] });Integrations
Vercel AI SDK
import { AgentTrace, logVercelAIResponse } from 'responsible-ai-platform';
import { streamText } from 'ai';
export const runtime = 'nodejs';
const trace = new AgentTrace({ taskDescription: userInput });
trace.start();
const result = streamText({
model: openai('gpt-4.1'),
messages,
tools,
onFinish: async (event) => {
logVercelAIResponse(trace, 'support_bot', userInput, event);
trace.setOutcome('success');
await trace.finish();
},
});
return result.toDataStreamResponse();Handles AI SDK v5+ (input/output, inputTokens/outputTokens) and falls back to v4 names.
Multi-step runs (stopWhen) are aggregated across every step, and failed tool executions that arrive
as tool-error parts are recorded rather than dropped.
Call it from
onFinish, or on the settled result ofgenerateText— never on a live stream, wheretextis still a promise.
LangGraph / LangChain
Simplest path — hand it the message array your agent returned:
import { AgentTrace, logLangGraphMessages } from 'responsible-ai-platform';
const trace = new AgentTrace({ taskDescription: query });
trace.start();
const result = await agent.invoke({ messages: [new HumanMessage(query)] });
logLangGraphMessages(trace, result.messages);
trace.setOutcome('success');
await trace.finish();Input, output, tool calls, tool results, token usage, and the model are all extracted for you.
Supervisor and swarm graphs get one entry per node, so each sub-agent is scored on its own work:
import { logLangGraphMessagesPerAgent } from 'responsible-ai-platform';
logLangGraphMessagesPerAgent(trace, result.messages);Node names are read from message.name, then response_metadata.langgraph_node. When a graph has no
node tagging (every plain LangChain run), this falls back to a single entry automatically.
logLangChainMessages and logLangChainAWSMessages are the same function under framework-specific
names — LangChain, LangGraph, and Bedrock-backed LangChain-AWS all emit the same message objects.
Streaming runs can use the callback handler instead, which accumulates across the stream:
import { createLangChainTracer } from 'responsible-ai-platform';
const tracer = createLangChainTracer(trace, 'support_bot');
for await (const [chunk] of await agent.stream({ messages }, { callbacks: [tracer] })) { /* … */ }
tracer.finish({ inputText: query, outputText: finalText });OpenAI (and any OpenAI-compatible provider)
import { AgentTrace, logOpenAIResponse } from 'responsible-ai-platform';
const completion = await client.chat.completions.create({ model, messages, tools });
logOpenAIResponse(trace, 'support_bot', userInput, completion, {
// The API returns tool *requests*; pass what your tools actually returned.
toolResults: [{ name: 'lookup_order', result: JSON.stringify(order) }],
});Handles both chat.completions.create() and the newer responses.create(). Because the shape is
the same, this also covers Azure OpenAI, Groq, Together, OpenRouter, Fireworks, DeepSeek, Ollama,
and vLLM. Refusals and content-filter stops are recorded as failed interactions.
Anthropic
import { AgentTrace, logAnthropicResponse } from 'responsible-ai-platform';
const message = await anthropic.messages.create({ model, max_tokens, messages, tools });
logAnthropicResponse(trace, 'support_bot', userInput, message);tool_use blocks become tool calls, extended-thinking blocks become agent_thinking, and cache
reads are counted as prompt tokens. A max_tokens stop is flagged as a truncated (failed) response.
Google Gemini
import { AgentTrace, logGeminiResponse } from 'responsible-ai-platform';
const result = await ai.models.generateContent({ model, contents });
logGeminiResponse(trace, 'support_bot', userInput, result);Works with both @google/genai and the older @google/generative-ai. Safety blocks and recitation
stops are recorded as failures.
AWS Bedrock (Converse)
import { AgentTrace, logBedrockResponse } from 'responsible-ai-platform';
import { ConverseCommand } from '@aws-sdk/client-bedrock-runtime';
const result = await client.send(new ConverseCommand({ modelId, messages, toolConfig }));
logBedrockResponse(trace, 'claire', userInput, result, { model: modelId });toolUse blocks become tool calls, reasoningContent blocks become agent_thinking, cached prompt
tokens are counted as prompt tokens, and Bedrock's own metrics.latencyMs is used in preference to
wall-clock timing. max_tokens, guardrail_intervened, and content_filtered stops are recorded as
failures.
Bedrock does not echo the model id in the response — pass it via options.model.
For ConverseStreamCommand, hand over the turn you accumulated from the event stream:
const { text, usage, stopReason } = await drainStream(...);
logBedrockResponse(trace, 'claire', userInput, { text, usage, stopReason }, { model: modelId });Any other framework, or none
Two options. Automatic — wrap your tools and let AsyncLocalStorage do the bookkeeping:
import { AgentTrace, withTrace, withAgent, tool } from 'responsible-ai-platform';
const search = tool('search', async (args: { query: string }) => db.search(args.query));
const trace = new AgentTrace({ taskDescription: userInput });
await withTrace(trace, 'orchestrator', async () => {
// Tool calls inside withAgent() are attributed to that sub-agent.
const facts = await withAgent('researcher', () => search({ query: userInput }));
return withAgent('writer', () => write(facts));
});On finish() these become one entry per agent that ran a tool.
Manual — build the entry yourself, for full control:
trace.logInteraction({
inputText: userInput,
outputText: answer,
toolCalls: [{ name: 'search', arguments: { query: 'shoes' }, invoked_by_agent_name: 'researcher' }],
toolResults: [{ name: 'search', result: JSON.stringify(hits) }],
promptTokens: 120,
completionTokens: 45,
model: 'gpt-4.1',
agentName: 'researcher',
});When do traces get evaluated?
Uploading is not the same as scoring. A trace lands in RAIA's storage immediately, but it is evaluated by a scheduled run, configured per tenant in the RAIA UI — not in real time, and not triggered by the upload itself.
Each scheduled run evaluates the traces uploaded in the previous 24 hours, merged into a single evaluation. Two things follow from that:
- Results appear after the next scheduled run, not seconds after your agent finishes.
- A tenant with no schedule configured never has its SDK traces evaluated at all.
This is also why autoUpload defaults to false: batching a trace into one upload at finish()
keeps the file count per run low. Turn it on for long-running sessions where holding entries in
memory matters more.
Threads, workers, and clustering
The SDK uses no threads. Node's fetch is already non-blocking async I/O, so uploads run
concurrently on the event loop — up to 4 at a time, with a queue depth of 100. Nothing is
offloaded to a worker thread, and the event loop is never blocked.
If your app itself uses worker_threads, cluster, or PM2 cluster mode, each worker loads its own
copy of the module and therefore gets its own queue, its own concurrency budget, and its own
shutdown hooks. They upload independently and never contend.
One caveat worth knowing: AsyncLocalStorage context does not cross a worker or process
boundary. If you hand work to a worker_thread, getCurrentTrace() returns undefined inside it
and tool() calls there are not captured. Either create a trace inside the worker, or pass what you
need across and log it on the main thread.
In cluster/PM2 setups, make sure workers actually receive SIGTERM/SIGINT on shutdown — that's
what triggers the final flush. A worker killed without a signal loses whatever is still queued.
Multi-turn sessions
One trace can span a whole conversation. Each logInteraction() is one turn, timed from the end of
the previous one:
const trace = new AgentTrace({
taskDescription: 'Customer support session',
sessionId: conversationId, // reuse across turns to group them
autoUpload: true, // upload each turn as it happens, instead of buffering
});
trace.start();
// …per turn:
trace.logInteraction({ inputText: turn.question, outputText: turn.answer });
// …at the end:
trace.setOutcome('success');
await trace.finish();Use autoUpload: true for long-lived sessions so entries aren't held in memory until the session
ends.
Safety and governance
// A sub-agent handed work to another
trace.logHandoff('orchestrator', 'researcher', 'needs a policy lookup');
// The agent did something it wasn't allowed to
trace.logBoundaryViolation('issued refund', 'refunds over $500 need approval');
// It gave up and escalated (a reason is required)
trace.setOutcome('escalated', 'handed to a human agent');These attach to the next entry logged — or to the last one at finish() — and are recorded exactly
once, never replayed across entries.
setOutcome() accepts 'success' | 'failure' | 'partial' | 'escalated'.
Serverless
Uploads run in the background, which is what keeps them off your agent's critical path — but
background work stops dead when a serverless container is frozen on response. The SDK flushes on
beforeExit, SIGTERM, and SIGINT, and none of those fire on Lambda or Vercel.
So on serverless, wait for the upload before returning. Either flush everything:
import { flush } from 'responsible-ai-platform';
await flush();or wait for one trace:
await withTrace(trace, 'support_bot', run, { waitForUpload: true });
// or, equivalently
await trace.finish();On a long-running server (Express, Fastify, NestJS) you want neither — let the upload happen in the
background and let the SIGTERM handler drain the queue on shutdown.
API reference
AgentTrace
| Member | Description |
|---|---|
| AgentTrace.configure({ agents, modelVersion?, systemPrompt? }) | Declares the global topology. Call once at startup. |
| new AgentTrace({ taskDescription, sessionId?, systemPrompt?, expectedOutcome?, metadata?, agents?, autoUpload?, credentials? }) | Starts a trace. credentials: { apiKey, baseUrl? } overrides RAIA_API_KEY for this trace. |
| .start() | Marks the start time. |
| .logInteraction({ … }) | Records one interaction. See below. |
| .logStep({ tool, args, result, latencyMs?, error?, agentName? }) | Records one tool step (what tool() calls). |
| .logHandoff(from, to, reason?) | Records an agent-to-agent handoff. |
| .logBoundaryViolation(action, ruleViolated) | Records a policy violation. |
| .setOutcome(outcome, escalationReason?) | Sets the task outcome. |
| .finish() | Finalizes and uploads. Idempotent; resolves after upload. |
| .getEntries() | The entries logged so far — useful in tests. |
logInteraction() accepts: inputText, outputText, toolCalls, toolResults, agentThinking,
promptTokens, completionTokens, totalTokens, success, errorType, errorMessage, model,
agentName, startTime, endTime, latencyMs, taskDescription, systemPrompt,
expectedOutcome, stepIndex, numSteps, isRetry, boundaryViolations, escalationEvents,
escalationReason.
Wrappers and context
| Export | Description |
|---|---|
| withTrace(trace, agentName, fn) | Runs fn in a trace context; sets the outcome and finishes. |
| withAgent(agentName, fn) | Scopes the active sub-agent inside a trace. |
| tool(name, fn) | Wraps a function so calls are logged with args, result, and latency. |
| getCurrentTrace() / getCurrentAgent() | Reads the ambient context. |
Integrations
Available from the package root, or individually as responsible-ai-platform/integrations/<name>.
| Export | For |
|---|---|
| logVercelAIResponse(trace, agentName, inputText, result, options?) | Vercel AI SDK |
| logOpenAIResponse(trace, agentName, inputText, response, options?) | OpenAI + compatible providers |
| logAnthropicResponse(trace, agentName, inputText, message, options?) | Anthropic |
| logGeminiResponse(trace, agentName, inputText, result, options?) | Google Gemini |
| logBedrockResponse(trace, agentName, inputText, result, options?) | AWS Bedrock Converse |
| logLangGraphMessages(trace, messages, options?) | LangChain / LangGraph / LangChain-AWS |
| logLangGraphMessagesPerAgent(trace, messages, options?) | Supervisor & swarm graphs, per node |
| createLangChainTracer(trace, agentName) | Streaming LangChain runs (callback handler) |
Every log*Response helper accepts { model, toolResults, agentThinking, startTime, endTime, latencyMs, success, errorType, errorMessage }.
Error handling and limits
Telemetry never breaks your agent. Uploads fail quietly to console.warn — there is no exception to
catch and no local retry queue on disk.
- Transient failures (5xx, 429, 408, network) retry 3 times with backoff; 4xx does not retry.
- Requests time out after 60 seconds.
- Traces are dropped, with a warning, when more than 100 uploads are pending
(
RAIA_MAX_PENDING_UPLOADS) or a payload exceeds 50 MB. - Circular structures and BigInts in tool results are serialized safely rather than throwing.
License
Apache 2.0. Copyright 2026 Cirrus Labs — RAIA.
Support
For issues, questions, or feedback, contact: [email protected]
