Audience: partner engineering teams integrating AskTheo. SDK versions: @harrisx/asktheo@0.6.0 (JavaScript/TypeScript) · asktheo==0.6.0 (Python)
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.
Both packages are served from an HTTPS package feed. Nothing to sign up for, no account to create, and no credentials to manage.
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.
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.
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.
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.
Before you can call anything, we configure:
asktheo:sessions.delegate. You receive its client_id and client_secret.You give us: your stable user-id scheme, your workspace keys, and your email domain(s).
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.
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.
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.
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.
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.
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:
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.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.
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:
backend prop/attribute — always wins, and skips discovery entirely/.well-known/asktheo, if you mount it/internal/asktheoAll 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.
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.
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.
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.
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.
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.
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.
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.
| 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.
client_secret must never appear in browser code, a mobile binary, or a git repo.deactivateUser promptly when a user loses access.client_id / client_secret (that is the whole configuration).npmrc line added, --extra-index-url set)registerUser wired into your user-provisioning pathexpiresIn respecteddeactivateUser wired into your user-removal pathcode, with backoff on 429/502/503Contact HarrisX engineering with the code, the HTTP status, and the time of the request.
Never include tokens or secrets.
Raw Markdown: integration.md