Documentation

One public reference for the Legion SDK, OAuth, API, CLI, MCP, and platform integrations.

Legion developer documentation

This page mirrors the Developer documentation with one exception: MCP access token generation requires login.

Use the copy controls or each section's plain-text view for the exact shared documentation source.

Create an App

Via Dashboard

Via CLI

legion login
legion create-app --name "My App" --redirect-uris "https://myapp.com/callback"
# Returns a public client_id and a one-time client_secret.
# Standard SDK, Next.js, WordPress, and Drupal integrations configure only client_id.
# They still send a registered redirect_uri during authorization and use S256 PKCE.
# Multiple redirect URIs (comma-separated):
legion create-app --name "My App" --redirect-uris "http://localhost:3000/callback,https://myapp.com/callback"
# Optional partner attribution for non-partner developers:
legion create-app --name "My App" --redirect-uris "https://myapp.com/callback" --partner lovable

Via MCP (AI Agents)

Use the legion_create_oauth_app tool with name and redirect_uris parameters.

View plain text
### Via Dashboard
- Log in to the Legion dashboard at https://legion-ai.org/developer
- Or start on the public registration page at https://legion-ai.org/register and sign in only after submitting the form
- Create a new app: enter your app name and domain (e.g., https://yourapp.com).
- You'll receive a client_id. Register your domain as the redirect URI.
- The server automatically appends /callback if no path is given, so https://yourapp.com becomes https://yourapp.com/callback. You can also register a custom path (e.g., /auth/callback) and it will be used as-is.
- Use the full redirect URI (with path) in your code.
- Response format is OpenAI-compatible.

### Via CLI
legion login
legion create-app --name "My App" --redirect-uris "https://myapp.com/callback"
# Returns a public client_id and a one-time client_secret.
# Standard SDK, Next.js, WordPress, and Drupal integrations configure only client_id.
# They still send a registered redirect_uri during authorization and use S256 PKCE.

# Multiple redirect URIs (comma-separated):
legion create-app --name "My App" --redirect-uris "http://localhost:3000/callback,https://myapp.com/callback"

# Optional partner attribution for non-partner developers:
legion create-app --name "My App" --redirect-uris "https://myapp.com/callback" --partner lovable

### Via MCP (AI Agents)
Use the legion_create_oauth_app tool with name and redirect_uris parameters.

Public Creator Profiles

Creator profile reads are public and resolve either a unique username or a Clerk user ID:

  • GET /v1/profiles/:identifier — public identity and profile counts
  • GET /v1/profiles/:identifier/ais — cursor-paginated hosted chat AIs created directly by the user
  • GET /v1/profiles/:identifier/apps — cursor-paginated registered non-chat apps/websites created directly by the user
  • GET /v1/profiles/:identifier/posts — cursor-paginated creator image posts with viewer-specific locked state

The registered-app response includes public display metadata and a normalized HTTP(S) website origin. It never exposes OAuth callback paths, client IDs, or secrets. Use the returned next_cursor as the cursor query parameter to request another page.

View plain text
Creator profile reads are public and resolve either a unique username or a Clerk user ID:

- GET /v1/profiles/:identifier — public identity and profile counts
- GET /v1/profiles/:identifier/ais — cursor-paginated hosted chat AIs created directly by the user
- GET /v1/profiles/:identifier/apps — cursor-paginated registered non-chat apps/websites created directly by the user
- GET /v1/profiles/:identifier/posts — cursor-paginated creator image posts with viewer-specific locked state

The registered-app response includes public display metadata and a normalized HTTP(S) website origin. It never exposes OAuth callback paths, client IDs, or secrets. Use the returned next_cursor as the cursor query parameter to request another page.

Key Concepts: OAuth App vs Railway Log Source

OAuth App = An OAuth application registration. Creating an app gives you a public client_id for standard PKCE integrations and a one-time client_secret for advanced confidential backend OAuth. OAuth apps are for building AI-powered features.

Railway Log Source = A Railway deployment connection. Connecting a log source gives you access to deployment logs for debugging. Log sources are for monitoring and log access only — they are not required for API integration.

Most developers only need an OAuth App. Railway log sources are optional and only useful if you deploy on Railway and want log access via the CLI or MCP tools.

View plain text
**OAuth App** = An OAuth application registration. Creating an app gives you a public client_id for standard PKCE integrations and a one-time client_secret for advanced confidential backend OAuth. OAuth apps are for building AI-powered features.

**Railway Log Source** = A Railway deployment connection. Connecting a log source gives you access to deployment logs for debugging. Log sources are for monitoring and log access only — they are not required for API integration.

Most developers only need an OAuth App. Railway log sources are optional and only useful if you deploy on Railway and want log access via the CLI or MCP tools.

Integration Approach

Recommended: Login at Point of Use

Prompt users to connect their Legion account when they first try to use AI features (e.g., send a chat message). This provides the best UX - users only authenticate when they actually need it.

Alternative: Dedicated Button

A "Connect Legion" button in your UI is also acceptable if you prefer upfront authentication.

View plain text
### Recommended: Login at Point of Use
Prompt users to connect their Legion account when they first try to use AI features (e.g., send a chat message). This provides the best UX - users only authenticate when they actually need it.

### Alternative: Dedicated Button
A "Connect Legion" button in your UI is also acceptable if you prefer upfront authentication.

OAuth Connect Flow (Manual)

Important notes:

  • Only response_type=code is supported (authorization code flow)
  • scope defaults to 'chat:completions' if omitted
  • Authorization start requires redirect_uri, and it must match a registered redirect URI.
  • Public browser/CMS clients must use S256 PKCE. The authorization request includes code_challenge and code_challenge_method=S256; the token exchange includes code_verifier.
  • redirect_uri is intentionally not sent again in the token exchange body.
  • A public-client token exchange with only grant_type, code, and client_id is invalid for PKCE-bound authorization codes and returns invalid_grant.
  • Public clients do not send client_secret; confidential server-side clients must send client_secret when exchanging a code issued without PKCE.
  • Authorization codes expire after ten minutes and can be used only once.
  • authorization_intent supports exactly: authorize, increase_cap, payment_method.
  • Legion may require the user to add a payment method during consent. If so, the hosted /connect page handles Stripe setup first, then redirects back to your callback with the authorization code.
  • If payment setup is not required during consent, the user can authorize first, receives a payment-method prompt email, and can make capped trial usage until a card is added. User-funded usage is collected through monthly billing.
// Step 1 (recommended): the SDK generates PKCE and redirects to consent.
const legion = createLegion({ clientId: 'YOUR_CLIENT_ID' });
legion.connect({ redirectUri: 'https://yourapp.com/callback' });
// Step 2: User authorizes and returns with code + state. The SDK retrieves the
// verifier associated with that state and performs the protected exchange.
const { access_token, refresh_token, expires_in } = await legion.exchangeCode(code, { state });
// access_token expires in 86400 seconds (24 hours)
// Store both tokens - use refresh_token to get new access tokens
// Step 2, manual equivalent for public clients:
// Send code_verifier. Do not send redirect_uri or client_secret.
const tokenRes = await fetch('https://auth.legion-ai.org/v1/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code,
    client_id: 'YOUR_CLIENT_ID',
    code_verifier: pkceVerifierFromStep1,
  }),
});
// Step 3: Make API calls with user's token
const chatRes = await fetch('https://api.legion-ai.org/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${access_token}`,
  },
  body: JSON.stringify({
    // Optional: omit model to use your app's default model from the dashboard
    model: 'gemini-2.5-flash',
    messages: [{ role: 'user', content: 'Hello!' }],
    // Optional: inject user context for personalized responses
    user_preferences: true,  // user's AI preferences (e.g. "brief answers")
    user_information: true,  // demographics (age, location, occupation)
    user_context: true,      // query patterns (top topics, intent distribution)
    // Optional: raise the owner margin for this call (integer % of model cost,
    // 0-1000). Raise-only: values below your app's configured margin are ignored.
    margin_pct: 150,
  }),
});
const data = await chatRes.json();
const reply = data.choices[0].message.content;

View plain text
Important notes:
- Only response_type=code is supported (authorization code flow)
- scope defaults to 'chat:completions' if omitted
- Authorization start requires redirect_uri, and it must match a registered redirect URI.
- Public browser/CMS clients must use S256 PKCE. The authorization request includes code_challenge and code_challenge_method=S256; the token exchange includes code_verifier.
- redirect_uri is intentionally not sent again in the token exchange body.
- A public-client token exchange with only grant_type, code, and client_id is invalid for PKCE-bound authorization codes and returns invalid_grant.
- Public clients do not send client_secret; confidential server-side clients must send client_secret when exchanging a code issued without PKCE.
- Authorization codes expire after ten minutes and can be used only once.
- authorization_intent supports exactly: authorize, increase_cap, payment_method.
- Legion may require the user to add a payment method during consent. If so, the hosted /connect page handles Stripe setup first, then redirects back to your callback with the authorization code.
- If payment setup is not required during consent, the user can authorize first, receives a payment-method prompt email, and can make capped trial usage until a card is added. User-funded usage is collected through monthly billing.

// Step 1 (recommended): the SDK generates PKCE and redirects to consent.
const legion = createLegion({ clientId: 'YOUR_CLIENT_ID' });
legion.connect({ redirectUri: 'https://yourapp.com/callback' });

// Step 2: User authorizes and returns with code + state. The SDK retrieves the
// verifier associated with that state and performs the protected exchange.
const { access_token, refresh_token, expires_in } = await legion.exchangeCode(code, { state });
// access_token expires in 86400 seconds (24 hours)
// Store both tokens - use refresh_token to get new access tokens

// Step 2, manual equivalent for public clients:
// Send code_verifier. Do not send redirect_uri or client_secret.
const tokenRes = await fetch('https://auth.legion-ai.org/v1/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code,
    client_id: 'YOUR_CLIENT_ID',
    code_verifier: pkceVerifierFromStep1,
  }),
});

// Step 3: Make API calls with user's token
const chatRes = await fetch('https://api.legion-ai.org/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${access_token}`,
  },
  body: JSON.stringify({
    // Optional: omit model to use your app's default model from the dashboard
    model: 'gemini-2.5-flash',
    messages: [{ role: 'user', content: 'Hello!' }],
    // Optional: inject user context for personalized responses
    user_preferences: true,  // user's AI preferences (e.g. "brief answers")
    user_information: true,  // demographics (age, location, occupation)
    user_context: true,      // query patterns (top topics, intent distribution)
    // Optional: raise the owner margin for this call (integer % of model cost,
    // 0-1000). Raise-only: values below your app's configured margin are ignored.
    margin_pct: 150,
  }),
});
const data = await chatRes.json();
const reply = data.choices[0].message.content;

---

Available Models

Default model: your app's default model if configured in the dashboard; otherwise the Google launch default gemini-2.5-flash. Owner margin: configurable markup on model costs (default 50%). Legion also adds a fixed 20% fee on model cost. You keep 100% of the owner margin. Set via the dashboard, CLI (--margin), or PATCH /v1/apps/:clientId with owner_margin_pct.

Inline (per-request) margin: any metered request (chat, media, audio) may include margin_pct — an integer percent of model cost (0-1000) — next to model. It is applied raise-only: values at or above your app's configured owner_margin_pct are honored for that call; values below it (or invalid values) are ignored and the app default applies. This makes the app-level margin your floor: set it to the lowest margin you ever want, then raise it inline per feature. The embed script supports data-margin-pct, and the WordPress/Drupal plugins expose margin_pct per widget. The applied percent is recorded per transaction and visible in your dashboard usage data (margin_pct_applied). Permitted models are controlled centrally. Canonical defaults live in packages/finance-core/src/index.js (DEFAULT_MODEL_CATALOG and DEFAULT_MODEL_PRICING). Runtime model activation overrides come from auth-server model_catalog, and runtime price overrides come from auth-server price_catalog.

Launch active chat models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite, gemini-3.5-flash, gemini-3.5-flash-lite

Launch note: Legion is temporarily Google-only across active AI models to reduce provider billing timing risk. OpenAI, Anthropic, and xAI integrations remain in the codebase but are inactive in the launch catalog until reintroduced.

Speech Models (Audio)

Speech-to-text default: gemini-2.5-flash Text-to-speech default: gemini-2.5-flash-preview-tts

Speech-to-text: gemini-2.5-flash, gemini-3.5-transcribe Text-to-speech: gemini-2.5-flash-preview-tts, gemini-3.1-flash-tts-preview

Note: use gemini-2.5-flash for speech-to-text requests. Legion routes that public model id through the audio transcription endpoint context; clients should not use internal catalog aliases.

Media Generation Models

Image: gemini-3.1-flash-image-preview (Google AI Studio, default), nano-banana-2 (Google Vertex) Video generation is temporarily unavailable during launch. Video model rows and pricing remain in Legion's internal catalog for reactivation, but active model discovery does not expose video models.

Media prices are based on the concrete generation settings Legion sends for each active model (for example: Gemini image generation at 1K). Text pricing also follows vendor large-context tiers when providers charge more above a prompt-token threshold. Configure defaults via the dashboard or PATCH /v1/apps/:clientId with default_image_model.

List Models Programmatically

GET https://api.legion-ai.org/v1/models
Returns an OpenAI-compatible model list including media models. No authentication required.
GET https://api.legion-ai.org/v1/models/catalog
Returns the active Legion catalog with active public defaults plus working-model options used internally by prompt optimization and intent routing. Speech model types are included with `type: "speech_to_text"` and `type: "text_to_speech"`.

Example:

curl https://api.legion-ai.org/v1/models
// Response: { "object": "list", "data": [{ "id": "gemini-2.5-flash", "object": "model", "type": "chat", ... }, { "id": "nano-banana-2", "type": "image_generation", ... }, ...] }

View plain text
Default model: your app's default model if configured in the dashboard; otherwise the Google launch default gemini-2.5-flash.
Owner margin: configurable markup on model costs (default 50%). Legion also adds a fixed 20% fee on model cost. You keep 100% of the owner margin. Set via the dashboard, CLI (--margin), or PATCH /v1/apps/:clientId with owner_margin_pct.

Inline (per-request) margin: any metered request (chat, media, audio) may include margin_pct — an integer percent of model cost (0-1000) — next to model. It is applied raise-only: values at or above your app's configured owner_margin_pct are honored for that call; values below it (or invalid values) are ignored and the app default applies. This makes the app-level margin your floor: set it to the lowest margin you ever want, then raise it inline per feature. The embed script supports data-margin-pct, and the WordPress/Drupal plugins expose margin_pct per widget. The applied percent is recorded per transaction and visible in your dashboard usage data (margin_pct_applied).
Permitted models are controlled centrally. Canonical defaults live in packages/finance-core/src/index.js (DEFAULT_MODEL_CATALOG and DEFAULT_MODEL_PRICING). Runtime model activation overrides come from auth-server model_catalog, and runtime price overrides come from auth-server price_catalog.

Launch active chat models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite, gemini-3.5-flash, gemini-3.5-flash-lite

Launch note: Legion is temporarily Google-only across active AI models to reduce provider billing timing risk. OpenAI, Anthropic, and xAI integrations remain in the codebase but are inactive in the launch catalog until reintroduced.

### Speech Models (Audio)

Speech-to-text default: gemini-2.5-flash
Text-to-speech default: gemini-2.5-flash-preview-tts

Speech-to-text: gemini-2.5-flash, gemini-3.5-transcribe
Text-to-speech: gemini-2.5-flash-preview-tts, gemini-3.1-flash-tts-preview

Note: use `gemini-2.5-flash` for speech-to-text requests. Legion routes that public model id through the audio transcription endpoint context; clients should not use internal catalog aliases.

### Media Generation Models

Image: gemini-3.1-flash-image-preview (Google AI Studio, default), nano-banana-2 (Google Vertex)
Video generation is temporarily unavailable during launch. Video model rows and pricing remain in Legion's internal catalog for reactivation, but active model discovery does not expose video models.

Media prices are based on the concrete generation settings Legion sends for each active model (for example: Gemini image generation at 1K).
Text pricing also follows vendor large-context tiers when providers charge more above a prompt-token threshold.
Configure defaults via the dashboard or PATCH /v1/apps/:clientId with default_image_model.

### List Models Programmatically
GET https://api.legion-ai.org/v1/models
Returns an OpenAI-compatible model list including media models. No authentication required.

GET https://api.legion-ai.org/v1/models/catalog
Returns the active Legion catalog with active public defaults plus working-model options used internally by prompt optimization and intent routing. Speech model types are included with `type: "speech_to_text"` and `type: "text_to_speech"`.

Example:
curl https://api.legion-ai.org/v1/models
// Response: { "object": "list", "data": [{ "id": "gemini-2.5-flash", "object": "model", "type": "chat", ... }, { "id": "nano-banana-2", "type": "image_generation", ... }, ...] }

---

Custom Models (Your Own Weights)

Upload a fine-tuned LoRA adapter and use it anywhere a model id is accepted — for your own apps. Legion never stores or runs your weights: they are registered with the inference provider, and Legion keeps only a reference.

Add one from the dashboard at /add-model (also linked from Developer → your app → Admin → Models). Give it a name, pick the base model your weights were trained on, and upload a .zip / .tar.gz — or point at a Hugging Face repo. Manage what you have added at /developer/models.

Scope and Visibility

