HarrisX

AskTheo SDK — Integration Guide

Audience: partner engineering teams integrating AskTheo. SDK versions: @harrisx/asktheo@0.6.0 (JavaScript/TypeScript) · asktheo==0.6.0 (Python)


1. How the integration works

You authenticate your own users. AskTheo never shows them a login screen, and there is no OAuth redirect anywhere in the flow — your users never see a WorkOS or AskTheo hostname.

  your user ──▶ your app (you authenticate them, your way)
                    │
                    │  1. client_credentials  ──▶  WorkOS   (your backend, once per hour)
                    │  2. register user       ──▶  AskTheo  (once per user)
                    │  3. delegate session    ──▶  AskTheo  (per sign-in)
                    ▼
              short-lived AskTheo token ──▶ browser ──▶ POST /api/v1/ask

Three credentials, and only one ever reaches a browser:

Credential Lives Purpose
M2M client_id + client_secret Your server only Authenticates your backend to AskTheo
Delegated access token Your server → your browser Acts as one user, ~15 minutes
There is no user password and no refresh token

The M2M credential cannot call user routes (/auth/me with it returns 401), and a delegated token cannot register or deactivate anyone. Neither can cross that line.


2. Installing the packages

Both packages are served from an HTTPS package feed. Nothing to sign up for, no account to create, and no credentials to manage.

Node / TypeScript

Add one line to the .npmrc at the root of your project:

@harrisx:registry=https://sdk.justasktheo.com/npm/

Then install as normal:

npm install @harrisx/asktheo

Only the @harrisx scope resolves from this feed — every other dependency still comes from your usual registry, so the rest of your install is unaffected.

Python

pip install --extra-index-url https://sdk.justasktheo.com/pypi/simple/ asktheo

Or keep it in project config:

# requirements.txt
--extra-index-url https://sdk.justasktheo.com/pypi/simple/
asktheo==0.6.0
# pyproject.toml (Poetry)
[[tool.poetry.source]]
name = "harrisx"
url = "https://sdk.justasktheo.com/pypi/simple/"
priority = "supplemental"

The Python package is dependency-free — standard library only.

CI

Nothing special. The .npmrc line and the --extra-index-url are the whole setup and can be committed; there is no token to inject or rotate.

Versions

Pin both and upgrade deliberately:

@harrisx/asktheo   0.6.0
asktheo            0.6.0

Artifacts carry checksums (integrity for npm, sha256 for Python), so npm ci and pip verify what they download. HarrisX will tell you when a new version ships and what changed.

3. What HarrisX sets up for you

Before you can call anything, we configure:

  1. A WorkOS M2M application for your backend, granted exactly one scope: asktheo:sessions.delegate. You receive its client_id and client_secret.
  2. A WorkOS organization per workspace you need, with your email domain(s) verified on it. Domain verification is what allows us to accept your users' emails.
  3. A workspace mapping — your own workspace key → that organization. You send us the keys you use; we never infer a workspace from an email domain.

You give us: your stable user-id scheme, your workspace keys, and your email domain(s).


4. Configuration

Two secrets. That is the whole configuration.

PARTNER_M2M_CLIENT_ID=client_01…
PARTNER_M2M_CLIENT_SECRET=…        # server-side only: never in browser code, a repo,
                                   # or a build arg baked into an image layer

There is deliberately no auth-domain setting. The SDK asks AskTheo where to obtain its machine token (GET /api/v1/auth/partners/config) and caches the answer, so no identity-provider hostname belongs in your environment and a change on our side needs no change on yours. Your client_secret goes straight to the identity provider and never transits AskTheo.

baseUrl also defaults to the AskTheo environment below, so you normally set no host either.

Environment Host Partner endpoints live?
dev (use this) https://asktheo.dev.harrisx.com yes — SDK default
uat https://asktheo.uat.harrisx.com not yet
prod https://asktheo.harrisx.com not yet

The partner endpoints are currently deployed to dev only. Build and test against the default; HarrisX will tell you when uat and prod are promoted, and then you only change baseUrl.


5. Register a user — once per user

No invitation email is sent and no password is created. Idempotent, so it is safe to call whenever you provision a user in your own system.

import { AskTheo } from "@harrisx/asktheo";

const asktheo = new AskTheo({
  partner: {
    clientId: process.env.PARTNER_M2M_CLIENT_ID!,
    clientSecret: process.env.PARTNER_M2M_CLIENT_SECRET!,   // server-side only
  },
});

