# AskTheo Partner 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:

```bash
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

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

Or keep it in project config:

```ini
# requirements.txt
--extra-index-url https://sdk.justasktheo.com/pypi/simple/
asktheo==0.6.0
```

```toml
# 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.

```bash
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.

```ts
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

```ts
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.

```python
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:

```python
# 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.

```python
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:

```tsx
<AskTheoAsk />                       {/* no backend, no baseUrl, no paths */}
```

```html
<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.

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

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

That is the whole integration. Common props:

```tsx
<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:

```tsx
<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:

```tsx
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:

```html
<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:

```python
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:

```ts
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.

```ts
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.

```ts
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

```ts
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:

```python
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

- The M2M `client_secret` must never appear in browser code, a mobile binary, or a git repo.
- Only ever send the **delegated access token** to a browser, over HTTPS.
- Serve your token route to authenticated sessions only, and apply CSRF protection.
- Do not log tokens.
- Assert a workspace only from your authenticated tenant context — never from user input.
- Call `deactivateUser` promptly when a user loses access.

---

## 13. Checklist

- [ ] Received M2M `client_id` / `client_secret` (that is the whole configuration)
- [ ] Email domain(s) verified on your organization by HarrisX
- [ ] Workspace keys mapped by HarrisX
- [ ] Both packages installing (`.npmrc` line added, `--extra-index-url` set)
- [ ] `registerUser` wired into your user-provisioning path
- [ ] Delegated sessions cached server-side, `expiresIn` respected
- [ ] Token route authenticated + CSRF-protected; secret never shipped to the client
- [ ] `deactivateUser` wired into your user-removal path
- [ ] Error handling branches on `code`, with backoff on 429/502/503

---

## Support

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