Uploaded models are private to you. They are given an id of the form custom-<slug> and:

  • appear only in YOUR model pickers, under the type you chose
  • can only be invoked through apps YOU own — a request from anyone else's app is rejected with

403 model_not_available

  • never appear in GET /v1/models or GET /v1/models/catalog, which stay public-only
  • are never chosen as a platform default

The model id is frozen at creation. Renaming a model changes its display name only, so app defaults and historical usage records keep working.

Supported Types

chat — LoRA adapters on an allowlisted base, served at the base model's per-token price.

Every other type (image_generation, video_generation, speech_to_text, text_to_speech) is rejected with unsupported_custom_model_type for now.

Your Weights Stay Private

Point Legion at a private Hugging Face repo and supply a read token. The token is used once to fetch the weights and is never stored. The provider then keeps its own copy, so after your model goes live the source repo can stay private, or be deleted entirely — inference never touches it again.

A public repo works too, but note that publishing the weights means anyone can self-host them; you would be monetizing your app experience rather than exclusive access to the model.

The Base Model Is Detected For You

You do not specify the base. Legion reads base_model_name_or_path out of your adapter_config.json and matches it against the supported bases, so a mismatch is caught in seconds rather than after a failed deployment. Community mirrors of the same base (for example unsloth/Meta-Llama-3.1-8B-Instruct) resolve to the canonical base automatically.

Pricing and Earnings

There is no separate charge for a custom model, and no new revenue split. You are billed the base model's provider rate, and you earn through your app's existing owner margin exactly as you do with first-party models:

user charge = provider cost + your owner margin + Legion margin (20%) + infra + risk premium

Set your rate in Admin → Owner Margin, or raise it per request with inline margin_pct.

Using a Custom Model

Set it as an app default (dashboard, or PATCH /v1/apps/:clientId with default_model), or pass the id per request:

  const res = await legion.chat({ model: 'custom-chef-llama-8b', messages });

API Reference

GET /v1/custom-models/options Enabled types, allowed base models with their provider cost (no auth) POST /v1/custom-models/inspect Resolve a repo's base model from its adapter_config.json GET /v1/custom-models Your models (refreshes anything still deploying) POST /v1/custom-models/upload-url Presigned upload for a weights archive POST /v1/custom-models Register a model; returns 202, then poll for status GET /v1/custom-models/:id One model, with a live status refresh while deploying PATCH /v1/custom-models/:id Rename, re-describe, or toggle active/disabled DELETE /v1/custom-models/:id Disable the model (historical usage is preserved)

All endpoints require your Clerk session or a leg_ personal access token, and only ever act on your own models.

Statuses: pending → deploying → active, or failed (with status_detail explaining the provider error). Disabling a model removes it from routing within about two minutes.

Create example:

curl -X POST https://auth.legion-ai.org/v1/custom-models \
  -H "Authorization: Bearer leg_..." -H "Content-Type: application/json" \
  -d '{"display_name":"Chef Llama 8B","model_type":"chat","source":{"kind":"hf_repo","hf_repo":"you/chef-lora","hf_token":"hf_..."},"terms_accepted":true}'

base_model is optional and detected from the adapter. Pass it explicitly only to override detection.

A Hugging Face token may be supplied as source.hf_token for a private repo. It is used once to fetch the weights and is never stored.


View plain text
Upload a fine-tuned LoRA adapter and use it anywhere a model id is accepted — for your own apps.
Legion never stores or runs your weights: they are registered with the inference provider, and
Legion keeps only a reference.

Add one from the dashboard at /add-model (also linked from Developer → your app → Admin → Models).
Give it a name, pick the base model your weights were trained on, and upload a .zip / .tar.gz — or
point at a Hugging Face repo. Manage what you have added at /developer/models.

### Scope and Visibility

Uploaded models are private to you. They are given an id of the form `custom-<slug>` and:
- appear only in YOUR model pickers, under the type you chose
- can only be invoked through apps YOU own — a request from anyone else's app is rejected with
  403 model_not_available
- never appear in GET /v1/models or GET /v1/models/catalog, which stay public-only
- are never chosen as a platform default

The model id is frozen at creation. Renaming a model changes its display name only, so app defaults
and historical usage records keep working.

### Supported Types

chat — LoRA adapters on an allowlisted base, served at the base model's per-token price.

Every other type (image_generation, video_generation, speech_to_text, text_to_speech) is rejected
with unsupported_custom_model_type for now.

### Your Weights Stay Private

Point Legion at a private Hugging Face repo and supply a read token. The token is used once to fetch
the weights and is never stored. The provider then keeps its own copy, so after your model goes live
the source repo can stay private, or be deleted entirely — inference never touches it again.

A public repo works too, but note that publishing the weights means anyone can self-host them; you
would be monetizing your app experience rather than exclusive access to the model.

### The Base Model Is Detected For You

You do not specify the base. Legion reads base_model_name_or_path out of your adapter_config.json
and matches it against the supported bases, so a mismatch is caught in seconds rather than after a
failed deployment. Community mirrors of the same base (for example unsloth/Meta-Llama-3.1-8B-Instruct)
resolve to the canonical base automatically.

### Pricing and Earnings

There is no separate charge for a custom model, and no new revenue split. You are billed the base
model's provider rate, and you earn through your app's existing owner margin exactly as you do with
first-party models:

  user charge = provider cost + your owner margin + Legion margin (20%) + infra + risk premium

Set your rate in Admin → Owner Margin, or raise it per request with inline margin_pct.

### Using a Custom Model

Set it as an app default (dashboard, or PATCH /v1/apps/:clientId with default_model), or pass the id
per request:

  const res = await legion.chat({ model: 'custom-chef-llama-8b', messages });

### API Reference

GET    /v1/custom-models/options   Enabled types, allowed base models with their provider cost (no auth)
POST   /v1/custom-models/inspect   Resolve a repo's base model from its adapter_config.json
GET    /v1/custom-models           Your models (refreshes anything still deploying)
POST   /v1/custom-models/upload-url  Presigned upload for a weights archive
POST   /v1/custom-models           Register a model; returns 202, then poll for status
GET    /v1/custom-models/:id       One model, with a live status refresh while deploying
PATCH  /v1/custom-models/:id       Rename, re-describe, or toggle active/disabled
DELETE /v1/custom-models/:id       Disable the model (historical usage is preserved)

All endpoints require your Clerk session or a leg_ personal access token, and only ever act on your
own models.

Statuses: pending → deploying → active, or failed (with status_detail explaining the provider
error). Disabling a model removes it from routing within about two minutes.

Create example:
curl -X POST https://auth.legion-ai.org/v1/custom-models \
  -H "Authorization: Bearer leg_..." -H "Content-Type: application/json" \
  -d '{"display_name":"Chef Llama 8B","model_type":"chat","source":{"kind":"hf_repo","hf_repo":"you/chef-lora","hf_token":"hf_..."},"terms_accepted":true}'

base_model is optional and detected from the adapter. Pass it explicitly only to override detection.

A Hugging Face token may be supplied as source.hf_token for a private repo. It is used once to fetch
the weights and is never stored.

---

Media Generation

POST https://api.legion-ai.org/v1/media/generations
Generate images. Requires a valid Bearer token.

Request body:

  • type (required): "image"
  • prompt (required): Description of the media to generate
  • model (optional): Specific model to use; falls back to app default then platform default
  • thread_messages (optional): Recent chat turns to preserve continuity for follow-up requests like "make another one like the last image"

For content-grounded image requests such as infographics, diagrams, timelines, or "make a visual from this paper", Legion first builds a structured visual brief from the recent chat thread and any retrieved app knowledge-base content, then converts that brief into the final image prompt.

Media generation is async — returns status "processing" with an id. Poll GET /v1/media/generations/{id} until status is "complete".

Video generation is temporarily unavailable during launch. Requests to POST /v1/media/generations with type "video" or to create a video infographic job return 503 with error "video_generation_unavailable". Existing video job status reads remain available for historical jobs.

Video infographic creation code is retained but disconnected at launch:

SDK usage:

const result = await legion.generateMedia("a sunset over the ocean", { type: "image" });
console.log(result.data[0].url);

Intent Classification

POST https://api.legion-ai.org/v1/media/classify
Determines whether a user message is asking for text, image generation, or media search. Video creation requests are classified as text while launch video generation is suspended. Uses gemini-2.5-flash-lite for fast, accurate classification.

Request: { "message": "draw me a cat on a skateboard", "history": [{ "role": "user", "content": "..." }] }

  • history (optional): last few conversation turns so follow-ups like "show me another one" classify correctly. Server-truncated to the last 3 turns.

Response: { "intent": "generate_image", "available_intents": ["text", "generate_image", "generate_infographic", "find_image", "find_video"] }

  • intent: one of "text", "generate_image", "generate_infographic", "find_image", "find_video"
  • available_intents: the intents this app's routing settings allow. Always includes intent.

Classification is constrained by the app's enabled services (see Prompt Routing) — a disabled service's labels are never offered to the classifier, so intent is always a route the app owner allows.

Prompt Routing (Per-App Service Toggles)

An app owner or partner chooses which services a user prompt may be routed to. The four services are:

  • text — text conversation, questions, code, explanations
  • image_generation — creates images and infographics (generate_image, generate_infographic)
  • image_retrieval — finds existing images (find_image)
  • video_retrieval — finds existing videos (find_video)

All four are enabled by default. At least one must stay enabled.

Configure via the dashboard (Chat settings → Prompt Routing) or PATCH /v1/apps/:clientId with enabled_chat_services.

Request: { "enabled_chat_services": ["text", "image_retrieval"] }

  • Array of service keys; unknown keys and an empty array are rejected with 400 validation_error.
  • Returned on every app payload (GET /v1/apps, GET /v1/apps/:clientId, and the public chat info endpoint).

When a prompt classifies to a disabled service, Legion falls back deterministically: to text if enabled, otherwise to the first enabled service in the order image_retrieval, image_generation, video_retrieval. An app with exactly one enabled service skips the classifier call entirely and routes straight to that service.

Media Search (Image, Video, and Audio Retrieval)

POST https://api.legion-ai.org/v1/media/search
Hybrid semantic/keyword search over the app's uploaded media (knowledge-base images/videos/audio) and, for image/video, this user's previously generated media, using Gemini Embedding 2 in a shared text/media vector space. Scoped to the app: a chat with app X only ever surfaces media uploaded for app X. Audio results are ready knowledge-base items and include a signed playable/download URL plus their bounded semantic description.

Request body:

  • query (required): the user's request text
  • media_type (required): "image", "video", or "audio"
  • dedup (optional): true to exclude media this user has already been shown by this app. When the whole matching pool has been shown, the least-recently-shown match is returned flagged "repeat": true and the response carries "exhausted": true.

Response: { "data": [ { "url", "storage_key", "item_ref", "media_type", "description", "source", "similarity", "score", "repeat"? } ], "exhausted" }

  • url: presigned download URL (expires ~1h). Persist storage_key, not url — the hosted chat re-mints fresh URLs at read time.
  • item_ref: stable identity ({ kind: "kb" | "generated", id }) — pass it back when persisting the assistant message so the shown-media ledger records it.
  • similarity: cosine similarity of the semantic match (null for keyword-only hits).

Recommended retrieval reply flow (what the hosted chat does):

  1. Classify the message; on find_image, call /v1/media/search with dedup: true.
  2. Take data[0] and send a normal /v1/chat/completions request whose last user message includes both the request text and the image as an image_url content part, instructing the model to explain in character why it chose this image. Content-part arrays work across providers — Legion inlines remote image URLs automatically for Gemini and Anthropic models.
  3. On no results, send a completion instructing the model to decline in character and offer to generate a new image instead.

All completions bill normally; classifier and embedding costs accrue as infra allocation on the next settlement.

Prompt Optimization & Context

All prompts are automatically refined using gemini-2.5-flash-lite before being sent to the generation model. Standard media requests use the same user context and app knowledge base (RAG) as the text path. Content-grounded visuals use the recent chat thread plus app RAG to extract only the relevant source material before the final image prompt is written. If the request clearly depends on source material ("this paper", "above", "this conversation") and no grounding content is available, the API returns 422 insufficient_source_context instead of generating a generic visual. The optimized prompt is returned as revised_prompt.


View plain text
POST https://api.legion-ai.org/v1/media/generations
Generate images. Requires a valid Bearer token.

Request body:
- type (required): "image"
- prompt (required): Description of the media to generate
- model (optional): Specific model to use; falls back to app default then platform default
- thread_messages (optional): Recent chat turns to preserve continuity for follow-up requests like "make another one like the last image"

For content-grounded image requests such as infographics, diagrams, timelines, or "make a visual from this paper", Legion first builds a structured visual brief from the recent chat thread and any retrieved app knowledge-base content, then converts that brief into the final image prompt.

Media generation is async — returns status "processing" with an id. Poll GET /v1/media/generations/{id} until status is "complete".

Video generation is temporarily unavailable during launch. Requests to POST /v1/media/generations with type "video" or to create a video infographic job return 503 with error "video_generation_unavailable". Existing video job status reads remain available for historical jobs.

Video infographic creation code is retained but disconnected at launch:
- POST https://api.legion-ai.org/v1/video-jobs
- GET https://api.legion-ai.org/v1/video-jobs/{id}
- Supports Idempotency-Key for safe client retries
- Status is durable: if the fast Redis cache expires, Legion falls back to the database record instead of treating the job as missing

SDK usage:
const result = await legion.generateMedia("a sunset over the ocean", { type: "image" });
console.log(result.data[0].url);

### Intent Classification
POST https://api.legion-ai.org/v1/media/classify
Determines whether a user message is asking for text, image generation, or media search. Video creation requests are classified as text while launch video generation is suspended. Uses gemini-2.5-flash-lite for fast, accurate classification.

Request: { "message": "draw me a cat on a skateboard", "history": [{ "role": "user", "content": "..." }] }
- history (optional): last few conversation turns so follow-ups like "show me another one" classify correctly. Server-truncated to the last 3 turns.
Response: { "intent": "generate_image", "available_intents": ["text", "generate_image", "generate_infographic", "find_image", "find_video"] }
- intent: one of "text", "generate_image", "generate_infographic", "find_image", "find_video"
- available_intents: the intents this app's routing settings allow. Always includes intent.

Classification is constrained by the app's enabled services (see Prompt Routing) — a disabled service's labels are never offered to the classifier, so intent is always a route the app owner allows.

### Prompt Routing (Per-App Service Toggles)
An app owner or partner chooses which services a user prompt may be routed to. The four services are:
- text — text conversation, questions, code, explanations
- image_generation — creates images and infographics (generate_image, generate_infographic)
- image_retrieval — finds existing images (find_image)
- video_retrieval — finds existing videos (find_video)

All four are enabled by default. At least one must stay enabled.

Configure via the dashboard (Chat settings → Prompt Routing) or PATCH /v1/apps/:clientId with enabled_chat_services.

Request: { "enabled_chat_services": ["text", "image_retrieval"] }
- Array of service keys; unknown keys and an empty array are rejected with 400 validation_error.
- Returned on every app payload (GET /v1/apps, GET /v1/apps/:clientId, and the public chat info endpoint).

When a prompt classifies to a disabled service, Legion falls back deterministically: to text if enabled, otherwise to the first enabled service in the order image_retrieval, image_generation, video_retrieval. An app with exactly one enabled service skips the classifier call entirely and routes straight to that service.

### Media Search (Image, Video, and Audio Retrieval)
POST https://api.legion-ai.org/v1/media/search
Hybrid semantic/keyword search over the app's uploaded media (knowledge-base images/videos/audio) and, for image/video, this user's previously generated media, using Gemini Embedding 2 in a shared text/media vector space. Scoped to the app: a chat with app X only ever surfaces media uploaded for app X. Audio results are ready knowledge-base items and include a signed playable/download URL plus their bounded semantic description.

Request body:
- query (required): the user's request text
- media_type (required): "image", "video", or "audio"
- dedup (optional): true to exclude media this user has already been shown by this app. When the whole matching pool has been shown, the least-recently-shown match is returned flagged "repeat": true and the response carries "exhausted": true.

Response: { "data": [ { "url", "storage_key", "item_ref", "media_type", "description", "source", "similarity", "score", "repeat"? } ], "exhausted" }
- url: presigned download URL (expires ~1h). Persist storage_key, not url — the hosted chat re-mints fresh URLs at read time.
- item_ref: stable identity ({ kind: "kb" | "generated", id }) — pass it back when persisting the assistant message so the shown-media ledger records it.
- similarity: cosine similarity of the semantic match (null for keyword-only hits).

Recommended retrieval reply flow (what the hosted chat does):
1. Classify the message; on find_image, call /v1/media/search with dedup: true.
2. Take data[0] and send a normal /v1/chat/completions request whose last user message includes both the request text and the image as an image_url content part, instructing the model to explain in character why it chose this image. Content-part arrays work across providers — Legion inlines remote image URLs automatically for Gemini and Anthropic models.
3. On no results, send a completion instructing the model to decline in character and offer to generate a new image instead.
All completions bill normally; classifier and embedding costs accrue as infra allocation on the next settlement.

### Prompt Optimization & Context
All prompts are automatically refined using gemini-2.5-flash-lite before being sent to the generation model. Standard media requests use the same user context and app knowledge base (RAG) as the text path. Content-grounded visuals use the recent chat thread plus app RAG to extract only the relevant source material before the final image prompt is written. If the request clearly depends on source material ("this paper", "above", "this conversation") and no grounding content is available, the API returns 422 insufficient_source_context instead of generating a generic visual. The optimized prompt is returned as revised_prompt.