const reg = await asktheo.registerUser({
  subject: user.id,                       // YOUR stable, immutable user id
  email: user.email,                      // must be on a domain verified for you
  name: user.name,
  workspaceId: user.workspaceId,
});
// reg.created === false means they already existed

subject must be immutable and globally unique in your system. We bind it as <your client_id>:<subject>, so your ids can never collide with another partner's. Email is not the identity key — a user can change email, and reusing an email already bound to a different subject fails with identity_conflict.


6. Delegate a session — per sign-in

const session = await asktheo.completeAuthentication({
  subject: user.id,
  email: user.email,
  workspaceId: user.workspaceId,
  authenticatedAt: user.authenticatedAt,   // unix seconds; must be < 5 min old
});
// { accessToken, expiresIn: 900, tenant, organizationId, user }

authenticatedAt is when you authenticated the human. We reject anything older than 5 minutes (60s clock skew allowed) with stale_user_authentication, so this must be a real authentication event, not a timestamp generated at call time for a long-idle session.

A subject you have not registered is rejected with user_not_registered. Registration is never implicit.

Cache the token server-side for its lifetime and reuse it; do not delegate per request.


7. The frontend — one component, one backend route

The only thing your frontend needs from you is a route that returns a delegated token. Everything else — token fetch, refresh, streaming, rendering — is in the SDK. Your M2M secret never leaves your server.

7a. The backend route (Python)

AskTheoComponent is the server half. It caches the delegated session per user, so a page with several widgets or a reloading user does not trigger a delegation per request.

from asktheo import AskTheoPartnerClient, AskTheoComponent

client = AskTheoPartnerClient(
    client_id=os.environ["PARTNER_M2M_CLIENT_ID"],
    client_secret=os.environ["PARTNER_M2M_CLIENT_SECRET"],
)
component = AskTheoComponent(client, script_url="/static/asktheo-element.js")

@app.get("/internal/asktheo/token")          # MUST require your own session
def asktheo_token():
    return component.token(
        subject=current_user.id,
        email=current_user.email,
        name=current_user.name,
        workspace_id=current_user.workspace_id,
        authenticated_at=current_user.authenticated_at,
    )                                        # -> {"accessToken": ..., "expiresIn": 900}

Call component.forget(subject) on sign-out or when you revoke access.

Guard this route with your existing session check and CSRF protection. It hands out a token that acts as that user — treat it exactly like your own session endpoint.

Frontend prerequisite: two routes on your own origin

The frontend talks only to your server, under one prefix you choose (default /internal/asktheo):

Route Purpose
POST {prefix}/ask Relays the question to AskTheo and streams the answer back
GET {prefix}/token Only needed if you call AskTheo directly instead of relaying
GET /.well-known/asktheo Optional. Publishes the prefix so the frontend needs no paths

Two reasons it works this way. AskTheo does not permit cross-origin browser requests — Access-Control-Allow-Origin is an explicit allowlist — so a browser calling AskTheo directly gets a CORS failure. And keeping the traffic on your origin means your session check, WAF, rate limits and logs all apply, under a prefix that cannot collide with your application's own routes.

In the default relay mode the delegated token never reaches the browser at all. The browser posts to your /ask route with its own session cookie; your server attaches the token. There is no credential in page source, in localStorage, or in a network response.

The SDK ships the server half, so the route is a pass-through:

# FastAPI
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse

router = APIRouter()

