OOtis Docs

Reference

Identity

User and group identity, auth provider pass-through, and session handling.

The SDK captures who did what: user identity, group membership, and session information. Identity flows through to every span and event automatically once set. All functions below work in both server and browser runtimes.

identifyUser

Link the current session to a user and optionally record group memberships:

import { identifyUser } from "@runotis/sdk";

identifyUser("user-123", { company: "acme", team: "eng" });

A user can belong to at most one group per group type, and at most 10 group types per call. The SDK throws if you pass more than 10.

In the browser, identifyUser also auto-captures session properties (user agent, language, UTM parameters). See Browser & consent for the exact keys.

setUserProperties

Set persistent user-level properties for cohort segmentation:

import { setUserProperties } from "@runotis/sdk";

setUserProperties("user-123", { plan: "pro", role: "developer" });

Feature flag exposures

Use the flag. prefix to record flag assignments:

setUserProperties("user-123", {
  "flag.dark_mode": "variant_b",
  "flag.new_onboarding": "control",
});

setGroupProperties

Set persistent properties on a group (company, team, workspace):

import { setGroupProperties } from "@runotis/sdk";

setGroupProperties("company", "acme", { plan: "enterprise", size: 500 });

Property keys and values

The rules below apply to all property calls: setUserProperties, setGroupProperties, and the group types passed to identifyUser.

Key format

Property keys, group types, and group IDs are trimmed and lowercased before use, and must match [a-z0-9_.-]+. The SDK throws on invalid input.

ExampleResult
"plan"plan
"Plan"✅ normalized to plan
"first-name"✅ hyphens allowed
"flag.dark_mode"✅ dots allowed (used for flag. prefix)
"company name"❌ throws; spaces not allowed
"role!"❌ throws; ! not in allowed set

Because keys are lowercased, "Plan" and "plan" are the same key. Keep key spelling stable across call sites. Inconsistent casing or underscore/hyphen mixing won't produce separate keys; it'll just churn the value on every write.

Per-key merge semantics

Each call sends only the keys you pass; it does not replace the full property set for the entity. Setting properties incrementally works as you'd expect:

setUserProperties("user-123", { plan: "pro" });
// ...later, somewhere else...
setUserProperties("user-123", { role: "admin" });
// Both `plan` and `role` are now set on user-123.

Each key is stored independently. Within a single key, the most recent write wins (unless the key is immutable; see below).

Immutable (first-writer-wins) properties

Some property keys represent initial state that shouldn't change after first set: signup_source, first_plan, referral_code, initial_referrer, and similar. For these keys, Otis treats the first value written as authoritative and silently discards later writes to the same key.

You don't mark keys as immutable from the SDK

There's no client-side API to opt a property into first-writer-wins. Otis identifies immutable candidates automatically on the server based on property semantics. Keys named after initial state (signup_source, first_*, initial_*, *_at_signup) are typical candidates.

Two practical implications:

  • Name keys after what they represent. Use present-tense names (plan, role, team_size) for things that change over time. Reserve initial-state names (first_plan, signup_source, original_referrer) for things that genuinely shouldn't change.
  • If a write appears not to take effect, the key may be immutable. Attempting to overwrite an immutable key from application code is a silent no-op. If you genuinely need to reset one, do it from the Otis dashboard's property configuration rather than from application code.

Auth provider pass-through

Most apps already have an auth provider with a session concept. Pass userId and sessionId from your auth helper into contextFromChatRequest.

Only pass opaque session IDs, never session tokens or cookie values. Session tokens are auth credentials; shipping them as span attributes would leak credentials into analytics storage. The callouts in each section below point to the right field for each provider.

JWT in request body or header (Convex, Supabase, Firebase, custom)

Many apps pass a JWT from the client to the server — in the request body (e.g. body.token for Convex), the Authorization header, or a cookie. Use identityFromJWT to pull userId and sessionId out of the token in one call:

import { contextFromChatRequest, identityFromJWT } from "@runotis/sdk";

const body = await req.json();
let userId: string | undefined;
let sessionId: string | undefined;
try {
  const token = body.token ?? req.headers.get("authorization");
  if (token) ({ userId, sessionId } = identityFromJWT(token));
} catch { /* malformed token — leave both undefined */ }

const ctx = contextFromChatRequest(body, { userId, sessionId });
return otis.withContext(ctx, () => streamText({ ... }));

identityFromJWT auto-extracts subuserId, sid (or session_id for Supabase) → sessionId, and org_id (or organization_id) → orgId. It strips a leading Bearer prefix automatically. Returns claims with the raw decoded payload for provider-specific fields (Auth0 namespaced custom claims, firebase.identities, etc.).

Decode-only — verification is your auth provider's job

identityFromJWT does NOT verify the signature. It trusts that the auth provider's middleware (Clerk, WorkOS, Auth0 SDK, your own JWT verifier) has already verified the token upstream. Never use it as a substitute for proper verification.

Only forward identity fields — never the full token

The JWT itself is an auth credential. Pass only the extracted userId/sessionId to withContext. Never put body.token or the raw Authorization header into a span attribute or context field.

WorkOS AuthKit

import { withAuth } from "@workos-inc/authkit-nextjs";
import { contextFromChatRequest } from "@runotis/sdk";

const { sessionId, user } = await withAuth();
const ctx = contextFromChatRequest(body, { sessionId, userId: user.id });
return otis.withContext(ctx, () => streamText({ ... }));