---

Audio

Legion exposes speech-to-text and text-to-speech as separate API calls. Legion does not orchestrate microphone capture, speaker playback, or conversational voice mode for your platform. Your app records audio or plays audio locally, and Legion handles the authenticated AI call plus billing.

Speech-to-Text

POST https://api.legion-ai.org/v1/audio/transcriptions

OpenAI-compatible multipart upload endpoint.

Form fields:

  • file (required): audio file upload
  • model (optional): defaults to app default default_speech_to_text_model, otherwise Legion default gemini-2.5-flash
  • prompt (optional): extra transcription guidance
  • response_format (optional): json or text

Response:

  • json: { "text": "..." }
  • text: plain transcription text

Text-to-Speech

POST https://api.legion-ai.org/v1/audio/speech

OpenAI-compatible speech generation endpoint.

JSON body:

  • input (required): text to synthesize
  • model (optional): defaults to app default default_text_to_speech_model, otherwise Legion default gemini-2.5-flash-preview-tts
  • voice (optional): provider voice hint
  • response_format (optional): requested output format

Response:

  • raw audio bytes with an audio Content-Type

App-Level Speech Defaults

Each app can set speech defaults independently in the developer dashboard:

  • default_speech_to_text_model
  • default_text_to_speech_model

Legion resolves speech models in this order:

  1. Request-level model
  2. App default speech model
  3. Legion platform default

View plain text
Legion exposes speech-to-text and text-to-speech as separate API calls. Legion does not orchestrate microphone capture, speaker playback, or conversational voice mode for your platform. Your app records audio or plays audio locally, and Legion handles the authenticated AI call plus billing.

### Speech-to-Text
POST https://api.legion-ai.org/v1/audio/transcriptions

OpenAI-compatible multipart upload endpoint.

Form fields:
- file (required): audio file upload
- model (optional): defaults to app default `default_speech_to_text_model`, otherwise Legion default `gemini-2.5-flash`
- prompt (optional): extra transcription guidance
- response_format (optional): `json` or `text`

Response:
- `json`: `{ "text": "..." }`
- `text`: plain transcription text

### Text-to-Speech
POST https://api.legion-ai.org/v1/audio/speech

OpenAI-compatible speech generation endpoint.

JSON body:
- input (required): text to synthesize
- model (optional): defaults to app default `default_text_to_speech_model`, otherwise Legion default `gemini-2.5-flash-preview-tts`
- voice (optional): provider voice hint
- response_format (optional): requested output format

Response:
- raw audio bytes with an audio `Content-Type`

### App-Level Speech Defaults
Each app can set speech defaults independently in the developer dashboard:
- `default_speech_to_text_model`
- `default_text_to_speech_model`

Legion resolves speech models in this order:
1. Request-level `model`
2. App default speech model
3. Legion platform default

---

User Context Injection (ON by Default)

Every API call automatically enriches the AI request with user-specific context for personalized responses. Three flags control what is injected — all default to true. To opt out, set a flag to false explicitly in your request body.

Flag: user_preferences (default: true)

  • Source: User's AI Preferences setting
  • Injects: Freeform preference text (e.g., "I prefer brief answers with sources")
  • Opt out: set user_preferences: false

Flag: user_information (default: true)

  • Source: User's profile demographics
  • Injects: Age, sex, language, location, occupation, industry
  • Opt out: set user_information: false

Flag: user_context (default: true)

  • Source: User's query history (last 90 days, across all apps)
  • Injects: Top 5 topics, intent distribution %, primary language, avg query length, total queries
  • Opt out: set user_context: false

How it works:

  • Context is appended to the system message before forwarding to the AI provider
  • If no system message exists, one is created
  • Empty sections are skipped automatically
  • If user has no profile data, nothing is injected
  • Zero additional latency when all flags are set to false
  • Results are cached for 5 minutes

SDK Example

const response = await legion.chat(
  [{ role: 'user', content: 'Help me plan my week' }],
  {
    model: 'gemini-2.5-flash',
    user_preferences: true,
    user_information: true,
    user_context: true,
  }
);
const transcription = await legion.transcribeAudio(file, {
  response_format: 'json',
});
const speech = await legion.generateSpeech('Hello from Legion', {
  voice: 'Puck',
});

Direct API Example

body: JSON.stringify({
  model: 'gemini-2.5-flash',
  messages: [{ role: 'user', content: 'Help me plan my week' }],
  user_preferences: true,
  user_information: true,
  user_context: true,
})

View plain text
Every API call automatically enriches the AI request with user-specific context for personalized responses. Three flags control what is injected — all default to true. To opt out, set a flag to false explicitly in your request body.

Flag: user_preferences (default: true)
- Source: User's AI Preferences setting
- Injects: Freeform preference text (e.g., "I prefer brief answers with sources")
- Opt out: set user_preferences: false

Flag: user_information (default: true)
- Source: User's profile demographics
- Injects: Age, sex, language, location, occupation, industry
- Opt out: set user_information: false

Flag: user_context (default: true)
- Source: User's query history (last 90 days, across all apps)
- Injects: Top 5 topics, intent distribution %, primary language, avg query length, total queries
- Opt out: set user_context: false

How it works:
- Context is appended to the system message before forwarding to the AI provider
- If no system message exists, one is created
- Empty sections are skipped automatically
- If user has no profile data, nothing is injected
- Zero additional latency when all flags are set to false
- Results are cached for 5 minutes

### SDK Example
const response = await legion.chat(
  [{ role: 'user', content: 'Help me plan my week' }],
  {
    model: 'gemini-2.5-flash',
    user_preferences: true,
    user_information: true,
    user_context: true,
  }
);

const transcription = await legion.transcribeAudio(file, {
  response_format: 'json',
});

const speech = await legion.generateSpeech('Hello from Legion', {
  voice: 'Puck',
});

### Direct API Example
body: JSON.stringify({
  model: 'gemini-2.5-flash',
  messages: [{ role: 'user', content: 'Help me plan my week' }],
  user_preferences: true,
  user_information: true,
  user_context: true,
})

---

Platform-Funded Mode (Developer Pays)

Developers can pay for AI usage on behalf of their users by passing platform_funded: true in the request body. When enabled, the user is not charged — the developer absorbs the cost (model cost + infra allocation) at cost with no markup.

Requirements

  • The developer must have a payment method on file (Stripe).
  • If no payment method is found, the API returns 402 developer_payment_required.

Request Example

body: JSON.stringify({
  model: 'gemini-2.5-flash',
  messages: [{ role: 'user', content: 'Hello!' }],
  platform_funded: true,
})

Response

The _legion object in the response will include:

  • platform_funded: true
  • cost_cents: 0 (user pays nothing)
  • developer_charge_cents: total cost absorbed by the developer

Billing

Platform-funded transactions are recorded with billed=false and collected via the developer's monthly invoice. The developer_charge_cents column tracks the amount owed.


View plain text
Developers can pay for AI usage on behalf of their users by passing platform_funded: true in the request body. When enabled, the user is not charged — the developer absorbs the cost (model cost + infra allocation) at cost with no markup.

### Requirements
- The developer must have a payment method on file (Stripe).
- If no payment method is found, the API returns 402 developer_payment_required.

### Request Example
body: JSON.stringify({
  model: 'gemini-2.5-flash',
  messages: [{ role: 'user', content: 'Hello!' }],
  platform_funded: true,
})

### Response
The _legion object in the response will include:
- platform_funded: true
- cost_cents: 0 (user pays nothing)
- developer_charge_cents: total cost absorbed by the developer

### Billing
Platform-funded transactions are recorded with billed=false and collected via the developer's monthly invoice. The developer_charge_cents column tracks the amount owed.

---

Partner Attribution

Accredited partners can receive a share of Legion's platform margin without increasing the user's cost or reducing the app owner's margin.

Attribution is app-level and editable. When an app is created by a claimed partner account, Legion adds that partner slug to the app's partner list. Non-partner owners may optionally provide a valid partner slug when registering the app; unknown partner slugs are rejected. App owners and active app partners can add or remove accredited partners later. Runtime chat and media calls do not set or override partner attribution.

Eligibility and Accreditation

Accredited partners are approved by Legion and listed in Legion's server-side partner registry. Examples include vibe-coding platforms and AI app-building tools. Eligibility is not self-serve: partner-claim succeeds only when the signed-in account's Clerk verified primary email exactly matches an email in the approved registry entry.

Security notes:

  • Partner claim checks Clerk directly for the verified primary email.
  • Legion does not trust the synced Postgres users.email value for partner or owner claim decisions.
  • A partner slug can only be claimed by one Legion user account.
  • A partner keeps app-admin access only while its slug remains attached to the app.

Revenue Split

Legion adds a fixed 20% platform margin on model cost. The affiliate program is temporarily paused, so new calls do not accrue affiliate shares. Partner share is calculated from the Legion platform margin:

  • No partner: Legion keeps 100% of the platform margin
  • Accredited partner: attached partners share 50%, Legion keeps 50%
  • Unaccredited or unknown partner slug: treated as no partner

The app owner's configured owner margin is separate. Partner attribution does not reduce the app owner's margin and does not increase the end user's price.

When multiple partners are attached to one app, they split the partner pool evenly. Two partners split it in half; three partners split it in thirds. Any odd cent remainder is assigned deterministically.

Partner CLI Workflow

Already-accredited partners can manage client apps from the CLI:

  1. legion login
  2. legion partner-claim
  3. legion partner-status
  4. legion create-app --name "Client Site" --redirect-uris "https://client.com/callback"
  5. legion invite-owner <client_id> --email client@example.com

Use legion partner-connect-start to start Stripe Connect onboarding for partner payouts. The first call can create a Stripe Connect Express account before returning an onboarding URL. Use legion partner-connect-status to check payout verification.

After partner-claim succeeds, new apps created by that account automatically include that partner slug. The optional --partner flag on create-app is for manual attribution by non-partner owners and is not required for claimed partner accounts.

Partner API Reference

All partner endpoints are on the auth server: https://auth.legion-ai.org.

Authentication:

  • Dashboard calls use Clerk authentication.
  • CLI calls use the Legion personal access token saved by legion login.
  • Partner claim still verifies eligibility through Clerk's verified primary email even when called with a CLI token.

POST /v1/partner/claim

  • Claims the accredited partner slug for the authenticated account.
  • Requires the authenticated user's verified Clerk primary email to appear in the partner registry.
  • Returns partner_slug, name, and category.
  • Common errors: 404 not_eligible, 409 already_claimed.

GET /v1/partner/me

  • Returns partner_slug, name, category, pending_cents, total_earned_cents, last_payout_at, connect_onboarded, connect_verified, attributed apps, and per-app earnings breakdown.
  • Common errors: 404 not_partner.

POST /v1/partner/connect/onboard

  • Starts Stripe Connect Express onboarding for partner payouts.
  • Side effect: on the first call, Legion can create a Stripe Connect account before returning the onboarding URL.
  • Returns url, account_id, and whether an existing Connect account was reused.
  • Common errors: 404 not_partner.

GET /v1/partner/connect/status

  • Checks the partner Stripe Connect account status and updates Legion's cached verification state.
  • Returns connected, verified, charges_enabled, payouts_enabled, details_submitted, and status.
  • Common errors: 404 not_partner.

GET /v1/apps/:clientId/partners

  • Lists active partner slugs for an app.
  • Available to the app owner and active app partners.

POST /v1/apps/:clientId/partners

  • Adds an accredited partner slug to an app.
  • Body: { "partner": "lovable" }
  • Idempotent for an already attached partner.
  • Common errors: 400 validation_error, 404 App not found or access denied.

DELETE /v1/apps/:clientId/partners/:partnerSlug

  • Removes a partner slug from an app.
  • Removing the final partner is allowed; after that, only the app owner can add partners back.

Partner CLI Commands

legion partner-claim
- Claim the accredited partner account for the logged-in Legion user.
legion partner-status
- Show partner balance, attributed apps, and per-app earnings.
legion partner-connect-start
- Start Stripe Connect onboarding for partner payouts. The first call may create a Stripe Connect account.
legion partner-connect-status
- Check partner payout verification status.
legion list-partners <client_id>
- List partners attached to an app.
legion add-partner <client_id> --partner lovable
- Add an accredited partner to an app.
legion remove-partner <client_id> --partner lovable
- Remove a partner from an app.
legion create-app --name "Client Site" --redirect-uris "https://client.com/callback"
- Create a client app. Claimed partner accounts are attached automatically.
legion create-app --name "My App" --redirect-uris "https://myapp.com/callback" --partner lovable
- Optional manual attribution for non-partner developers. Claimed partners normally do not need --partner.

View plain text
Accredited partners can receive a share of Legion's platform margin without increasing the user's cost or reducing the app owner's margin.

Attribution is app-level and editable. When an app is created by a claimed partner account, Legion adds that partner slug to the app's partner list. Non-partner owners may optionally provide a valid partner slug when registering the app; unknown partner slugs are rejected. App owners and active app partners can add or remove accredited partners later. Runtime chat and media calls do not set or override partner attribution.

### Eligibility and Accreditation

Accredited partners are approved by Legion and listed in Legion's server-side partner registry. Examples include vibe-coding platforms and AI app-building tools. Eligibility is not self-serve: partner-claim succeeds only when the signed-in account's Clerk verified primary email exactly matches an email in the approved registry entry.

Security notes:
- Partner claim checks Clerk directly for the verified primary email.
- Legion does not trust the synced Postgres users.email value for partner or owner claim decisions.
- A partner slug can only be claimed by one Legion user account.
- A partner keeps app-admin access only while its slug remains attached to the app.

### Revenue Split

Legion adds a fixed 20% platform margin on model cost. The affiliate program is temporarily paused, so new calls do not accrue affiliate shares. Partner share is calculated from the Legion platform margin:

- No partner: Legion keeps 100% of the platform margin
- Accredited partner: attached partners share 50%, Legion keeps 50%
- Unaccredited or unknown partner slug: treated as no partner

The app owner's configured owner margin is separate. Partner attribution does not reduce the app owner's margin and does not increase the end user's price.

When multiple partners are attached to one app, they split the partner pool evenly. Two partners split it in half; three partners split it in thirds. Any odd cent remainder is assigned deterministically.

### Partner CLI Workflow

Already-accredited partners can manage client apps from the CLI:

1. legion login
2. legion partner-claim
3. legion partner-status
4. legion create-app --name "Client Site" --redirect-uris "https://client.com/callback"
5. legion invite-owner <client_id> --email client@example.com

Use legion partner-connect-start to start Stripe Connect onboarding for partner payouts. The first call can create a Stripe Connect Express account before returning an onboarding URL. Use legion partner-connect-status to check payout verification.

After partner-claim succeeds, new apps created by that account automatically include that partner slug. The optional --partner flag on create-app is for manual attribution by non-partner owners and is not required for claimed partner accounts.

### Partner API Reference

All partner endpoints are on the auth server: https://auth.legion-ai.org.

Authentication:
- Dashboard calls use Clerk authentication.
- CLI calls use the Legion personal access token saved by legion login.
- Partner claim still verifies eligibility through Clerk's verified primary email even when called with a CLI token.

POST /v1/partner/claim
- Claims the accredited partner slug for the authenticated account.
- Requires the authenticated user's verified Clerk primary email to appear in the partner registry.
- Returns partner_slug, name, and category.
- Common errors: 404 not_eligible, 409 already_claimed.

GET /v1/partner/me
- Returns partner_slug, name, category, pending_cents, total_earned_cents, last_payout_at, connect_onboarded, connect_verified, attributed apps, and per-app earnings breakdown.
- Common errors: 404 not_partner.

POST /v1/partner/connect/onboard
- Starts Stripe Connect Express onboarding for partner payouts.
- Side effect: on the first call, Legion can create a Stripe Connect account before returning the onboarding URL.
- Returns url, account_id, and whether an existing Connect account was reused.
- Common errors: 404 not_partner.

GET /v1/partner/connect/status
- Checks the partner Stripe Connect account status and updates Legion's cached verification state.
- Returns connected, verified, charges_enabled, payouts_enabled, details_submitted, and status.
- Common errors: 404 not_partner.

GET /v1/apps/:clientId/partners
- Lists active partner slugs for an app.
- Available to the app owner and active app partners.

POST /v1/apps/:clientId/partners
- Adds an accredited partner slug to an app.
- Body: { "partner": "lovable" }
- Idempotent for an already attached partner.
- Common errors: 400 validation_error, 404 App not found or access denied.

DELETE /v1/apps/:clientId/partners/:partnerSlug
- Removes a partner slug from an app.
- Removing the final partner is allowed; after that, only the app owner can add partners back.

### Partner CLI Commands

legion partner-claim
- Claim the accredited partner account for the logged-in Legion user.

legion partner-status
- Show partner balance, attributed apps, and per-app earnings.

legion partner-connect-start
- Start Stripe Connect onboarding for partner payouts. The first call may create a Stripe Connect account.

legion partner-connect-status
- Check partner payout verification status.

legion list-partners <client_id>
- List partners attached to an app.

legion add-partner <client_id> --partner lovable
- Add an accredited partner to an app.

legion remove-partner <client_id> --partner lovable
- Remove a partner from an app.