@router.post("/internal/asktheo/ask")          # MUST require your own session
async def asktheo_ask(request: Request):
    relay = component.ask_request(
        await request.body(),
        subject=current_user.id,
        email=current_user.email,
        workspace_id=current_user.workspace_id,
        authenticated_at=current_user.authenticated_at,
    )
    return StreamingResponse(
        component.stream_ask(relay),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

ask_request() mints (and caches) the delegated token and returns {url, headers, body}; stream_ask() yields the upstream SSE bytes. The body is passed through unchanged, so the relay keeps working as the API grows.

Two things to get right, or answers appear to hang:

  1. Do not buffer the response. Set X-Accel-Buffering: no, disable buffering on any proxy in front of your app (for example nginx proxy_buffering off), and make sure any CDN in front of your app does not cache or buffer this route.
  2. No read timeout on the upstream call — a full answer can take minutes. stream_ask() already sets none.

If you would rather call AskTheo directly from the browser, send HarrisX your exact frontend origins and we will allowlist them; then pass baseUrl and implement {prefix}/token instead.

The frontend needs no paths at all

One path is hard-coded in the SDK and it is the one that never changes:

GET /.well-known/asktheo   ->  {"backend": "/internal/asktheo",
                                "ask":     "/internal/asktheo/ask",
                                "token":   "/internal/asktheo/token"}

Mount it and your route prefix becomes a single server-side value. Move the routes from /internal/asktheo to /vanguard/theo — or anywhere else — and deployed frontends follow without a rebuild or a redeploy.

component = AskTheoComponent(client, script_url="…", backend_path="/vanguard/theo")

@app.get("/.well-known/asktheo")     # public; returns only your own relative paths
def asktheo_discovery():
    return component.discovery()

Then the frontend is simply:

<AskTheoAsk />                       {/* no backend, no baseUrl, no paths */}
<asktheo-ask></asktheo-ask>

Resolution order, highest first:

  1. an explicit backend prop/attribute — always wins, and skips discovery entirely
  2. /.well-known/asktheo, if you mount it
  3. the built-in default /internal/asktheo

All three are optional in the sense that doing nothing works: skip the descriptor, keep the default prefix, and the frontend still finds the routes. Discovery is fetched once per page and shared across every component on it, and a failure falls back to the default rather than breaking the widget.

Only same-origin absolute paths are accepted from the descriptor — a full URL or a protocol-relative value is ignored, so a misconfigured or tampered descriptor cannot redirect questions and your session cookie to another host.

7b. React (verified on React 18.3 and 19.3)

react is an optional peer dependency (>=18); nothing else is required.

import { AskTheoAsk } from "@harrisx/asktheo/react";

<AskTheoAsk backend="/internal/asktheo" />

That is the whole integration. Common props:

<AskTheoAsk
  backend="/internal/asktheo"          // your token route (default)
  folderId="<dataset-or-folder-id>"
  model="claude-sonnet-5"
  reportSettings={{ output_format: "powerpoint", verbosity: "short" }}
  placeholder="Ask about Q3…"
  className="my-widget"
  onAnswer={(answer, question) => track(question, answer)}
  onError={(err) => toast(err.message)}
/>

Your own UI, our streaming — pass a render prop:

<AskTheoAsk backend="/internal/asktheo">
  {({ answer, isStreaming, error }) => (
    <MyBubble text={answer} loading={isStreaming} error={error?.message} />
  )}
</AskTheoAsk>

Fully headless — use the hook and render everything yourself:

import { useAskTheo } from "@harrisx/asktheo/react";

function Deck() {
  const { ask, answer, isStreaming, error, abort, sessionId } = useAskTheo({
    backend: "/internal/asktheo",
    reportSettings: { output_format: "powerpoint" },
  });

  return (
    <>
      <button onClick={() => ask("Build the Q3 deck")} disabled={isStreaming}>Generate</button>
      {isStreaming && <button onClick={abort}>Stop</button>}
      <pre>{answer}</pre>
      {error && <p role="alert">{error.message}</p>}
    </>
  );
}

sessionId is captured from the stream automatically and reused, so follow-up questions continue the same conversation.

7c. Non-React frontends — a custom element

For plain JS, Vue, Angular, or a server-rendered template, use the element. It self-registers and needs no build step:

<script type="module" src="/static/asktheo-element.js"></script>
<asktheo-ask backend="/internal/asktheo"></asktheo-ask>

Attributes: backend (default /internal/asktheo), base-url (direct mode only), folder-id, model, placeholder, label, report (powerpoint | charts_only | interactive). Set .reportSettings on the element for full control. It emits asktheo:answer, asktheo:chunk and asktheo:error, and renders in a shadow root so your page CSS and the widget cannot interfere with each other.

AskTheoComponent.embed_html() renders both lines for you:

component.embed_html(folder_id="abc-123", report="powerpoint")

Serve asktheo-element.js (7.7 kB) from your own static path — copy it out of node_modules/@harrisx/asktheo/dist/, or from wherever you host static assets.

7d. Programmatic browser client

If you want neither the component nor the element:

import { AskTheoClient } from "@harrisx/asktheo/browser";

const asktheo = new AskTheoClient({
  tokenProvider: async () => (await fetch("/internal/asktheo/token", {
    credentials: "same-origin",
  })).json(),
});

const stream = await asktheo.ask({ question: "What are the top Q3 trends?" });
for await (const chunk of stream) if (chunk.text) render(chunk.text);

The /browser, /react and /element entry points do not contain the partner functions at all, so a client_secret cannot reach a browser bundle even by mistake. Concurrent requests share a single token refresh rather than each hitting your token route.


8. Asking questions

ask() returns an async iterator over Server-Sent Events.

const stream = await asktheo.ask({
  question: "Compare sentiment across Q2 and Q3",
  folderId: "<dataset-or-folder-id>",     // optional; or folderIds: [...]
  sessionId: "thread-123",                // optional; resumes a conversation
  model: "claude-sonnet-5",               // optional
});

for await (const chunk of stream) {
  chunk.event;   // "text_delta" | "tool_start" | "turn_start" | "done" | …
  chunk.text;    // present on text events
  chunk.done;    // terminal
}

Collect everything instead: const answer = await stream.result(); Cancel with an AbortSignal passed as signal.

Report generation

Passing reportSettings switches the agent into report mode (outline → style guide → theme → render). Omit it for a normal answer.

const stream = await asktheo.ask({
  question: "Build the Q3 consumer trends deck",
  reportSettings: {
    output_format: "powerpoint",   // "powerpoint" | "charts_only" | "interactive"
    verbosity: "short",            // "short" | "medium" | "heavy"
    content_scope: "overall",      // "overall" | "every_question" | "cross_tabs"
    color_palette: "corporate",    // "auto" | "corporate" | "warm" | "forest" | "mono"
    style: "briefing",             // "auto" | "dashboard" | "data_story" | "briefing" | …
    inline_preview: true,
    cross_tabs_enabled: false,
  },
});

Results arrive as content blocks on the same stream — there is no separate generation endpoint to poll.


9. Removing access

await asktheo.deactivateUser({ subject: user.id, workspaceId: user.workspaceId });

Deactivates the user's membership and revokes their sessions, so no new token can be delegated. Tokens already issued remain valid until they expire (≤15 min) — that window is the reason the lifetime is short. Call this when you remove a user from a workspace.


10. Python

Same lifecycle, server-side:

import os, time
from asktheo import AskTheoPartnerClient

asktheo = AskTheoPartnerClient(
    client_id=os.environ["PARTNER_M2M_CLIENT_ID"],
    client_secret=os.environ["PARTNER_M2M_CLIENT_SECRET"],
)

asktheo.register_user(
    subject=user.id, email=user.email, name=user.name, workspace_id=user.workspace_id,
)

session = asktheo.complete_authentication(
    subject=user.id, email=user.email, workspace_id=user.workspace_id,
    authenticated_at=int(time.time()),
)
# session["accessToken"], session["expiresIn"], session["tenant"]

asktheo.deactivate_user(subject=user.id, workspace_id=user.workspace_id)

The M2M token is cached internally and refreshed automatically.


11. Failure contract

Status Code Meaning Retry?
400 stale_user_authentication authenticated_at too old or in the future No — re-authenticate the user
401 invalid_token M2M token missing, expired, malformed or untrusted No — re-fetch the M2M token
403 insufficient_scope Not a registered partner client, or missing the delegation scope No — contact HarrisX
403 workspace_not_allowed Workspace key has no mapping No — contact HarrisX
403 tenant_not_provisioned Mapped tenant has no organization No — contact HarrisX
403 user_not_registered Subject was never registered No — call registerUser first
403 email_domain_not_allowed Email is not on a verified domain No — contact HarrisX to verify it
409 identity_conflict Email already bound to a different subject No — reconcile the identity
413 request_too_large Request body over 16 KiB No
422 validation error Unknown or malformed field No
429 rate_limited Issuance limit exceeded Yes — honor Retry-After
502 identity_provider_unavailable Upstream identity provider failed Yes — bounded backoff
503 security_control_unavailable Shared rate-limit state unavailable Yes — bounded backoff

Errors are { "detail": { "code": …, "message": … } }. Branch on code, never on message — messages may be reworded.


12. Security requirements


13. Checklist


Support

Contact HarrisX engineering with the code, the HTTP status, and the time of the request. Never include tokens or secrets.

Raw Markdown: integration.md