Use withAuth().sessionId, not the session token

withAuth().sessionId is an opaque identifier and safe to use. Never pass the WorkOS session token or the wos-session cookie value; those are auth credentials.

Clerk

import { auth } from "@clerk/nextjs/server";

const { sessionId, userId } = await auth();
const ctx = contextFromChatRequest(body, {
  sessionId: sessionId!,
  userId: userId!,
});

Use auth().sessionId, not the session token

auth().sessionId is an opaque identifier and safe to use. Never pass the Clerk session token or the __session cookie value; those are auth credentials.

Stytch

const { session } = await stytchClient.sessions.authenticate({ session_token });
const ctx = contextFromChatRequest(body, {
  sessionId: session.session_id,
  userId: session.user_id,
});

Use session.session_id, not session_token

session.session_id is an opaque identifier and safe to use. Never pass session_token or session_jwt; those are auth credentials.

Auth.js (v5)

Auth.js doesn't expose a safe session ID. Fall back to browser auto-session (see Browser & consent) or pass only userId:

const session = await auth();
const ctx = contextFromChatRequest(body, { userId: session?.user?.id });

Express + express-session

req.sessionID is an auth credential

Never send req.sessionID as a span attribute. It's the value that authenticates the user's session cookie.

Generate a separate ID:

const sessionId = req.session.otisSessionId ??= crypto.randomUUID();
const ctx = contextFromChatRequest(req.body, { sessionId, userId: req.user?.id });

contextFromChatRequest

Derives stable chatId and eventId from a Vercel AI SDK useChat request body so tool roundtrips extend the same trace instead of creating duplicates.

Three overloads:

// Body only — minimum: chatId + eventId
contextFromChatRequest(body): WrapContext

// Body + options (auth provider pass-through)
contextFromChatRequest(body, { userId, sessionId, metadata }): WrapContext

// Body + Request + options (reads cookies/headers automatically)
contextFromChatRequest(body, req: Request, { userId, sessionId, metadata }?): WrapContext

When a Request is passed:

  • userId — explicit options > __otis_uid cookie > undefined
  • sessionId — explicit options > __otis_session cookie > X-Otis-Session-Id header > undefined

Both are hashed via HMAC automatically when identifier hashing is enabled (the default). Already-hashed usr_v1_ / ses_v1_ values pass through.

Worked example: useChat + JWT-in-body

The full pattern for an app where the browser uses useChat and forwards a JWT (Convex, Supabase, Firebase, or any custom auth setup that hands the client a JWT) to the server.

Client — sends the JWT in the request body:

components/Chat.tsx
"use client";
import { useChat } from "ai/react";
import { useAuthToken } from "./your-auth-hook";

export function Chat() {
  const token = useAuthToken();           // however your app exposes the JWT
  const { messages, input, handleInputChange, handleSubmit } = useChat({
    api: "/api/chat",
    body: { token },                       // forwarded into req.json() server-side
  });

  return (/* ...your chat UI... */);
}

Server — one call extracts identity, one call wires it onto every span:

app/api/chat/route.ts
import { contextFromChatRequest, identityFromJWT } from "@runotis/sdk";
import { getServerOtis } from "@runotis/sdk/next/server";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { waitUntil } from "@vercel/functions";

export async function POST(req: Request) {
  const otis = getServerOtis()!;
  const body = await req.json();

  // 1. Identity — pulled from the JWT in the request body.
  //    `identityFromJWT` decodes only; signature verification is your
  //    auth provider's job upstream. Returns undefined fields if the
  //    token is missing or malformed.
  let userId: string | undefined;
  let sessionId: string | undefined;
  try {
    if (body.token) ({ userId, sessionId } = identityFromJWT(body.token));
  } catch { /* malformed token — leave both undefined */ }

  // 2. Context — `contextFromChatRequest` derives stable chatId + eventId
  //    from the useChat body so multi-step tool roundtrips extend the same
  //    trace. Passing the Request also picks up __otis_uid / __otis_session
  //    cookies automatically (set by OtisProvider in the browser).
  const ctx = contextFromChatRequest(body, req, { userId, sessionId });

  return otis.withContext(ctx, async () => {
    const { streamText: tracedStreamText } = otis.wrap(ai);
    const result = await tracedStreamText({
      model: anthropic("claude-sonnet-4-6"),
      messages: body.messages,
    });

    waitUntil(otis.flush());                 // serverless: keep alive until spans flush
    return result.toTextStreamResponse();
  });
}

The trace produced has userId, sessionId, and chatId set on every span — the AI call, every tool invocation, and any sendEvent calls made inside the withContext callback. Tool roundtrips from the same useChat turn share chatId so they collapse into one trace, not N.

Don't mix-and-match

Don't pass the JWT directly into withContext (that would attach the credential to every span — a leak). And don't extract userId by hand-rolling JSON.parse(atob(token.split(".")[1]))identityFromJWT handles base64url, UTF-8, missing claims, and the Bearer prefix correctly across all common auth providers.

Next.js browser hook — useOtis()

import { useOtis } from "@runotis/sdk/next";

function ChatFeedback({ messageId }: { messageId: string }) {
  const { userId, sessionId, isAnonymous, sendFeedbackSignal } = useOtis();

  return (
    <button onClick={() => sendFeedbackSignal(messageId, "thumbs_up")}>
      Helpful
    </button>
  );
}

useOtis() returns reactive identity values. Components re-render when identifyUser() is called or sessions rotate.

On this page