legion create-app --name "Client Site" --redirect-uris "https://client.com/callback"
- Create a client app. Claimed partner accounts are attached automatically.

legion create-app --name "My App" --redirect-uris "https://myapp.com/callback" --partner lovable
- Optional manual attribution for non-partner developers. Claimed partners normally do not need --partner.

---

Delegated Ownership

Agency or partner-managed apps can assign a separate client owner for payouts. The developer keeps app management access, while the owner only gets read-only earnings and Stripe Connect setup for owner-margin payouts.

Developers invite an owner from the app's Admin area. Legion emails a claim link to the owner. The owner must sign in with the same verified email address, claim the website, then use My websites to view earnings and set up Stripe Connect.

Partners and developers can also send or cancel owner invites from the CLI:

legion invite-owner <client_id> --email client@example.com
legion cancel-owner-invite <client_id>

Client owners should use the emailed web claim page and My websites payout flow; the CLI does not expose client-owner claim or payout commands.

After an owner is assigned, the developer can no longer attach their own Stripe Connect account for that app's owner payouts. Reassignment is handled manually by Legion support.

Delegated Ownership API Reference

POST /v1/apps/:clientId/owner-invite

  • Invites a client owner by email for an app managed by the authenticated developer or partner.
  • Authentication: Clerk or Legion personal access token.
  • Body: { "email": "client@example.com" }
  • Sends an email containing a claim link to https://legion-ai.org/claim-ownership?token=...
  • Returns invite metadata, email_sent, and email_status.
  • Common errors: 400 validation_error, 404 App not found or access denied, 409 invite_pending, 409 owner_already_assigned.

DELETE /v1/apps/:clientId/owner-invite

  • Cancels the active pending unclaimed owner invite for an app.
  • Authentication: Clerk or Legion personal access token.
  • Returns { "cancelled": true } when an invite was cancelled, or false when no active invite existed.

POST /v1/owner-claims/accept

  • Used by the web claim page, not by the CLI.
  • The invitee must sign in with the same verified Clerk primary email that received the invite.
  • Body: { "token": "claim-token-from-email" }
  • Common errors: 403 email_mismatch, 404 invalid_or_expired, 409 owner_already_assigned.

GET /v1/owned-apps

  • Powers the owner's My websites page.
  • Returns read-only app earnings and payout status for apps where the caller is the delegated owner.

POST /v1/owned-apps/:clientId/connect/onboard

  • Starts Stripe Connect onboarding for an app owner payout account.
  • Used by the My websites web flow.

GET /v1/owned-apps/:clientId/connect/status

  • Checks owner payout verification for an owned app.

Delegated Ownership Boundaries

Client owners receive only payout visibility and payout onboarding for owner-margin earnings. They do not receive app keys, client secrets, redirect URI controls, model settings, knowledge base controls, logs, deletion, rotation, or developer/operator access.

Partners and developers keep app management access after assigning an owner. Owner reassignment and support recovery are manual Legion support operations.

Owner Invite CLI Commands

legion invite-owner <client_id> --email client@example.com
- Sends the owner claim email for an app you manage.
legion cancel-owner-invite <client_id>
- Cancels the active pending claim email for an app you manage.

The CLI reports common owner invite conflicts with actionable messages:

  • invite_pending: cancel the existing invite before sending another.
  • owner_already_assigned: the app already has a delegated owner; contact Legion support for reassignment.

View plain text
Agency or partner-managed apps can assign a separate client owner for payouts. The developer keeps app management access, while the owner only gets read-only earnings and Stripe Connect setup for owner-margin payouts.

Developers invite an owner from the app's Admin area. Legion emails a claim link to the owner. The owner must sign in with the same verified email address, claim the website, then use My websites to view earnings and set up Stripe Connect.

Partners and developers can also send or cancel owner invites from the CLI:

legion invite-owner <client_id> --email client@example.com
legion cancel-owner-invite <client_id>

Client owners should use the emailed web claim page and My websites payout flow; the CLI does not expose client-owner claim or payout commands.

After an owner is assigned, the developer can no longer attach their own Stripe Connect account for that app's owner payouts. Reassignment is handled manually by Legion support.

### Delegated Ownership API Reference

POST /v1/apps/:clientId/owner-invite
- Invites a client owner by email for an app managed by the authenticated developer or partner.
- Authentication: Clerk or Legion personal access token.
- Body: { "email": "client@example.com" }
- Sends an email containing a claim link to https://legion-ai.org/claim-ownership?token=...
- Returns invite metadata, email_sent, and email_status.
- Common errors: 400 validation_error, 404 App not found or access denied, 409 invite_pending, 409 owner_already_assigned.

DELETE /v1/apps/:clientId/owner-invite
- Cancels the active pending unclaimed owner invite for an app.
- Authentication: Clerk or Legion personal access token.
- Returns { "cancelled": true } when an invite was cancelled, or false when no active invite existed.

POST /v1/owner-claims/accept
- Used by the web claim page, not by the CLI.
- The invitee must sign in with the same verified Clerk primary email that received the invite.
- Body: { "token": "claim-token-from-email" }
- Common errors: 403 email_mismatch, 404 invalid_or_expired, 409 owner_already_assigned.

GET /v1/owned-apps
- Powers the owner's My websites page.
- Returns read-only app earnings and payout status for apps where the caller is the delegated owner.

POST /v1/owned-apps/:clientId/connect/onboard
- Starts Stripe Connect onboarding for an app owner payout account.
- Used by the My websites web flow.

GET /v1/owned-apps/:clientId/connect/status
- Checks owner payout verification for an owned app.

### Delegated Ownership Boundaries

Client owners receive only payout visibility and payout onboarding for owner-margin earnings. They do not receive app keys, client secrets, redirect URI controls, model settings, knowledge base controls, logs, deletion, rotation, or developer/operator access.

Partners and developers keep app management access after assigning an owner. Owner reassignment and support recovery are manual Legion support operations.

### Owner Invite CLI Commands

legion invite-owner <client_id> --email client@example.com
- Sends the owner claim email for an app you manage.

legion cancel-owner-invite <client_id>
- Cancels the active pending claim email for an app you manage.

The CLI reports common owner invite conflicts with actionable messages:
- invite_pending: cancel the existing invite before sending another.
- owner_already_assigned: the app already has a delegated owner; contact Legion support for reassignment.

---

App Knowledge Base (RAG Context)

Give your app domain-specific knowledge by uploading files. Content is automatically chunked, embedded, and retrieved at query time via hybrid search (semantic + keyword with Reciprocal Rank Fusion).

Supported File Types

  • PDF (.pdf)
  • Word (.docx)
  • PowerPoint (.pptx)
  • Video (.mp4, .mov) — described, embedded, and automatically split into searchable 15–60 second clips when long enough; MOV uploads are converted to MP4 for browser playback
  • Audio (.mp3, .wav) — maximum 14 MiB and 180 seconds; validated before provider work, embedded as one unit, and described/transcribed into bounded semantic text for normal chat and keyword retrieval
  • Images (.png, .jpg, .webp, .gif, .svg, .heic, .heif) — maximum 14 MiB for raster images; embedded as a single unit; HEIC/HEIF is converted to JPEG during processing; SVG uses the 100 MiB document limit

Upload Flow

  1. From My AIs or My Apps, choose Manage knowledge (the developer dashboard uses the same shared manager)
  2. Select files, choose a recursive local folder, drag supported files/folders, or select one or more Drive files. One immutable selection may contain up to 5,000 supported members; there is no aggregate-byte cap. The existing per-file limits remain 100 MiB per document/SVG/video and 14 MiB per raster image or MP3/WAV audio, with audio also limited to 180 seconds. Unsupported entries are excluded with a bounded explanation while valid entries remain selected; 5,001 supported entries are rejected before batch creation
  3. Upload confirmation changes the item from "pending_upload" to "queued"; wait for it to pass through "processing" (and automatic "retrying" when provider capacity is temporary) to "ready"
  4. Each file gets a context_id (e.g., ctx_abc123...) that you can copy

The upload-url response also returns the normalized mime_type that must be used as the PUT Content-Type. This matters for accepted legacy audio aliases such as audio/mp3, which normalize to audio/mpeg.

Legion computes SHA-256 from newly stored or streamed bytes and deduplicates that verified content within one app. Identical direct uploads and Drive imports resolve to one canonical context item and one provider-processing path; hashes never cross app boundaries. Every first or repeated duplicate confirmation returns the provisional context_id, actual app-scoped canonical_context_id, status: duplicate, duplicate: true, and the appropriate idempotent flag. Repeated confirmation does not dispatch provider work or schedule object cleanup twice. List/detail status exposes the durable canonical relationship. Canonical pending, queued, processing, retrying, ready, and failed items are reused deterministically; deleting the canonical releases its hash/source claims so changed or reintroduced bytes can be admitted. Existing legacy items without a verified digest remain usable and are never guessed or merged from filenames or metadata.

The Knowledge section provides separate Add photos and Add files controls so iPhone photo-library selection uses the native image picker, plus Choose folder and recursive drag/drop where the browser supports directory APIs. Safe normalized relative paths remain part of member identity and reselection matching. Google Picker is deliberately file-only: the connector retains narrow per-file drive.file authorization, and production verification showed that selecting a folder did not grant the app access to its descendants. Select multiple Drive files directly; recursive Drive-folder import is not claimed and Legion does not silently request restricted whole-Drive access.

Fresh, reselected, and retried browser files share one FIFO scheduler with at most three active transfers. Files at or above 8 MiB use durable R2 multipart transfer with sequential 8 MiB parts, authoritative listed-part reconciliation, five capped retries with URL refresh, offline pause, acknowledged-byte progress, and durable cancel/abort. Transfer retry reuses the original batch/member/session and uploads only missing parts; a provisional context_id remains a transfer until confirmation, while only confirmed stored processing failures use processing-only retry. Active discovery, reselection, rows, and retry scheduling share scoped batch/member identity, so a transfer is upserted once and successful completion removes its interrupted warning. Completion/cancellation use mutually exclusive leased states and confirmation atomically fences the current session against the uncancelled batch/pending member without holding a transaction across R2. Cancellation remains reclaimable until R2 abort, eligible member termination, and durable governed-deletion ledger creation/recovery converge; only then is the session terminally cancelled. Multipart-owned pending items are excluded from ordinary upload expiry; multipart expiry consistently aborts and governs deletion, including a completed-but-unconfirmed object. Browser descriptor storage accepts arrays only, ignores malformed members, is best-effort, and server discovery remains authoritative. If the browser reloads without retained file access, it lists active sessions and asks the creator to reselect the original path/name/size/mtime before uploading only missing parts. It does not claim background upload after tab closure. Small/legacy uploads retain the verified direct path with bounded retry. If part of a batch fails, successful files remain stored and failed files can be retried individually or together. Processing-only retry also repairs legacy ready standalone audio only when its canonical chunk is contentless and its retained source still exists; healthy ready items remain protected, and a missing source requires re-upload. Confirmed images, audio, and videos are retained in R2 until explicitly deleted; HEIC/HEIF uploads are converted to a durable JPEG, and iPhone MOV uploads to browser-compatible MP4. Ready audio contributes its bounded description/transcript to ordinary app-context retrieval and its original object can be returned through dedicated media search.

Every current dashboard selection is also a durable owner/app-scoped ingest batch. The browser retains one creation identity for the same immutable source/order/path/name/MIME/bytes/Drive identities, computes the exact canonical fingerprint, appends ordered replay-safe pages of 250 members with stable page keys, and explicitly finalizes. Ambiguous shell/page/finalize responses replay safely; Drive work cannot be claimed before sealing. The browser discovers all active batches through bounded cursor pages after reload, also discovers historical batches and active multipart sessions, limits rendered outcomes, and gives each batch one compact non-overlapping poller without a five-minute cutoff. Direct members that never reach upload admission and unconfirmed objects expire durably. Multipart expiry is a separate leased lifecycle duty, so it cannot suppress Drive, outbox, transfer/upload expiry, batch, or deletion reconciliation. Google Drive jobs return 202 before download, refresh credentials only at execution, combine renewable leases with execution deadlines, fence reservation and confirmation/outbox commit against cancellation/lease loss, adopt already-valid authoritative R2 objects on redelivery, and use bounded retry/stale recovery/dead-letter outcomes without putting OAuth tokens in jobs or telemetry.

Using context_ids in API Calls

By default, all app context is searched for every API call. To limit search to specific files, pass context_ids:

A video's uploaded parent context_id remains the stable file-scoped identifier after segmentation: it searches the eligible clips from that parent's current active plan once they become authoritative, and falls back to the parent only while that parent is the valid searchable representation. Supplying a clip's own context_id continues to target that clip directly.

SDK Example

const response = await legion.chat(
  [{ role: 'user', content: 'What does our return policy say?' }],
  {
    model: 'gemini-2.5-flash',
    context_ids: ['ctx_abc123def456', 'ctx_789xyz'],
  }
);

Direct API Example

body: JSON.stringify({
  model: 'gemini-2.5-flash',
  messages: [{ role: 'user', content: 'What does our return policy say?' }],
  context_ids: ['ctx_abc123def456'],
})

How It Works

  • Files are chunked (~1000 tokens with overlap) and embedded using Gemini Embedding 2
  • Plain text and SVG stream with 100 MiB source and 500,000-character extraction ceilings. PDF, DOCX, PPT/PPTX, and XLSX use a single-concurrency isolated parser with a 30-second timeout, Linux CPU/memory/file/process limits, guarded Office archive expansion and structure, a 2,000-page PDF ceiling, bounded diagnostics, and a 500,000-character parent result. A bounded-extraction rejection occurs before contextualisation, embedding, or chunk insertion
  • At ingest, each document gets one bounded synopsis. Bounded structured requests then map chunk indexes to short optional situating preambles without resending the full document for every chunk. Each valid preamble is embedded and keyword-indexed with its raw chunk, so a chunk can be found by document-level terms it never contains; malformed enrichment cannot shift text onto another chunk, and raw chunks remain searchable if enrichment is partial
  • At query time, the user's message is embedded and used for hybrid search (pgvector cosine + tsvector keyword)
  • Retrieval breadth is app-owned (default 8 context seeds and 12 media items; bounded to 20 and 30). Semantic candidates below the app's ordinary-context similarity floor (default 0.35) are removed before ranking; exact keyword-only matches remain eligible. You can change these values in the dashboard Knowledge section or with the settings endpoint below
  • Matching context seeds are expanded into their neighbours and stitched back into document order, subject to hard chunk and formatted-context budgets, so wider retrieval cannot create an unbounded prompt
  • The result is injected into the system message as "[App Knowledge Base]": one header per document carrying its synopsis, then each contiguous run labelled with its position (for example "chunks 13–15 of 32"), with "…" marking a jump between windows
  • Results are cached for 60 seconds under an app cache generation that changes on ready, deletion, and retrieval-settings updates
  • Redis has-context state is only a hint: missing, legacy, negative, and candidate formatted-cache states are checked against the authoritative database generation without generating a query embedding. Stale formatted generations are never returned, and Redis outages do not roll back committed ready/settings/delete mutations

Video Knowledge and Clips

Video duration is validated before embedding. A video over the provider's 120-second full-input ceiling is never sent as one embedding request and moves directly to application-controlled 15–60 second segmentation. Clip fan-out is bounded (24 by default); sources that cannot be fully covered within the configured bound fail instead of being silently truncated. A safely embedded short parent stays searchable while children are outstanding, then the first ready child atomically becomes authoritative. The uploaded parent's context_id follows that handover to eligible clips in its current active plan. The parent remains processing until every required child is terminal; mixed outcomes are reported as partial, and all-child failure restores only a validated parent fallback or fails honestly.

The owner-authenticated context list/detail responses include item_id, parent_item_id, segment_start_s, segment_end_s, metadata, video_aggregate_status, video_expected_children, video_ready_children, video_failed_children, and video_parent_embedding_valid. Child clips have their own context_id. Deleting a parent context item deletes its child rows and stored clip objects as well.

Image Metadata

Uploaded images get metadata extracted automatically during processing, at no extra latency:

  • Technical: width, height, format, orientation, color space, and EXIF (capture time, camera, lens, ISO, GPS) when present
  • Semantic: a caption, tags, any text visible in the image (OCR), notable objects, and dominant colors

The caption and OCR text are also indexed, so images are searchable by what they contain — not just by filename — and the retrieved context describes the image to the model. Extraction is best-effort: if it fails, the image is still embedded and marked ready, with metadata null.

Programmatic Upload (via API)

Drive downloads stream into exclusive request-specific temporary files, reject declared or actual over-limit bytes, and upload to R2 from disk with verified ContentLength. Exact unchanged Drive source versions return their canonical item before download; changed versions download and use app-scoped SHA-256 ownership. Redis dispatch is best effort after the authoritative queued-state/outbox transaction, and post-upload failures retain their storage key in the durable deletion ledger.

POST /v1/apps/:clientId/context/upload-url — get presigned upload URL POST /v1/apps/:clientId/context/confirm — verify the uploaded R2 object and idempotently create one durable processing job GET /v1/apps/:clientId/context — cursor-paginated compact item list; bounded limit plus optional status, modality, source_provider, and filename q filters GET /v1/apps/:clientId/context/:contextId — get item status, metadata, and video parent/segment fields POST /v1/apps/:clientId/context/batches — idempotently create a one-page batch, or an unsealed batch with expected_member_count and manifest_fingerprint POST /v1/apps/:clientId/context/batches/:batchId/pages — append one replay-safe contiguous bounded manifest page POST /v1/apps/:clientId/context/batches/:batchId/finalize — verify the complete fingerprint, seal the manifest, and schedule Drive jobs set-wise GET /v1/apps/:clientId/context/batches — cursor-paginate the caller's batches; active=true limits reload resumption to unfinished batches GET /v1/apps/:clientId/context/batches/:batchId — compact authoritative aggregate status GET /v1/apps/:clientId/context/batches/:batchId/members — cursor-paginate compact member outcomes POST /v1/apps/:clientId/context/batches/:batchId/cancel — cancel eligible outstanding work without cancelling reused canonical content POST /v1/apps/:clientId/context/batches/:batchId/members/:memberId/abandon — report an uncompleted direct transfer POST /v1/apps/:clientId/context/multipart — idempotently initiate/recover a multipart session for one sealed direct-upload batch member GET /v1/apps/:clientId/context/multipart — cursor-paginate active owner/app multipart sessions for reload discovery GET /v1/apps/:clientId/context/multipart/:sessionId — reconcile uploaded parts authoritatively with R2 POST /v1/apps/:clientId/context/multipart/:sessionId/parts/:partNumber — sign the server-derived expected part only POST /v1/apps/:clientId/context/multipart/:sessionId/complete — idempotently complete, verify, deduplicate, reserve, and confirm through the existing outbox path POST /v1/apps/:clientId/context/multipart/:sessionId/cancel — abort incomplete R2 multipart state and start governed provisional-item cleanup POST /v1/apps/:clientId/context/:contextId/retry — retry processing for an existing failed item, or repair legacy ready standalone audio whose canonical chunk is contentless, without uploading again when the retained source still exists GET /v1/apps/:clientId/context/settings — read context_seed_count, media_result_count, context_similarity_floor, and retrieval_cache_generation PATCH /v1/apps/:clientId/context/settings — update one or more numeric retrieval settings (context seeds 1–20, media results 1–30, similarity floor 0–1) DELETE /v1/apps/:clientId/context/:contextId — delete item, chunks, and any child video clips

For paged creation, manifest_fingerprint is lowercase SHA-256 over UTF-8 JSON with this exact shape and key order: {"source_type":sourceType,"members":[{"ordinal":0,"client_member_id":"...","filename":null,"source_file_id":"...","source_mime_type":null,"expected_bytes":null},...]}. Use explicit null for absent immutable fields. Pages must be contiguous from ordinal zero, carry a stable page_key, and may be replayed only with identical content. Finalize rejects gaps, reorderings, duplicates, count changes, or a fingerprint mismatch with stable 409 errors; Drive execution cannot begin before finalize.

Upload lifecycle statuses are pending_upload (presigned but not confirmed), queued, processing, retrying, ready, failed, duplicate, cancelled, and deleting. Batch pending means all members await direct transfer/upload or unclaimed Drive transfer; importing/queued/processing/retrying makes it running. Compact list and batch polling projections deliberately omit metadata, summaries, storage/provider details, and internal errors; the dashboard lazily reads and caches owner-only detail when expanded clip metadata is needed. Detail responses include upload_expires_at while it applies plus processing_attempts, next_retry_at, stable error_code, a safe creator-facing error_message, processing_warning, content_sha256, duplicate_of_item_id, canonical_context_id, and canonical_status. Unadmitted direct transfers and unconfirmed uploads expire durably after their configured deadlines. Confirm returns queued the first time, the current status on safe non-duplicate repeats, or duplicate plus the actual canonical_context_id and duplicate/idempotent flags on every first or repeated duplicate confirmation; it returns 409 upload_not_found if the expected object is absent.

Transient provider capacity, service, timeout, and network failures retry automatically with bounded backoff. A failed item can be retried through the processing-only endpoint, which reuses its storage object and creates one durable job even under simultaneous requests. The only ready exception is standalone legacy audio with no non-empty canonical chunk; the retry transaction verifies the retained source and removes the unusable representation before queueing. Healthy ready audio, non-audio ready items, deleting, pending_upload, queued without an existing manual retry, retrying, processing, cancelled, and missing items are rejected. Missing retained source for legacy remediation returns context_source_missing and requires re-upload. The protected operator cancel control is available only before active processing (queued or retrying) or after failure; active processing cannot be safely interrupted and returns a conflict. Raw provider payloads are never returned to creators.

Direct uploads, downloaded Google Drive imports, processing-only retries, and physical video children are subject to atomic server-side controls. Retained storage, outstanding work, queue depth, upload/import-confirmation-retry rates, and estimated daily provider request/input-token budgets are enforced at app, developer, and global scope as applicable. Audio admission reserves its semantic and embedding calls at the 180-second maximum before trusted probing. Worker governance uses validated duration: Gemini Embedding 2 uses Google's documented 25 audio input tokens per second, while Gemini semantic generation uses Google's documented 32 audio input tokens per second plus Legion's conservative fixed 256-token instruction/configuration-overhead allowance. At 180 seconds this reserves 4,500 embedding and 6,016 semantic tokens, or 10,516 raw estimated input tokens per permitted attempt. These controls do not use Provisioned Throughput burndown multipliers and are not monetary-spend measurements. Pending uploads and durable video clips still being staged reserve outstanding capacity; staged clips do not count as queued until their plan enters the durable outbox. Processing retry reuses the retained object but must pass outstanding, queue, retry-rate, and provider-budget checks. Rejections use a machine-readable error code, retry_after_seconds, and Retry-After; retained-storage limits return 413 and temporary capacity/rate/budget limits return 429. Defaults are conservative safety estimates, not production quota values or a claim of exact monetary spend.

During planned maintenance, direct upload/confirmation, batch creation/page/finalization, multipart initiation/part signing/completion, Google Drive import, and processing retry can be paused globally. These calls return 503 with error context_ingest_paused, retryable: true, and Retry-After: 60. Retry after the indicated interval. Lists, detail, settings, batch/multipart discovery, cancellation/abandonment, retrieval, and deletion remain available.

Deletion is complete only after every retained parent, converted-original, child-clip, and preview object has been removed from storage. A temporary R2 failure returns 503 storage_deletion_pending with retry_scheduled: true while automatic reconciliation retains the storage keys and database rows.

Deleting an OAuth app follows the same durable storage lifecycle for all context items, converted originals, previews, app icons, and chat backgrounds. DELETE /v1/apps/:clientId returns 503 storage_deletion_pending with retry_scheduled: true while an R2 retry is outstanding; the app row is retained until cleanup succeeds. Forced administrative user deletion likewise retains the user while any owned app cleanup is pending and must be retried after the scheduled app deletion completes.

The metadata field on a context item looks like:

{
  technical: { width: 1920, height: 1080, format: 'jpeg', exif: { captured_at, camera, gps } },
  semantic: { subject, caption, tags, ocr_text, objects, dominant_colors },
  extracted_at: '2026-08-02T00:00:00.000Z',
  extractor_version: 2
}
For videos, technical metadata includes duration, dimensions, frame rate, codecs, bitrate, and container; semantic metadata can include summary, scenes, transcript_excerpt, tags, and on_screen_text. Metadata is null when extraction produced nothing and for older items uploaded before extraction was enabled.

View plain text
Give your app domain-specific knowledge by uploading files. Content is automatically chunked, embedded, and retrieved at query time via hybrid search (semantic + keyword with Reciprocal Rank Fusion).

### Supported File Types
- PDF (.pdf)
- Word (.docx)
- PowerPoint (.pptx)
- Video (.mp4, .mov) — described, embedded, and automatically split into searchable 15–60 second clips when long enough; MOV uploads are converted to MP4 for browser playback
- Audio (.mp3, .wav) — maximum 14 MiB and 180 seconds; validated before provider work, embedded as one unit, and described/transcribed into bounded semantic text for normal chat and keyword retrieval
- Images (.png, .jpg, .webp, .gif, .svg, .heic, .heif) — maximum 14 MiB for raster images; embedded as a single unit; HEIC/HEIF is converted to JPEG during processing; SVG uses the 100 MiB document limit

### Upload Flow
1. From My AIs or My Apps, choose Manage knowledge (the developer dashboard uses the same shared manager)
2. Select files, choose a recursive local folder, drag supported files/folders, or select one or more Drive files. One immutable selection may contain up to 5,000 supported members; there is no aggregate-byte cap. The existing per-file limits remain 100 MiB per document/SVG/video and 14 MiB per raster image or MP3/WAV audio, with audio also limited to 180 seconds. Unsupported entries are excluded with a bounded explanation while valid entries remain selected; 5,001 supported entries are rejected before batch creation
3. Upload confirmation changes the item from "pending_upload" to "queued"; wait for it to pass through "processing" (and automatic "retrying" when provider capacity is temporary) to "ready"
4. Each file gets a context_id (e.g., ctx_abc123...) that you can copy

The upload-url response also returns the normalized `mime_type` that must be used as the PUT `Content-Type`. This matters for accepted legacy audio aliases such as `audio/mp3`, which normalize to `audio/mpeg`.

Legion computes SHA-256 from newly stored or streamed bytes and deduplicates that verified content within one app. Identical direct uploads and Drive imports resolve to one canonical context item and one provider-processing path; hashes never cross app boundaries. Every first or repeated duplicate confirmation returns the provisional `context_id`, actual app-scoped `canonical_context_id`, `status: duplicate`, `duplicate: true`, and the appropriate `idempotent` flag. Repeated confirmation does not dispatch provider work or schedule object cleanup twice. List/detail status exposes the durable canonical relationship. Canonical pending, queued, processing, retrying, ready, and failed items are reused deterministically; deleting the canonical releases its hash/source claims so changed or reintroduced bytes can be admitted. Existing legacy items without a verified digest remain usable and are never guessed or merged from filenames or metadata.

The Knowledge section provides separate Add photos and Add files controls so iPhone photo-library selection uses the native image picker, plus Choose folder and recursive drag/drop where the browser supports directory APIs. Safe normalized relative paths remain part of member identity and reselection matching. Google Picker is deliberately file-only: the connector retains narrow per-file `drive.file` authorization, and production verification showed that selecting a folder did not grant the app access to its descendants. Select multiple Drive files directly; recursive Drive-folder import is not claimed and Legion does not silently request restricted whole-Drive access.

Fresh, reselected, and retried browser files share one FIFO scheduler with at most three active transfers. Files at or above 8 MiB use durable R2 multipart transfer with sequential 8 MiB parts, authoritative listed-part reconciliation, five capped retries with URL refresh, offline pause, acknowledged-byte progress, and durable cancel/abort. Transfer retry reuses the original batch/member/session and uploads only missing parts; a provisional context_id remains a transfer until confirmation, while only confirmed stored processing failures use processing-only retry. Active discovery, reselection, rows, and retry scheduling share scoped batch/member identity, so a transfer is upserted once and successful completion removes its interrupted warning. Completion/cancellation use mutually exclusive leased states and confirmation atomically fences the current session against the uncancelled batch/pending member without holding a transaction across R2. Cancellation remains reclaimable until R2 abort, eligible member termination, and durable governed-deletion ledger creation/recovery converge; only then is the session terminally cancelled. Multipart-owned pending items are excluded from ordinary upload expiry; multipart expiry consistently aborts and governs deletion, including a completed-but-unconfirmed object. Browser descriptor storage accepts arrays only, ignores malformed members, is best-effort, and server discovery remains authoritative. If the browser reloads without retained file access, it lists active sessions and asks the creator to reselect the original path/name/size/mtime before uploading only missing parts. It does not claim background upload after tab closure. Small/legacy uploads retain the verified direct path with bounded retry. If part of a batch fails, successful files remain stored and failed files can be retried individually or together. Processing-only retry also repairs legacy ready standalone audio only when its canonical chunk is contentless and its retained source still exists; healthy ready items remain protected, and a missing source requires re-upload. Confirmed images, audio, and videos are retained in R2 until explicitly deleted; HEIC/HEIF uploads are converted to a durable JPEG, and iPhone MOV uploads to browser-compatible MP4. Ready audio contributes its bounded description/transcript to ordinary app-context retrieval and its original object can be returned through dedicated media search.

Every current dashboard selection is also a durable owner/app-scoped ingest batch. The browser retains one creation identity for the same immutable source/order/path/name/MIME/bytes/Drive identities, computes the exact canonical fingerprint, appends ordered replay-safe pages of 250 members with stable page keys, and explicitly finalizes. Ambiguous shell/page/finalize responses replay safely; Drive work cannot be claimed before sealing. The browser discovers all active batches through bounded cursor pages after reload, also discovers historical batches and active multipart sessions, limits rendered outcomes, and gives each batch one compact non-overlapping poller without a five-minute cutoff. Direct members that never reach upload admission and unconfirmed objects expire durably. Multipart expiry is a separate leased lifecycle duty, so it cannot suppress Drive, outbox, transfer/upload expiry, batch, or deletion reconciliation. Google Drive jobs return 202 before download, refresh credentials only at execution, combine renewable leases with execution deadlines, fence reservation and confirmation/outbox commit against cancellation/lease loss, adopt already-valid authoritative R2 objects on redelivery, and use bounded retry/stale recovery/dead-letter outcomes without putting OAuth tokens in jobs or telemetry.

### Using context_ids in API Calls
By default, all app context is searched for every API call. To limit search to specific files, pass context_ids:

A video's uploaded parent context_id remains the stable file-scoped identifier after segmentation: it searches the eligible clips from that parent's current active plan once they become authoritative, and falls back to the parent only while that parent is the valid searchable representation. Supplying a clip's own context_id continues to target that clip directly.

#### SDK Example
const response = await legion.chat(
  [{ role: 'user', content: 'What does our return policy say?' }],
  {
    model: 'gemini-2.5-flash',
    context_ids: ['ctx_abc123def456', 'ctx_789xyz'],
  }
);

#### Direct API Example
body: JSON.stringify({
  model: 'gemini-2.5-flash',
  messages: [{ role: 'user', content: 'What does our return policy say?' }],
  context_ids: ['ctx_abc123def456'],
})

### How It Works
- Files are chunked (~1000 tokens with overlap) and embedded using Gemini Embedding 2
- Plain text and SVG stream with 100 MiB source and 500,000-character extraction ceilings. PDF, DOCX, PPT/PPTX, and XLSX use a single-concurrency isolated parser with a 30-second timeout, Linux CPU/memory/file/process limits, guarded Office archive expansion and structure, a 2,000-page PDF ceiling, bounded diagnostics, and a 500,000-character parent result. A bounded-extraction rejection occurs before contextualisation, embedding, or chunk insertion
- At ingest, each document gets one bounded synopsis. Bounded structured requests then map chunk indexes to short optional situating preambles without resending the full document for every chunk. Each valid preamble is embedded and keyword-indexed with its raw chunk, so a chunk can be found by document-level terms it never contains; malformed enrichment cannot shift text onto another chunk, and raw chunks remain searchable if enrichment is partial
- At query time, the user's message is embedded and used for hybrid search (pgvector cosine + tsvector keyword)
- Retrieval breadth is app-owned (default 8 context seeds and 12 media items; bounded to 20 and 30). Semantic candidates below the app's ordinary-context similarity floor (default 0.35) are removed before ranking; exact keyword-only matches remain eligible. You can change these values in the dashboard Knowledge section or with the settings endpoint below
- Matching context seeds are expanded into their neighbours and stitched back into document order, subject to hard chunk and formatted-context budgets, so wider retrieval cannot create an unbounded prompt
- The result is injected into the system message as "[App Knowledge Base]": one header per document carrying its synopsis, then each contiguous run labelled with its position (for example "chunks 13–15 of 32"), with "…" marking a jump between windows
- Results are cached for 60 seconds under an app cache generation that changes on ready, deletion, and retrieval-settings updates
- Redis has-context state is only a hint: missing, legacy, negative, and candidate formatted-cache states are checked against the authoritative database generation without generating a query embedding. Stale formatted generations are never returned, and Redis outages do not roll back committed ready/settings/delete mutations

### Video Knowledge and Clips
Video duration is validated before embedding. A video over the provider's 120-second full-input ceiling is never sent as one embedding request and moves directly to application-controlled 15–60 second segmentation. Clip fan-out is bounded (24 by default); sources that cannot be fully covered within the configured bound fail instead of being silently truncated. A safely embedded short parent stays searchable while children are outstanding, then the first ready child atomically becomes authoritative. The uploaded parent's context_id follows that handover to eligible clips in its current active plan. The parent remains processing until every required child is terminal; mixed outcomes are reported as partial, and all-child failure restores only a validated parent fallback or fails honestly.

The owner-authenticated context list/detail responses include item_id, parent_item_id, segment_start_s, segment_end_s, metadata, video_aggregate_status, video_expected_children, video_ready_children, video_failed_children, and video_parent_embedding_valid. Child clips have their own context_id. Deleting a parent context item deletes its child rows and stored clip objects as well.

### Image Metadata
Uploaded images get metadata extracted automatically during processing, at no extra latency:
- Technical: width, height, format, orientation, color space, and EXIF (capture time, camera, lens, ISO, GPS) when present
- Semantic: a caption, tags, any text visible in the image (OCR), notable objects, and dominant colors

The caption and OCR text are also indexed, so images are searchable by what they contain — not just by filename — and the retrieved context describes the image to the model. Extraction is best-effort: if it fails, the image is still embedded and marked ready, with metadata null.

### Programmatic Upload (via API)
Drive downloads stream into exclusive request-specific temporary files, reject declared or actual over-limit bytes, and upload to R2 from disk with verified ContentLength. Exact unchanged Drive source versions return their canonical item before download; changed versions download and use app-scoped SHA-256 ownership. Redis dispatch is best effort after the authoritative queued-state/outbox transaction, and post-upload failures retain their storage key in the durable deletion ledger.

POST /v1/apps/:clientId/context/upload-url — get presigned upload URL
POST /v1/apps/:clientId/context/confirm — verify the uploaded R2 object and idempotently create one durable processing job
GET  /v1/apps/:clientId/context — cursor-paginated compact item list; bounded limit plus optional status, modality, source_provider, and filename q filters
GET  /v1/apps/:clientId/context/:contextId — get item status, metadata, and video parent/segment fields
POST /v1/apps/:clientId/context/batches — idempotently create a one-page batch, or an unsealed batch with expected_member_count and manifest_fingerprint
POST /v1/apps/:clientId/context/batches/:batchId/pages — append one replay-safe contiguous bounded manifest page
POST /v1/apps/:clientId/context/batches/:batchId/finalize — verify the complete fingerprint, seal the manifest, and schedule Drive jobs set-wise
GET  /v1/apps/:clientId/context/batches — cursor-paginate the caller's batches; active=true limits reload resumption to unfinished batches
GET  /v1/apps/:clientId/context/batches/:batchId — compact authoritative aggregate status
GET  /v1/apps/:clientId/context/batches/:batchId/members — cursor-paginate compact member outcomes
POST /v1/apps/:clientId/context/batches/:batchId/cancel — cancel eligible outstanding work without cancelling reused canonical content
POST /v1/apps/:clientId/context/batches/:batchId/members/:memberId/abandon — report an uncompleted direct transfer
POST /v1/apps/:clientId/context/multipart — idempotently initiate/recover a multipart session for one sealed direct-upload batch member
GET  /v1/apps/:clientId/context/multipart — cursor-paginate active owner/app multipart sessions for reload discovery
GET  /v1/apps/:clientId/context/multipart/:sessionId — reconcile uploaded parts authoritatively with R2
POST /v1/apps/:clientId/context/multipart/:sessionId/parts/:partNumber — sign the server-derived expected part only
POST /v1/apps/:clientId/context/multipart/:sessionId/complete — idempotently complete, verify, deduplicate, reserve, and confirm through the existing outbox path
POST /v1/apps/:clientId/context/multipart/:sessionId/cancel — abort incomplete R2 multipart state and start governed provisional-item cleanup
POST /v1/apps/:clientId/context/:contextId/retry — retry processing for an existing failed item, or repair legacy ready standalone audio whose canonical chunk is contentless, without uploading again when the retained source still exists
GET  /v1/apps/:clientId/context/settings — read context_seed_count, media_result_count, context_similarity_floor, and retrieval_cache_generation
PATCH /v1/apps/:clientId/context/settings — update one or more numeric retrieval settings (context seeds 1–20, media results 1–30, similarity floor 0–1)
DELETE /v1/apps/:clientId/context/:contextId — delete item, chunks, and any child video clips

For paged creation, manifest_fingerprint is lowercase SHA-256 over UTF-8 JSON with this exact shape and key order: {"source_type":sourceType,"members":[{"ordinal":0,"client_member_id":"...","filename":null,"source_file_id":"...","source_mime_type":null,"expected_bytes":null},...]}. Use explicit null for absent immutable fields. Pages must be contiguous from ordinal zero, carry a stable page_key, and may be replayed only with identical content. Finalize rejects gaps, reorderings, duplicates, count changes, or a fingerprint mismatch with stable 409 errors; Drive execution cannot begin before finalize.

Upload lifecycle statuses are pending_upload (presigned but not confirmed), queued, processing, retrying, ready, failed, duplicate, cancelled, and deleting. Batch `pending` means all members await direct transfer/upload or unclaimed Drive transfer; importing/queued/processing/retrying makes it `running`. Compact list and batch polling projections deliberately omit metadata, summaries, storage/provider details, and internal errors; the dashboard lazily reads and caches owner-only detail when expanded clip metadata is needed. Detail responses include upload_expires_at while it applies plus processing_attempts, next_retry_at, stable error_code, a safe creator-facing error_message, processing_warning, content_sha256, duplicate_of_item_id, canonical_context_id, and canonical_status. Unadmitted direct transfers and unconfirmed uploads expire durably after their configured deadlines. Confirm returns queued the first time, the current status on safe non-duplicate repeats, or duplicate plus the actual canonical_context_id and duplicate/idempotent flags on every first or repeated duplicate confirmation; it returns 409 upload_not_found if the expected object is absent.

Transient provider capacity, service, timeout, and network failures retry automatically with bounded backoff. A failed item can be retried through the processing-only endpoint, which reuses its storage object and creates one durable job even under simultaneous requests. The only ready exception is standalone legacy audio with no non-empty canonical chunk; the retry transaction verifies the retained source and removes the unusable representation before queueing. Healthy ready audio, non-audio ready items, deleting, pending_upload, queued without an existing manual retry, retrying, processing, cancelled, and missing items are rejected. Missing retained source for legacy remediation returns context_source_missing and requires re-upload. The protected operator cancel control is available only before active processing (queued or retrying) or after failure; active processing cannot be safely interrupted and returns a conflict. Raw provider payloads are never returned to creators.

Direct uploads, downloaded Google Drive imports, processing-only retries, and physical video children are subject to atomic server-side controls. Retained storage, outstanding work, queue depth, upload/import-confirmation-retry rates, and estimated daily provider request/input-token budgets are enforced at app, developer, and global scope as applicable. Audio admission reserves its semantic and embedding calls at the 180-second maximum before trusted probing. Worker governance uses validated duration: Gemini Embedding 2 uses Google's documented 25 audio input tokens per second, while Gemini semantic generation uses Google's documented 32 audio input tokens per second plus Legion's conservative fixed 256-token instruction/configuration-overhead allowance. At 180 seconds this reserves 4,500 embedding and 6,016 semantic tokens, or 10,516 raw estimated input tokens per permitted attempt. These controls do not use Provisioned Throughput burndown multipliers and are not monetary-spend measurements. Pending uploads and durable video clips still being staged reserve outstanding capacity; staged clips do not count as queued until their plan enters the durable outbox. Processing retry reuses the retained object but must pass outstanding, queue, retry-rate, and provider-budget checks. Rejections use a machine-readable error code, retry_after_seconds, and Retry-After; retained-storage limits return 413 and temporary capacity/rate/budget limits return 429. Defaults are conservative safety estimates, not production quota values or a claim of exact monetary spend.

During planned maintenance, direct upload/confirmation, batch creation/page/finalization, multipart initiation/part signing/completion, Google Drive import, and processing retry can be paused globally. These calls return 503 with error context_ingest_paused, retryable: true, and Retry-After: 60. Retry after the indicated interval. Lists, detail, settings, batch/multipart discovery, cancellation/abandonment, retrieval, and deletion remain available.

Deletion is complete only after every retained parent, converted-original, child-clip, and preview object has been removed from storage. A temporary R2 failure returns 503 storage_deletion_pending with retry_scheduled: true while automatic reconciliation retains the storage keys and database rows.

Deleting an OAuth app follows the same durable storage lifecycle for all context items, converted originals, previews, app icons, and chat backgrounds. DELETE /v1/apps/:clientId returns 503 storage_deletion_pending with retry_scheduled: true while an R2 retry is outstanding; the app row is retained until cleanup succeeds. Forced administrative user deletion likewise retains the user while any owned app cleanup is pending and must be retried after the scheduled app deletion completes.

The metadata field on a context item looks like:
{
  technical: { width: 1920, height: 1080, format: 'jpeg', exif: { captured_at, camera, gps } },
  semantic: { subject, caption, tags, ocr_text, objects, dominant_colors },
  extracted_at: '2026-08-02T00:00:00.000Z',
  extractor_version: 2
}
For videos, technical metadata includes duration, dimensions, frame rate, codecs, bitrate, and container; semantic metadata can include summary, scenes, transcript_excerpt, tags, and on_screen_text. Metadata is null when extraction produced nothing and for older items uploaded before extraction was enabled.

---

SDK Quick Start

Install the SDK

npm install @legionai/sdk

Basic Usage

import { createLegion } from '@legionai/sdk';
const legion = createLegion({
  clientId: 'YOUR_CLIENT_ID',
  // Optional: callback when tokens are auto-refreshed (persist new tokens)
  onTokenRefresh: (tokens) => saveTokens(tokens),
});
// Connect user's credits (redirectUri is passed here, not in createLegion)
legion.connect({ redirectUri: 'https://yourapp.com/callback' });
// After user returns to your callback, exchange code for tokens
const state = new URLSearchParams(window.location.search).get('state');
const tokens = await legion.exchangeCode(code, { state });
// tokens = { access_token, refresh_token, token_type, expires_in }
// Make API calls (uses your app default model if set; otherwise gemini-2.5-flash during launch)
const response = await legion.chat([
  { role: 'user', content: 'Hello!' }
]);
// Specify a different active launch model
const response2 = await legion.chat(
  [{ role: 'user', content: 'Hello!' }],
  { model: 'gemini-2.5-pro' }
);

View plain text
### Install the SDK
npm install @legionai/sdk

### Basic Usage
import { createLegion } from '@legionai/sdk';

const legion = createLegion({
  clientId: 'YOUR_CLIENT_ID',
  // Optional: callback when tokens are auto-refreshed (persist new tokens)
  onTokenRefresh: (tokens) => saveTokens(tokens),
});

// Connect user's credits (redirectUri is passed here, not in createLegion)
legion.connect({ redirectUri: 'https://yourapp.com/callback' });

// After user returns to your callback, exchange code for tokens
const state = new URLSearchParams(window.location.search).get('state');
const tokens = await legion.exchangeCode(code, { state });
// tokens = { access_token, refresh_token, token_type, expires_in }

// Make API calls (uses your app default model if set; otherwise gemini-2.5-flash during launch)
const response = await legion.chat([
  { role: 'user', content: 'Hello!' }
]);

// Specify a different active launch model
const response2 = await legion.chat(
  [{ role: 'user', content: 'Hello!' }],
  { model: 'gemini-2.5-pro' }
);

---

Next.js Quick Start (@legionai/next)

Drop-in OAuth integration for Next.js App Router apps. Handles the full connect flow, stores tokens in httpOnly cookies, and provides React hooks for connection state.

Install

npm install @legionai/next @legionai/sdk

1. Route Handler — app/api/auth/[...legion]/route.js

import { createLegionHandler } from '@legionai/next';
export const { GET, POST } = createLegionHandler({
  clientId: process.env.LEGION_CLIENT_ID,
  afterCallback: '/dashboard', // where to redirect after OAuth
});
// @legionai/next always uses S256 PKCE. Do not add a client secret here.
// The generated callback URL must still be registered as a redirect URI.

2. Layout — app/layout.js

import { LegionProvider } from '@legionai/next';
export default function Layout({ children }) {
  return <html><body><LegionProvider>{children}</LegionProvider></body></html>;
}

3. Connect Button — any client component

'use client';

import { useLegion } from '@legionai/next';
export function ConnectButton() {
  const { connected, loading, connect, disconnect } = useLegion();
  if (loading) return null;
  return (
    <button onClick={connected ? disconnect : connect}>
      {connected ? 'Disconnect' : 'Connect Legion'}
    </button>
  );
}

4. Custom API Route — app/api/chat/route.js

import { getLegionSession } from '@legionai/next';
export async function POST(request) {
  const session = getLegionSession(request);
  if (!session) return Response.json({ error: 'Not connected' }, { status: 401 });
  const { messages } = await request.json();
  const res = await fetch('https://api.legion-ai.org/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${session.accessToken}`,
    },
    body: JSON.stringify({ model: 'gemini-2.5-flash', messages }),
  });
  return Response.json(await res.json());
}

Handler Config Options

  • clientId (required): Your Legion app client ID
  • afterCallback (default: '/'): Redirect path after successful OAuth
  • basePath (default: '/api/auth/legion'): Route prefix for the catch-all handler
  • cookiePrefix (default: 'legion'): Prefix for cookie names
  • scope (default: 'chat:completions'): OAuth scope
  • authUrl (optional): Override the Legion auth server URL
  • consentUrl (optional): Override the hosted Legion consent URL

How It Works

The catch-all route handles four endpoints automatically:

  • GET /api/auth/legion/connect — Redirects to Legion consent page
  • GET /api/auth/legion/callback — Exchanges code for tokens, sets cookies
  • GET /api/auth/legion/status — Returns { connected: true/false }
  • POST /api/auth/legion/disconnect — Clears cookies

Tokens are stored in httpOnly cookies (never exposed to client JavaScript). The React hook only reflects connection state — it does not access tokens directly.


View plain text
Drop-in OAuth integration for Next.js App Router apps. Handles the full connect flow, stores tokens in httpOnly cookies, and provides React hooks for connection state.

### Install
npm install @legionai/next @legionai/sdk

### 1. Route Handler — app/api/auth/[...legion]/route.js
import { createLegionHandler } from '@legionai/next';

export const { GET, POST } = createLegionHandler({
  clientId: process.env.LEGION_CLIENT_ID,
  afterCallback: '/dashboard', // where to redirect after OAuth
});

// @legionai/next always uses S256 PKCE. Do not add a client secret here.
// The generated callback URL must still be registered as a redirect URI.

### 2. Layout — app/layout.js
import { LegionProvider } from '@legionai/next';

export default function Layout({ children }) {
  return <html><body><LegionProvider>{children}</LegionProvider></body></html>;
}

### 3. Connect Button — any client component
'use client';
import { useLegion } from '@legionai/next';

export function ConnectButton() {
  const { connected, loading, connect, disconnect } = useLegion();
  if (loading) return null;
  return (
    <button onClick={connected ? disconnect : connect}>
      {connected ? 'Disconnect' : 'Connect Legion'}
    </button>
  );
}

### 4. Custom API Route — app/api/chat/route.js
import { getLegionSession } from '@legionai/next';

export async function POST(request) {
  const session = getLegionSession(request);
  if (!session) return Response.json({ error: 'Not connected' }, { status: 401 });

  const { messages } = await request.json();
  const res = await fetch('https://api.legion-ai.org/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${session.accessToken}`,
    },
    body: JSON.stringify({ model: 'gemini-2.5-flash', messages }),
  });
  return Response.json(await res.json());
}

### Handler Config Options
- clientId (required): Your Legion app client ID
- afterCallback (default: '/'): Redirect path after successful OAuth
- basePath (default: '/api/auth/legion'): Route prefix for the catch-all handler
- cookiePrefix (default: 'legion'): Prefix for cookie names
- scope (default: 'chat:completions'): OAuth scope
- authUrl (optional): Override the Legion auth server URL
- consentUrl (optional): Override the hosted Legion consent URL

### How It Works
The catch-all route handles four endpoints automatically:
- GET /api/auth/legion/connect — Redirects to Legion consent page
- GET /api/auth/legion/callback — Exchanges code for tokens, sets cookies
- GET /api/auth/legion/status — Returns { connected: true/false }
- POST /api/auth/legion/disconnect — Clears cookies

Tokens are stored in httpOnly cookies (never exposed to client JavaScript). The React hook only reflects connection state — it does not access tokens directly.

---

Authentication

Token Lifecycle

  • Access tokens expire after 24 hours (86400 seconds)
  • The SDK automatically refreshes tokens on 401 errors using the refresh token
  • When tokens are refreshed, the onTokenRefresh callback is called so you can persist the new tokens
  • If refresh fails, the user must re-authorize via connect()
  • Refresh tokens are single-use — each refresh call returns a new refresh token (rotation)

Manual Token Refresh (Without SDK)

If you're not using the SDK, refresh tokens manually:

const res = await fetch('https://auth.legion-ai.org/v1/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'refresh_token',
    refresh_token: 'YOUR_REFRESH_TOKEN',
  }),
});
const { access_token, refresh_token, expires_in } = await res.json();
// IMPORTANT: Save the new refresh_token — it replaces the old one (single-use rotation)
// client_id is not required for refresh requests

Restoring Sessions

// Load saved tokens (both access and refresh tokens)
legion.setTokens({
  accessToken: saved.access_token,
  refreshToken: saved.refresh_token,
});

View plain text
### Token Lifecycle
- Access tokens expire after 24 hours (86400 seconds)
- The SDK automatically refreshes tokens on 401 errors using the refresh token
- When tokens are refreshed, the onTokenRefresh callback is called so you can persist the new tokens
- If refresh fails, the user must re-authorize via connect()
- Refresh tokens are single-use — each refresh call returns a new refresh token (rotation)

### Manual Token Refresh (Without SDK)
If you're not using the SDK, refresh tokens manually:

const res = await fetch('https://auth.legion-ai.org/v1/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'refresh_token',
    refresh_token: 'YOUR_REFRESH_TOKEN',
  }),
});
const { access_token, refresh_token, expires_in } = await res.json();
// IMPORTANT: Save the new refresh_token — it replaces the old one (single-use rotation)
// client_id is not required for refresh requests

### Restoring Sessions
// Load saved tokens (both access and refresh tokens)
legion.setTokens({
  accessToken: saved.access_token,
  refreshToken: saved.refresh_token,
});

---

Error Handling

StatusCodeAction
401invalid_tokenToken expired. SDK auto-refreshes; if that fails, call connect()
403connection_inactiveUser deactivated. Call connect() to reauthorize
402limit_exceededCall connect({ authorizationIntent: 'increase_cap' })
402payment_requiredCall connect({ authorizationIntent: 'payment_method' })

Recommended Pattern

For any authentication or connection error, call connect() to restart the OAuth flow:

try {
  const response = await legion.chat(messages);
} catch (error) {
  if (error.requiresReauth() || error.isDeactivated()) {
    // Reconnect - this handles reauth and reactivates deactivated connections
    legion.connect({ redirectUri: 'https://yourapp.com/callback' });
  } else if (error.isCapError()) {
    legion.connect({
      redirectUri: 'https://yourapp.com/callback',
      authorizationIntent: 'increase_cap',
    });
  } else if (error.isPaymentMethodError()) {
    legion.connect({
      redirectUri: 'https://yourapp.com/callback',
      authorizationIntent: 'payment_method',
    });
  }
}

View plain text
Status | Code | Action
401 | invalid_token | Token expired. SDK auto-refreshes; if that fails, call connect()
403 | connection_inactive | User deactivated. Call connect() to reauthorize
402 | limit_exceeded | Call connect({ authorizationIntent: 'increase_cap' })
402 | payment_required | Call connect({ authorizationIntent: 'payment_method' })

### Recommended Pattern
For any authentication or connection error, call connect() to restart the OAuth flow:

try {
  const response = await legion.chat(messages);
} catch (error) {
  if (error.requiresReauth() || error.isDeactivated()) {
    // Reconnect - this handles reauth and reactivates deactivated connections
    legion.connect({ redirectUri: 'https://yourapp.com/callback' });
  } else if (error.isCapError()) {
    legion.connect({
      redirectUri: 'https://yourapp.com/callback',
      authorizationIntent: 'increase_cap',
    });
  } else if (error.isPaymentMethodError()) {
    legion.connect({
      redirectUri: 'https://yourapp.com/callback',
      authorizationIntent: 'payment_method',
    });
  }
}

---

Connection States

Active: Normal operation. Make API calls freely. Inactive: User deactivated. Call connect() - reauthorization automatically reactivates. Revoked: Permanently disconnected. Call connect() to start fresh.


View plain text
Active: Normal operation. Make API calls freely.
Inactive: User deactivated. Call connect() - reauthorization automatically reactivates.
Revoked: Permanently disconnected. Call connect() to start fresh.

---

API Reference

createLegion(config)

legion.connect(options)

  • Start OAuth flow (redirects user to consent page).
  • Options: redirectUri, state, scope (default: 'chat:completions'), popup, authorizationIntent (authorize, increase_cap, payment_method)

legion.exchangeCode(code, { state })

  • Exchange authorization code for tokens.
  • Public clients retrieve the PKCE verifier associated with the returned state and send it as code_verifier.
  • Token exchange sends grant_type, code, client_id, and code_verifier. It does not send redirect_uri or client_secret.
  • Sending only grant_type, code, and client_id is not sufficient for public-client codes created with PKCE.
  • Advanced backend clients may instead configure clientSecret and exchange a code issued without PKCE.
  • Returns: { access_token, refresh_token, token_type, expires_in }

legion.setTokens({ accessToken, refreshToken })

  • Set tokens directly (e.g., from storage).

legion.chat(messages, options)

  • Make chat completion request.
  • Default model: your app default if set, otherwise gemini-2.5-flash during launch. Auto-refreshes token on 401.
  • Options: model, temperature, max_tokens, autoRefresh (default: true), user_preferences, user_information, user_context, context_ids, platform_funded

legion.refreshAccessToken()

  • Manually refresh access token using refresh token.
  • Calls onTokenRefresh callback with new tokens.

legion.isConnected()

  • Returns true if SDK has an access token.

legion.disconnect()

  • Clear all tokens from SDK.

View plain text
createLegion(config)
- Create a Legion SDK instance.
- Config: clientId, authUrl (default: https://auth.legion-ai.org), connectUrl (default: https://legion-ai.org/connect), apiUrl (default: https://api.legion-ai.org), onTokenRefresh

legion.connect(options)
- Start OAuth flow (redirects user to consent page).
- Options: redirectUri, state, scope (default: 'chat:completions'), popup, authorizationIntent (authorize, increase_cap, payment_method)

legion.exchangeCode(code, { state })
- Exchange authorization code for tokens.
- Public clients retrieve the PKCE verifier associated with the returned state and send it as code_verifier.
- Token exchange sends grant_type, code, client_id, and code_verifier. It does not send redirect_uri or client_secret.
- Sending only grant_type, code, and client_id is not sufficient for public-client codes created with PKCE.
- Advanced backend clients may instead configure clientSecret and exchange a code issued without PKCE.
- Returns: { access_token, refresh_token, token_type, expires_in }

legion.setTokens({ accessToken, refreshToken })
- Set tokens directly (e.g., from storage).

legion.chat(messages, options)
- Make chat completion request.
- Default model: your app default if set, otherwise gemini-2.5-flash during launch. Auto-refreshes token on 401.
- Options: model, temperature, max_tokens, autoRefresh (default: true), user_preferences, user_information, user_context, context_ids, platform_funded

legion.refreshAccessToken()
- Manually refresh access token using refresh token.
- Calls onTokenRefresh callback with new tokens.

legion.isConnected()
- Returns true if SDK has an access token.

legion.disconnect()
- Clear all tokens from SDK.

---

LegionError

All SDK errors are instances of LegionError with these properties:

  • error.message: Human-readable message
  • error.code: Error code (e.g., 'invalid_token')
  • error.status: HTTP status code
  • error.action: Financial remediation action when supplied
  • error.currentSpendCents / error.currentCapCents: Server-authoritative cap context for limit_exceeded
  • error.metadata: Raw structured API error payload

Helper Methods

  • error.requiresReauth() - Returns true if user needs to reconnect
  • error.isDeactivated() - Returns true if user deactivated the connection
  • error.isBillingError() - Backward-compatible check for any HTTP 402
  • error.isCapError() - Returns true when increase_cap authorization is required
  • error.isPaymentMethodError() - Returns true when payment_method authorization is required

View plain text
All SDK errors are instances of LegionError with these properties:
- error.message: Human-readable message
- error.code: Error code (e.g., 'invalid_token')
- error.status: HTTP status code
- error.action: Financial remediation action when supplied
- error.currentSpendCents / error.currentCapCents: Server-authoritative cap context for limit_exceeded
- error.metadata: Raw structured API error payload

Helper Methods
- error.requiresReauth() - Returns true if user needs to reconnect
- error.isDeactivated() - Returns true if user deactivated the connection
- error.isBillingError() - Backward-compatible check for any HTTP 402
- error.isCapError() - Returns true when increase_cap authorization is required
- error.isPaymentMethodError() - Returns true when payment_method authorization is required

---

AI Agent Quick Start

End-to-end flow for AI agents integrating a new app with Legion:

1. Setup

npm install -g @legionai/cli
legion login
legion create-app --name "My App" --redirect-uris "https://myapp.com/callback" --margin 50

Accredited partners creating apps for clients should first run:

legion partner-claim

2. Save Credentials

The create-app output includes a public client_id and a one-time client_secret. Standard SDK, Next.js, WordPress, and Drupal integrations configure only client_id, then use a registered redirect URI plus S256 PKCE during authorization. Save client_secret only when building an advanced confidential backend OAuth flow.

3. Integrate

npm install @legionai/sdk
Then use createLegion({ clientId }) in your app. See "SDK Quick Start" above for full usage.

4. What Happens by Default on Every API Call

When your app makes a POST /v1/chat/completions call, Legion automatically:

  • Injects user context: user_preferences, user_information, and user_context all default to true. The user's AI preferences, demographics, and query patterns are added to the system message.
  • Searches app knowledge base (RAG): If you've uploaded files, every call automatically runs language-neutral semantic + keyword retrieval. The app's bounded retrieval settings default to 8 context seeds and 12 media results; weak semantic-only matches are excluded by the configured similarity floor, neighbours are stitched in document order, and formatted context has a hard size budget. If no files are uploaded, a self-healing existence cache skips query embedding.
  • Applies owner margin: Your configured markup on model costs (default 50%). Legion separately adds a fixed 20% fee on model cost. You keep 100% of the owner margin.
  • Applies partner attribution: if your app was registered with an accredited partner slug, Legion attributes the partner share automatically.
  • Routes to the active launch provider: all responses are OpenAI-compatible; during launch the active provider catalog is Google-only.

5. How to Control Defaults

  • Opt out of user context: set user_preferences: false, user_information: false, or user_context: false in the request body
  • Limit RAG search to specific files: pass context_ids: ['ctx_abc123'] in the request body
  • Change margin: legion update-app --client-id <id> --margin <pct>
  • Change default model: configure in the dashboard, or pass model in each request
  • Invite a non-technical client owner for payouts: legion invite-owner <client_id> --email client@example.com

6. View Full Docs

Run "legion docs" to print the complete documentation in your terminal.

7. (Optional) Connect Railway Log Source

legion connect-source --name "my-api" --project-id <id> --environment-id <id> --project-token <token>
View plain text
End-to-end flow for AI agents integrating a new app with Legion:

### 1. Setup
npm install -g @legionai/cli
legion login
legion create-app --name "My App" --redirect-uris "https://myapp.com/callback" --margin 50

Accredited partners creating apps for clients should first run:
legion partner-claim

### 2. Save Credentials
The create-app output includes a public client_id and a one-time client_secret.
Standard SDK, Next.js, WordPress, and Drupal integrations configure only client_id, then use a registered redirect URI plus S256 PKCE during authorization.
Save client_secret only when building an advanced confidential backend OAuth flow.

### 3. Integrate
npm install @legionai/sdk
Then use createLegion({ clientId }) in your app. See "SDK Quick Start" above for full usage.

### 4. What Happens by Default on Every API Call
When your app makes a POST /v1/chat/completions call, Legion automatically:
- Injects user context: user_preferences, user_information, and user_context all default to true. The user's AI preferences, demographics, and query patterns are added to the system message.
- Searches app knowledge base (RAG): If you've uploaded files, every call automatically runs language-neutral semantic + keyword retrieval. The app's bounded retrieval settings default to 8 context seeds and 12 media results; weak semantic-only matches are excluded by the configured similarity floor, neighbours are stitched in document order, and formatted context has a hard size budget. If no files are uploaded, a self-healing existence cache skips query embedding.
- Applies owner margin: Your configured markup on model costs (default 50%). Legion separately adds a fixed 20% fee on model cost. You keep 100% of the owner margin.
- Applies partner attribution: if your app was registered with an accredited partner slug, Legion attributes the partner share automatically.
- Routes to the active launch provider: all responses are OpenAI-compatible; during launch the active provider catalog is Google-only.

### 5. How to Control Defaults
- Opt out of user context: set user_preferences: false, user_information: false, or user_context: false in the request body
- Limit RAG search to specific files: pass context_ids: ['ctx_abc123'] in the request body
- Change margin: legion update-app --client-id <id> --margin <pct>
- Change default model: configure in the dashboard, or pass model in each request
- Invite a non-technical client owner for payouts: legion invite-owner <client_id> --email client@example.com

### 6. View Full Docs
Run "legion docs" to print the complete documentation in your terminal.

### 7. (Optional) Connect Railway Log Source
legion connect-source --name "my-api" --project-id <id> --environment-id <id> --project-token <token>

MCP Server (AI Agents)

Manage apps and access deployment logs from AI agents like Claude Code, Cursor, or Windsurf.

Transport: stdio (the MCP server communicates over stdin/stdout)

Quick Setup

  1. Install CLI: npm install -g @legionai/cli
  2. Install MCP server: npm install -g @legionai/mcp
  3. Login: legion login (opens browser, saves token to ~/.legion/config.json)
  4. Add the config below to your editor's MCP settings file
  5. Restart your editor / reload MCP servers

Installation

npm install -g @legionai/cli    # CLI commands (login, app management, log sources)
npm install -g @legionai/mcp    # MCP server (for AI agent integration)

Configuration (Login Required)

Generate your MCP access token in the logged-in dashboard: Developer > Documentation > MCP Server > Get MCP Config

Claude Code (~/.config/claude/mcp.json)

{
  "mcpServers": {
    "legion": {
      "command": "legion-mcp",
      "env": {
        "LEGION_AUTH_SERVER_URL": "https://auth.legion-ai.org",
        "LEGION_ACCESS_TOKEN": "<LOGIN_REQUIRED_TOKEN>"
      }
    }
  }
}

Cursor (~/.cursor/mcp.json)

{
  "mcpServers": {
    "legion": {
      "command": "legion-mcp",
      "env": {
        "LEGION_AUTH_SERVER_URL": "https://auth.legion-ai.org",
        "LEGION_ACCESS_TOKEN": "<LOGIN_REQUIRED_TOKEN>"
      }
    }
  }
}

Windsurf (~/.windsurf/mcp.json)

{
  "mcpServers": {
    "legion": {
      "command": "legion-mcp",
      "env": {
        "LEGION_AUTH_SERVER_URL": "https://auth.legion-ai.org",
        "LEGION_ACCESS_TOKEN": "<LOGIN_REQUIRED_TOKEN>"
      }
    }
  }
}

Available Tools

OAuth App Management:

  • legion_list_oauth_apps: List all OAuth apps on your account
  • legion_get_oauth_app: Get details of an OAuth app by client ID
  • legion_create_oauth_app: Create a new OAuth app
  • legion_update_oauth_app: Update OAuth app name or redirect URIs
  • legion_delete_oauth_app: Delete an OAuth app (revokes all connections)
  • legion_rotate_app_secret: Rotate an OAuth app's client secret (new secret shown once)
  • legion_get_oauth_app_stats: Get usage stats (users, tokens, earnings)
  • legion_get_oauth_app_transactions: Get transaction history (prompts, costs)

API Documentation:

  • legion_get_api_docs: Get full API docs (request format, models, errors)

Deployment Logs (Railway):

  • legion_list_railway_sources: List connected Railway log sources
  • legion_connect_railway_source: Connect a Railway deployment as a log source
  • legion_get_deployment_logs: Fetch deployment logs with filters (level, limit)
  • legion_get_deployment_errors: Fetch error-level deployment logs only
  • legion_disconnect_railway_source: Remove a Railway log source connection

CLI Equivalents (install @legionai/cli)

All MCP tools have CLI counterparts. Examples:

  legion list-apps
  legion create-app --name "My App" --redirect-uris "https://myapp.com/callback"
  legion get-app lgn_abc123
  legion rotate-secret lgn_abc123
  legion app-stats lgn_abc123
  legion partner-claim
  legion partner-status
  legion partner-connect-start
  legion partner-connect-status
  legion invite-owner lgn_abc123 --email client@example.com
  legion cancel-owner-invite lgn_abc123
  legion list-sources
  legion get-logs --source-id conn_xyz
  legion get-errors --source-id conn_xyz

Add --verbose or --debug to any command for HTTP request/response details:

  legion list-apps --verbose

Run "legion --help" for the full command reference.

Example Prompts

  • "List my OAuth apps and their stats"
  • "Create a new OAuth app called my-saas with redirect URI https://myapp.com/callback"
  • "Check my deployment logs for errors"
  • "Connect my Railway deployment for debugging"

View plain text
Manage apps and access deployment logs from AI agents like Claude Code, Cursor, or Windsurf.

Transport: stdio (the MCP server communicates over stdin/stdout)

### Quick Setup
1. Install CLI: npm install -g @legionai/cli
2. Install MCP server: npm install -g @legionai/mcp
3. Login: legion login (opens browser, saves token to ~/.legion/config.json)
4. Add the config below to your editor's MCP settings file
5. Restart your editor / reload MCP servers

### Installation
npm install -g @legionai/cli    # CLI commands (login, app management, log sources)
npm install -g @legionai/mcp    # MCP server (for AI agent integration)

### Configuration (Login Required)
Generate your MCP access token in the logged-in dashboard:
Developer > Documentation > MCP Server > Get MCP Config

#### Claude Code (~/.config/claude/mcp.json)
{
  "mcpServers": {
    "legion": {
      "command": "legion-mcp",
      "env": {
        "LEGION_AUTH_SERVER_URL": "https://auth.legion-ai.org",
        "LEGION_ACCESS_TOKEN": "<LOGIN_REQUIRED_TOKEN>"
      }
    }
  }
}

#### Cursor (~/.cursor/mcp.json)
{
  "mcpServers": {
    "legion": {
      "command": "legion-mcp",
      "env": {
        "LEGION_AUTH_SERVER_URL": "https://auth.legion-ai.org",
        "LEGION_ACCESS_TOKEN": "<LOGIN_REQUIRED_TOKEN>"
      }
    }
  }
}

#### Windsurf (~/.windsurf/mcp.json)
{
  "mcpServers": {
    "legion": {
      "command": "legion-mcp",
      "env": {
        "LEGION_AUTH_SERVER_URL": "https://auth.legion-ai.org",
        "LEGION_ACCESS_TOKEN": "<LOGIN_REQUIRED_TOKEN>"
      }
    }
  }
}

### Available Tools

OAuth App Management:
- legion_list_oauth_apps: List all OAuth apps on your account
- legion_get_oauth_app: Get details of an OAuth app by client ID
- legion_create_oauth_app: Create a new OAuth app
- legion_update_oauth_app: Update OAuth app name or redirect URIs
- legion_delete_oauth_app: Delete an OAuth app (revokes all connections)
- legion_rotate_app_secret: Rotate an OAuth app's client secret (new secret shown once)
- legion_get_oauth_app_stats: Get usage stats (users, tokens, earnings)
- legion_get_oauth_app_transactions: Get transaction history (prompts, costs)

API Documentation:
- legion_get_api_docs: Get full API docs (request format, models, errors)

Deployment Logs (Railway):
- legion_list_railway_sources: List connected Railway log sources
- legion_connect_railway_source: Connect a Railway deployment as a log source
- legion_get_deployment_logs: Fetch deployment logs with filters (level, limit)
- legion_get_deployment_errors: Fetch error-level deployment logs only
- legion_disconnect_railway_source: Remove a Railway log source connection

### CLI Equivalents (install @legionai/cli)
All MCP tools have CLI counterparts. Examples:
  legion list-apps
  legion create-app --name "My App" --redirect-uris "https://myapp.com/callback"
  legion get-app lgn_abc123
  legion rotate-secret lgn_abc123
  legion app-stats lgn_abc123
  legion partner-claim
  legion partner-status
  legion partner-connect-start
  legion partner-connect-status
  legion invite-owner lgn_abc123 --email client@example.com
  legion cancel-owner-invite lgn_abc123
  legion list-sources
  legion get-logs --source-id conn_xyz
  legion get-errors --source-id conn_xyz

Add --verbose or --debug to any command for HTTP request/response details:
  legion list-apps --verbose

Run "legion --help" for the full command reference.

### Example Prompts
- "List my OAuth apps and their stats"
- "Create a new OAuth app called my-saas with redirect URI https://myapp.com/callback"
- "Check my deployment logs for errors"
- "Connect my Railway deployment for debugging"

---

CMS Integrations (WordPress & Drupal)

Add AI chat to your WordPress or Drupal site with zero code. Site visitors authorize through Legion before chatting, and you earn your full configured owner margin.

How It Works

  1. Install the Legion plugin on your site
  2. Enter your Client ID in the plugin settings
  3. Site visitors choose Connect to AI before chatting
  4. You earn your full configured owner margin on every API call

Download Plugins

WordPress plugin details, installation instructions, source-build directions, and external-service disclosures are available at https://legion-ai.org/wordpress.

WordPress Plugin

Installation

  1. Install "Legion AI Connect" from the WordPress.org plugin directory
  2. In WordPress Admin: Plugins > Add New and search for Legion AI Connect
  3. Install and activate "Legion AI Connect"

Configuration

  1. Go to Settings > Legion AI
  2. Enter your Client ID (get it from legion-ai.org/developer)
  3. Copy the displayed Redirect URI
  4. Add this Redirect URI to your Legion app settings
  5. Save changes

Always register the exact URI displayed by WordPress. Sites using plain permalinks use /?legion_callback=1; other sites use /legion-callback.

Usage

Add the chat widget using the shortcode: [legion_chat]

Shortcode options:

  • position: bottom-right, bottom-left, button, inline (default: bottom-right)
  • theme: dark, light, auto (default: dark)
  • placeholder: Custom input placeholder text
  • greeting: Custom welcome message
  • accent_color: Hex color for accent elements
  • trigger_text: Text shown for article-button mode
  • context_ids: Comma or space-separated context IDs searched for this widget only
  • context_text: Inline context sent with each request for this widget only; not added to persistent Legion knowledge
  • context_label: Label for inline context (default: Page context)

Example: [legion_chat position="inline" theme="light" placeholder="Ask me anything..."]

Article-button example: [legion_chat position="button" trigger_text="Chat with this article"]

Article-specific context example: [legion_chat position="inline" context_ids="ctx_abc123" context_text="Paste article context here"]

For longer pasted context or text containing quotation marks, put the context between opening and closing shortcode tags: [legion_chat position="inline"]Paste article context here[/legion_chat]

Drupal Module

Installation

  1. Download legion-drupal.zip from the link above
  2. Extract to /modules/contrib/legion_integration
  3. Enable: drush en legion_integration

Configuration

  1. Go to Admin > Configuration > Services > Legion AI
  2. Enter your Client ID
  3. Copy the Redirect URI and add to your Legion app
  4. Save configuration

Usage

  1. Go to Structure > Block Layout
  2. Place the "Legion AI Chat" block in your desired region
  3. Configure block settings (position, theme, placeholder, greeting, accent color, article-button text)

Drupal content can also embed the widget with the built-in Legion AI chat text filter: [legion_chat position="button" trigger_text="Chat with this article"]

Enable the "Legion AI chat shortcode" filter on trusted text formats before using shortcode embeds in content.

Creating a CMS App

When creating your app in the Legion dashboard:

  1. Select "WordPress" or "Drupal" as the app type
  2. WordPress normally suggests /legion-callback; sites using plain permalinks display /?legion_callback=1 instead
  3. Enter the exact Redirect URI displayed on the plugin settings page

Widget Features

  • Automatic session management (tokens stored in localStorage)
  • Same-tab OAuth flow that returns visitors to the originating page
  • Automatic same-tab reauthorization and return when a saved connection is revoked or expires
  • Automatic same-tab increase-cap and payment-method remediation with explicit user approval
  • Animated thinking indicator while an AI response is pending
  • Safe Markdown formatting for assistant responses
  • Light/dark/auto theme support
  • Floating, inline, and compact article-button display modes
  • Responsive design (works on mobile)
  • Auto error handling (silent token refresh, automatic reauthorization, billing alerts)
  • Zero backend required — public client OAuth, no server-side code
  • No default public-facing credit link in the CMS plugins; marketplace front-end credits remain opt-in

Security Notes

  • Uses Public Client OAuth flow (S256 PKCE, no client_secret in PHP)
  • Tokens stored in browser localStorage
  • WordPress and Drupal OAuth state combine a server-verifiable CMS nonce with a per-tab cryptographically random value
  • CMS callbacks verify the nonce before accepting authorization input; the browser verifies the complete state value before exchanging codes
  • Revoked or expired saved connections clear stale tokens and use a same-tab PKCE redirect back to the originating page
  • Strict redirect URI matching enforced
  • CORS enabled for cross-origin requests

View plain text
Add AI chat to your WordPress or Drupal site with zero code. Site visitors authorize through Legion before chatting, and you earn your full configured owner margin.

### How It Works
1. Install the Legion plugin on your site
2. Enter your Client ID in the plugin settings
3. Site visitors choose Connect to AI before chatting
4. You earn your full configured owner margin on every API call

### Download Plugins
- WordPress: https://wordpress.org/plugins/legion-ai-connect/ (requires WordPress 6.3+ / PHP 7.4+)
- Drupal: https://legion-ai.org/downloads/legion-drupal.zip (requires Drupal 10+ / PHP 8.1+)

WordPress plugin details, installation instructions, source-build directions, and external-service disclosures are available at https://legion-ai.org/wordpress.

### WordPress Plugin

#### Installation
1. Install "Legion AI Connect" from the WordPress.org plugin directory
2. In WordPress Admin: Plugins > Add New and search for Legion AI Connect
3. Install and activate "Legion AI Connect"

#### Configuration
1. Go to Settings > Legion AI
2. Enter your Client ID (get it from legion-ai.org/developer)
3. Copy the displayed Redirect URI
4. Add this Redirect URI to your Legion app settings
5. Save changes

Always register the exact URI displayed by WordPress. Sites using plain permalinks use `/?legion_callback=1`; other sites use `/legion-callback`.

#### Usage
Add the chat widget using the shortcode:
`[legion_chat]`

Shortcode options:
- position: bottom-right, bottom-left, button, inline (default: bottom-right)
- theme: dark, light, auto (default: dark)
- placeholder: Custom input placeholder text
- greeting: Custom welcome message
- accent_color: Hex color for accent elements
- trigger_text: Text shown for article-button mode
- context_ids: Comma or space-separated context IDs searched for this widget only
- context_text: Inline context sent with each request for this widget only; not added to persistent Legion knowledge
- context_label: Label for inline context (default: Page context)

Example:
`[legion_chat position="inline" theme="light" placeholder="Ask me anything..."]`

Article-button example:
`[legion_chat position="button" trigger_text="Chat with this article"]`

Article-specific context example:
`[legion_chat position="inline" context_ids="ctx_abc123" context_text="Paste article context here"]`

For longer pasted context or text containing quotation marks, put the context between opening and closing shortcode tags:
`[legion_chat position="inline"]Paste article context here[/legion_chat]`

### Drupal Module

#### Installation
1. Download legion-drupal.zip from the link above
2. Extract to /modules/contrib/legion_integration
3. Enable: drush en legion_integration

#### Configuration
1. Go to Admin > Configuration > Services > Legion AI
2. Enter your Client ID
3. Copy the Redirect URI and add to your Legion app
4. Save configuration

#### Usage
1. Go to Structure > Block Layout
2. Place the "Legion AI Chat" block in your desired region
3. Configure block settings (position, theme, placeholder, greeting, accent color, article-button text)

Drupal content can also embed the widget with the built-in Legion AI chat text filter:
`[legion_chat position="button" trigger_text="Chat with this article"]`

Enable the "Legion AI chat shortcode" filter on trusted text formats before using shortcode embeds in content.

### Creating a CMS App

When creating your app in the Legion dashboard:
1. Select "WordPress" or "Drupal" as the app type
2. WordPress normally suggests /legion-callback; sites using plain permalinks display /?legion_callback=1 instead
3. Enter the exact Redirect URI displayed on the plugin settings page

### Widget Features
- Automatic session management (tokens stored in localStorage)
- Same-tab OAuth flow that returns visitors to the originating page
- Automatic same-tab reauthorization and return when a saved connection is revoked or expires
- Automatic same-tab increase-cap and payment-method remediation with explicit user approval
- Animated thinking indicator while an AI response is pending
- Safe Markdown formatting for assistant responses
- Light/dark/auto theme support
- Floating, inline, and compact article-button display modes
- Responsive design (works on mobile)
- Auto error handling (silent token refresh, automatic reauthorization, billing alerts)
- Zero backend required — public client OAuth, no server-side code
- No default public-facing credit link in the CMS plugins; marketplace front-end credits remain opt-in

### Security Notes
- Uses Public Client OAuth flow (S256 PKCE, no client_secret in PHP)
- Tokens stored in browser localStorage
- WordPress and Drupal OAuth state combine a server-verifiable CMS nonce with a per-tab cryptographically random value
- CMS callbacks verify the nonce before accepting authorization input; the browser verifies the complete state value before exchanging codes
- Revoked or expired saved connections clear stale tokens and use a same-tab PKCE redirect back to the originating page
- Strict redirect URI matching enforced
- CORS enabled for cross-origin requests

---

Chat AI (Hosted Chat Apps)

Create a hosted chat app on Legion — no SDK integration needed. Users chat directly at https://legion-ai.org/chat/:slug and authorize it through Legion's standard consent screen on first use.

Creating a Chat App

  1. Go to https://legion-ai.org/developer and click "Create a Chat AI"
  2. Or use the public registration page at https://legion-ai.org/register-chat
  3. Configure a system prompt in the app detail view
  4. In Admin → Appearance, optionally upload a chat icon/background and choose the font and text color used by conversation messages
  5. Share the chat link: https://legion-ai.org/chat/your-app-name

Chat apps use app_type='chat' and appear with a purple "Chat" badge in the developer dashboard.

Each chat app gets a readable slug derived from its name (for example "Chef Bot" becomes chef-bot; if that slug is taken, a short suffix is added like chef-bot-a7f3). The slug is fixed when the app is created, so renaming the app does not change its URL — the UUID is the permanent link, and keeping the slug stable keeps already-shared links alive. The app's UUID also still works in the URL: any older https://legion-ai.org/chat/UUID link redirects to the slug URL (server-side, preserving the query string), so shared links and social unfurls resolve to the canonical address.

Chat icons and background images are presentation-only uploads associated with the chat and authenticated admin. They are stored separately from knowledge uploads: Legion does not extract metadata, create embeddings, index them for search, or expose them as creator posts. Replacing or removing a managed image also removes the superseded stored object. Icon uploads accept PNG, JPEG, WebP, GIF, or SVG; backgrounds accept PNG, JPEG, WebP, or GIF; both are limited to 5 MB.

Message text colors use six-digit hex values and affect assistant/user message bodies plus assistant media captions; bubbled user messages remain white. When a background image is present, the hosted chat automatically selects a light or dark overlay based on the configured message text color to preserve contrast.

The message-font selector includes System, Serif, Monospace, Rounded, Inter, Roboto, Open Sans, Lato, Nunito, Space Grotesk, Merriweather, Playfair Display, Lora, Pirata One (blackletter Gothic), and League Gothic. The choice affects user and assistant message content only—not chat navigation, prompts, composer controls, or typography elsewhere in Legion.

Chat API Endpoints

All endpoints are under /v1/chat/:appId on the auth server (https://auth.legion-ai.org). The :appId segment accepts either the app's slug or its UUID.

GET /v1/chat/:appId/info

  • Public (no auth required)
  • Returns chat app metadata: name, description, whether a system prompt is configured

POST /v1/chat/:appId/connect

  • Requires Clerk authentication
  • Returns an access token for users with an active app connection
  • Returns a hosted consent URL when the user needs to authorize or re-authorize the chat app

POST /v1/chat/:appId/conversations

  • Requires auth
  • Creates a new conversation, returns conversation ID

GET /v1/chat/:appId/conversations

  • Requires auth
  • Lists the user's conversations for this chat app

GET /v1/chat/:appId/conversations/:id/messages

  • Requires auth
  • Returns the most recent messages for a conversation, in chronological order
  • Optional ?limit (default 200, max 500) — the newest N messages are returned
  • Response: { messages, total, truncated } where truncated is true if the

conversation has more messages than were returned

POST /v1/chat/:appId/conversations/:id/messages

  • Requires auth
  • Saves messages (user + assistant) to a conversation
View plain text
Create a hosted chat app on Legion — no SDK integration needed. Users chat directly at https://legion-ai.org/chat/:slug and authorize it through Legion's standard consent screen on first use.

### Creating a Chat App
1. Go to https://legion-ai.org/developer and click "Create a Chat AI"
2. Or use the public registration page at https://legion-ai.org/register-chat
3. Configure a system prompt in the app detail view
4. In Admin → Appearance, optionally upload a chat icon/background and choose the font and text color used by conversation messages
5. Share the chat link: https://legion-ai.org/chat/your-app-name

Chat apps use app_type='chat' and appear with a purple "Chat" badge in the developer dashboard.

Each chat app gets a readable slug derived from its name (for example "Chef Bot" becomes chef-bot; if that slug is taken, a short suffix is added like chef-bot-a7f3). The slug is fixed when the app is created, so renaming the app does not change its URL — the UUID is the permanent link, and keeping the slug stable keeps already-shared links alive. The app's UUID also still works in the URL: any older https://legion-ai.org/chat/UUID link redirects to the slug URL (server-side, preserving the query string), so shared links and social unfurls resolve to the canonical address.

Chat icons and background images are presentation-only uploads associated with the chat and authenticated admin. They are stored separately from knowledge uploads: Legion does not extract metadata, create embeddings, index them for search, or expose them as creator posts. Replacing or removing a managed image also removes the superseded stored object. Icon uploads accept PNG, JPEG, WebP, GIF, or SVG; backgrounds accept PNG, JPEG, WebP, or GIF; both are limited to 5 MB.

Message text colors use six-digit hex values and affect assistant/user message bodies plus assistant media captions; bubbled user messages remain white. When a background image is present, the hosted chat automatically selects a light or dark overlay based on the configured message text color to preserve contrast.

The message-font selector includes System, Serif, Monospace, Rounded, Inter, Roboto, Open Sans, Lato, Nunito, Space Grotesk, Merriweather, Playfair Display, Lora, Pirata One (blackletter Gothic), and League Gothic. The choice affects user and assistant message content only—not chat navigation, prompts, composer controls, or typography elsewhere in Legion.

### Chat API Endpoints

All endpoints are under /v1/chat/:appId on the auth server (https://auth.legion-ai.org). The :appId segment accepts either the app's slug or its UUID.

GET /v1/chat/:appId/info
- Public (no auth required)
- Returns chat app metadata: name, description, whether a system prompt is configured

POST /v1/chat/:appId/connect
- Requires Clerk authentication
- Returns an access token for users with an active app connection
- Returns a hosted consent URL when the user needs to authorize or re-authorize the chat app

POST /v1/chat/:appId/conversations
- Requires auth
- Creates a new conversation, returns conversation ID

GET /v1/chat/:appId/conversations
- Requires auth
- Lists the user's conversations for this chat app

GET /v1/chat/:appId/conversations/:id/messages
- Requires auth
- Returns the most recent messages for a conversation, in chronological order
- Optional ?limit (default 200, max 500) — the newest N messages are returned
- Response: { messages, total, truncated } where truncated is true if the
  conversation has more messages than were returned

POST /v1/chat/:appId/conversations/:id/messages
- Requires auth
- Saves messages (user + assistant) to a conversation